diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 893e5dd..8b95e9d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -8,7 +8,8 @@ - **Backend API** — FastAPI (Python 3.12), uvicorn - **Tile Server** — статические raster tiles (PNG), раздаются через FastAPI/nginx - **Routing Engine** — OSRM с кастомным эндуро-профилем -- **Database** — SQLite + Spatialite (точки интереса, маршруты) +- **Database** — SQLite + Spatialite (точки интереса, маршруты, публичные GPS-треки) +- **GPS Tracks Pipeline** — `gps-collector` (docker-compose service, `profiles: [batch]`), запускается host cron'ом 1–2 раза в неделю; собирает публичные GPS-треки с внешних платформ в `data/gps_tracks.sqlite` (ET-008 / ADR-007) ## Слои карты - Base map: **Схема** (OpenStreetMap raster) либо **Спутник** (Esri World Imagery raster) — переключается в UI (ET-007 / ADR-004) @@ -30,6 +31,44 @@ Атрибуция обоих провайдеров выводится MapLibre автоматически при активном source. +## GPS Tracks Pipeline (ET-008) + +Серверный офлайн-pipeline сбора публичных GPS-треков. Не часть runtime +API, изолирован отдельным docker-compose service'ом и отдельной БД. + +### Компонент + +- Сервис: `gps-collector` в `docker-compose.yml`, `profiles: ["batch"]`, + тот же образ что `app`, не стартует при `docker compose up -d`. +- Точка входа: `scripts/gps_collect.py` (см. `src/api/gps_tracks/`). +- Расписание: cron на mva154, Mon + Thu 03:00 UTC; + ежемесячный GC. +- БД: `data/gps_tracks.sqlite` (SQLite + Spatialite, отдельный файл от + `centralfederal.sqlite`). + +### Внешние источники pipeline + +Скрейпинг/API только из контейнера `gps-collector`, при наличии +accepted-ADR на источник. + +| Источник | Доступ | Лицензия | ADR | MVP | +|---|---|---|---|---| +| OSM Public GPS Traces | API `api.openstreetmap.org/api/0.6/trackpoints` | ODbL | ADR-009 (accepted) | да | +| EnduroRussia.ru | HTML + GPX-ссылки | требует review | ADR-010 (proposed/blocked) | условно | +| ttrails.ru / Тропинки.ру | HTML + GPX-ссылки | требует review | ADR-011 (proposed/blocked) | условно | + +Источник без `status: accepted` в ADR pipeline'ом **пропускается** (см. +ADR-007 §6 licensing guard). + +### Клиентский слой публичных треков + +Двухрежимная отдача (см. ADR-008): +- z=8..11 — MVT через `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` + сервер-LRU. +- z≥12 — GeoJSON через `GET /api/gps-tracks?bbox=...&activity=...&source=...`. +- z<8 — слой скрыт (защита от шторма запросов). + +Health/observability: `GET /api/gps-tracks/health` — состояние БД, +число треков по источникам, последний прогон. + ## Деплой Один Docker Compose на mva154. Nginx проксирует /enduro/ на контейнер. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index c91bad5..1c4d28e 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -8,3 +8,10 @@ | ADR-002 | GPX-фича как отдельный модуль `gpx.js` | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md) | | ADR-003 | Парсинг GPX — `DOMParser` в основном потоке с чанковой конвертацией | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-003-gpx-parsing-strategy.md) | | ADR-004 | Спутниковая подложка: Esri World Imagery, ленивый raster-source, гибридное halo | accepted | 2026-05-31 | [ET-007](../../work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md) | +| ADR-005 | Хранение публичных GPS-треков: отдельная БД `data/gps_tracks.sqlite`, единая таблица, sources как JSON-массив (arch:major-change) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-005-storage-schema.md) | +| ADR-006 | Дедупликация GPS-треков: bbox+length+date bucket-hash, мерж sources при коллизии, без геометрических метрик | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md) | +| ADR-007 | Pipeline сбора GPS-треков: docker-compose service `gps-collector` (profiles:[batch]), запуск host cron'ом, per-source изоляция (arch:major-change) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md) | +| ADR-008 | Двухрежимная отдача публичных треков: MVT на z≤11, GeoJSON на z≥12, клиентский AbortController+debounce | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md) | +| ADR-009 | OSM Public GPS Traces — licensing: ODbL, accepted для MVP | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-009-osm-licensing.md) | +| ADR-010 | EnduroRussia.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md) | +| ADR-011 | ttrails.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md) | diff --git a/docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md b/docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md new file mode 100644 index 0000000..290f41b --- /dev/null +++ b/docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md @@ -0,0 +1,169 @@ +--- +type: adr +work_item_id: ET-008 +adr_id: ADR-005 +title: "ADR-005: Хранение публичных GPS-треков — отдельная БД data/gps_tracks.sqlite, SQLite+Spatialite, общая схема для всех источников, sources как JSON-массив" +status: accepted +created_at: 2026-06-01 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "arch:major-change" +--- + +# ADR-005 — Схема хранения публичных GPS-треков + +## Статус + +Accepted + +## Контекст + +ET-008 вводит новый класс данных в проект — **публичные GPS-треки**, агрегированные офлайн-pipeline'ом из ≥ 3 внешних источников по региону MVP (ЦФО + Чувашия). По BRD §3 целевой объём — ≥ 5000 треков, по BRD §6 предел — несколько ГБ на регион при дальнейшем расширении. По BRD §1 модель данных не пересекается с существующими сущностями: + +- vector-tile слой `trails` (`data/centralfederal.sqlite`) — OSM-дороги/тропы, отдельный формат, отдельный pipeline (osm2pgsql-like); +- личные GPX-треки (ET-006) — живут только в памяти браузера (`window.gpxTracks`), на сервере не хранятся; +- POI и маршруты (PH-1/2) — другие сущности `centralfederal.sqlite`. + +Архитектурно нужно решить: + +1. **Где хранить** — в существующей `centralfederal.sqlite` или отдельным файлом. +2. **Как организовать схему** — одна таблица на все источники или партиционирование по источнику. +3. **Как хранить мульти-источник** (трек найден в N платформах после дедупа) — нормализованная таблица `track_sources` или JSON-массив в основной таблице. +4. **Какие индексы** дают приемлемый p95 ≤ 300 мс на bbox-запрос с фильтрами. +5. **Совместимость с MVT-generation pipeline'ом**, уже существующим в `src/api/main.py` для `/api/tiles/{z}/{x}/{y}.mvt`. + +## Рассмотренные варианты + +### Вариант D (Database) — где хранить + +- **D-A — отдельный файл `data/gps_tracks.sqlite`** (выбран, совпадает с BRD §7 и TRZ §8 ADR-001-recommendation). + Плюсы: + - Pipeline пишет в свою БД — нет блокировок write на `centralfederal.sqlite`, который активно читается API под нагрузкой раздачи MVT. + - Независимый цикл бэкапа (см. `07-infra-requirements.md` §4): `gps_tracks.sqlite` бэкапится ежедневно, `centralfederal.sqlite` — после редкой ребилд-сессии OSM-данных. + - Независимая ротация: ретеншн 5 лет (REQ-NF-03) применяется только к одной БД; `centralfederal.sqlite` пересобирается из OSM по своему графику. + - Изоляция риска при ошибке pipeline — нельзя случайно повредить OSM-данные. + - В будущем (BRD §6 риск роста до миллионов треков) переход на PostGIS затрагивает один файл, а не корневую БД. + Минусы: + - Второй коннект из FastAPI (мелкая сложность, ~10 строк в `main.py`). + - При совместных запросах «дороги OSM × публичные треки рядом» (PH-3 Smart Route) — кросс-БД JOIN неэффективен. Принято: на горизонте MVP таких запросов нет; в PH-3 решается отдельным ADR (вариант: `ATTACH DATABASE` или денормализация в материализованную таблицу). + +- **D-B — в существующую `centralfederal.sqlite`, отдельные таблицы `gps_tracks_*`**. Отклонён: + - Pipeline writer и MVT reader конкурируют за один файл; SQLite WAL смягчает, но не устраняет. + - Backup-цикл становится зависимым: невозможно ребилдить OSM-данные не «остановив» pipeline. + - Сценарий «удалить весь gps-датасет и пересобрать» (R-3 ниже) требует `DROP TABLE` в большой production-БД; в отдельном файле — `rm data/gps_tracks.sqlite && python scripts/gps_collect.py`. + +- **D-C — PostGIS**. Отклонён: + - BRD §1 «SQLite по умолчанию, PostgreSQL когда нужно». ≥ 5000 треков для ЦФО легко влезают в SQLite (оценочно ≤ 500 МБ при средней геометрии 1240 точек × 16 байт). Spatialite даёт BLOB+R-tree, чего хватает для всех запросов TRZ. + - Введение PostgreSQL — новый класс инфры (контейнер + бэкап + миграции через alembic). Это `arch:major-change` уровня всего проекта; ET-008 такого не требует. + +### Вариант T (Table layout) — одна или несколько таблиц + +- **T-A — единая таблица `tracks`** (выбран). Поля per-источник денормализованы в JSON-колонки. Все источники приводятся к общему контракту в `models.py::Track` (TRZ §7). + Плюсы: + - Самый простой bbox-запрос: один SELECT с одним bbox-фильтром. + - Дедупликация на уровне БД через UNIQUE-индекс по `dedup_key` (TRZ REQ-F-08). + - MVT-генерация на низком зуме — одно сканирование R-tree → одна `LineString → MVT` петля. +- **T-B — таблица на источник + view `tracks_all UNION ALL ...`**. Отклонён: + - Дедупликация между источниками превращается в кросс-таблицу процедуру. + - Изменение списка источников требует DDL-миграции, что блокирует «расширяемость на новый регион ≤ 30 строк YAML без правки кода» (BRD-метрика). + +### Вариант S (Sources field) — как хранить N источников у одного трека + +- **S-A — JSON-массив в колонках `sources_json`, `external_urls_json`** (выбран, совпадает с TRZ REQ-F-09). + Плюсы: + - Запись/чтение трека — атомарная операция. + - При мерже дубликата `UPDATE sources_json = json_array_union(...)` через Python-сторону (без JSON1-функций SQLite, чтобы не зависеть от SQLite-версии). + - Фильтр API «source=osm,ttrails» работает через bbox-prefetch + Python-постфильтр (≤ 500 треков на bbox — это O(500) проверка `'osm' in sources`, ничтожно). + Минусы: + - Невозможно индексировать массив без JSON1; нет нативного `WHERE 'osm' = ANY(sources)`. Принято: на BRD-объёме это не узкое место. +- **S-B — нормализованная таблица `track_sources(track_id, source_id, ext_url)`**. Отклонён: + - JOIN на каждый bbox-запрос (1 → N запись на трек) +30–60% к p95. + - Усложняет API: GeoJSON-формирование требует aggregate-функции (`group_concat`) → лишний SQL. + - Не даёт значимого выигрыша на BRD-объёме (≤ 5–10 источников на трек после дедупа в худшем случае). + +### Вариант I (Indexes) — как ускорить bbox-фильтр + +- **I-A — Spatialite R-tree через виртуальную таблицу `idx_tracks_geom` + обычный B-tree на `activity_type`** (выбран). + - R-tree даёт O(log n) на bbox-prefetch. + - `idx_tracks_activity` ускоряет fallback-фильтр. + - `created_at` — обычный B-tree для GC и для health-отчёта. +- **I-B — четыре B-tree-индекса на `min_lon`, `max_lon`, `min_lat`, `max_lat`** (вариант из TRZ REQ-F-09). Отклонён: + - SQLite-оптимизатор не комбинирует 4 индекса в bbox-плане; в лучшем случае использует один (по `min_lon`), что даёт линейный полу-скан. + - R-tree через Spatialite — стандартный паттерн для spatial-запросов; уже используется в `centralfederal.sqlite` (`idx_features_geom`). + +### Вариант W (WAL) — режим записи + +- **W-A — WAL-mode постоянно** (выбран). При запуске pipeline `PRAGMA journal_mode=WAL`. Даёт читателям (FastAPI) видеть консистентный снэпшот пока pipeline пишет. +- **W-B — DELETE-mode + блокировка читателей на время прогона**. Отклонён: означает простой `/api/gps-tracks` на 1–6 часов в неделю. + +## Решение + +Принимается комбинация: **D-A + T-A + S-A + I-A + W-A**. + +1. **Отдельная БД `data/gps_tracks.sqlite`** (Spatialite-extension загружается при коннекте). Путь в окружении — `GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite` (см. `07-infra-requirements.md` §5). + +2. **Единая таблица `tracks`** со схемой, зафиксированной в `08-data-requirements.md` §3. Уточнения относительно TRZ REQ-F-09: + - `points_count` и `length_m` — посчитанные на pipeline (НФТ Endpoint p95 ≤ 300 мс не оставляет бюджета считать длину на лету). + - `min_lon/max_lon/min_lat/max_lat` сохраняются денормализованно вместе с R-tree (избыточно, но ускоряет MVT-генерацию: можно отбросить трек до `wkb_to_coords()` если bbox целиком вне тайла). + - `tags_json`, `description` — допускается NULL (не все источники их отдают). + - `user` (имя автора) сохраняется **только если** ADR licensing соответствующего источника явно разрешает (см. ADR-009/010/011). Иначе — NULL. + +3. **`sources_json` и `external_urls_json` — JSON-массивы** строк, длина ≤ 8 элементов (дополнительные источники после дедупа). Порядок — стабильный (по `gps_sources.yaml`), что фиксирует «первый» источник для MVT-фичи `properties.source` (используется для цветовой палитры по умолчанию, REQ-F-16). + +4. **Индексация:** + - Spatialite R-tree `idx_tracks_geom` через `CreateSpatialIndex('tracks', 'geom')`. + - B-tree `idx_tracks_activity(activity_type)`. + - B-tree `idx_tracks_created(created_at)` для GC и health. + - UNIQUE `idx_tracks_dedup(dedup_key)` — критичен для ON CONFLICT логики dedup (ADR-006). + - Дополнительный bbox-индекс из TRZ REQ-F-09 (`min_lon, max_lon, min_lat, max_lat`) **не создаётся** — R-tree его покрывает; B-tree на 4 колонки даст overhead на INSERT без выгоды на SELECT. + +5. **WAL-mode** включается в `db.py::open_connection()` через `PRAGMA journal_mode=WAL` при первом запуске; повторно команда no-op. Pipeline пишет в WAL, читатели видят последний checkpoint. После каждого `(region, source)` pipeline вызывает `PRAGMA wal_checkpoint(PASSIVE)` для контроля размера WAL-файла. + +6. **Размер БД** оценивается ≤ 2 ГБ для ЦФО+Чувашии при ≥ 5000 треков (REQ-NF-03). Метрика `db_size_mb` — в `/api/gps-tracks/health` (REQ-F-12), порог-алерт > 2 ГБ — в `10-tech-risks.md` R-4. + +7. **Pipeline-история** — таблица `pipeline_runs` (TRZ REQ-F-09) в той же БД. Используется только для health-эндпоинта и оператора. Не индексируется по region/source — её объём ≤ 10⁴ строк за годы. + +8. **Совместимость с MVT-pipeline в `main.py`.** Утилитарные функции `tile_to_bbox`, `wkb_to_coords`, `simplify_coords` уже существуют в `src/api/main.py` для слоя `trails`. ET-008 **не рефакторит** их (out of scope, риск регрессии слоя `trails`). Вместо этого: + - В `src/api/gps_tracks/mvt.py` функции `_tile_to_bbox` / `_wkb_to_coords` дублируются с TODO-комментарием и ссылкой на тех-долг (`10-tech-risks.md` R-7). + - Если в будущей фазе появится третий MVT-источник (BRD §1 «Видеть реальные дороги/тропы»), перед ним вводится shared-модуль `src/api/tiles_util.py` отдельным work item. + +9. **Cross-DB запросы (PH-3)** — out of scope. Принципиальный путь, если понадобится в Smart Route: `ATTACH DATABASE 'data/gps_tracks.sqlite' AS gps` в коннекте main-API. Это решение откладывается до конкретной задачи PH-3. + +## Последствия + +### Положительные + +- Pipeline пишет, не блокируя API-чтения OSM-данных. +- Бэкап и ротация независимы — оператор управляет каждой БД отдельно. +- Расширение списка источников (BRD F-04) или регионов (BRD F-12) не требует DDL — только обновление YAML. +- При ошибке pipeline (повреждение БД) — `rm data/gps_tracks.sqlite && python scripts/gps_collect.py` восстанавливает за один прогон (≤ 6 часов, REQ-NF-02). Это закрывает риск «pipeline испортил продакшен-данные». +- Spatialite R-tree обеспечивает p95 ≤ 300 мс на bbox-запросах без необходимости PostgreSQL. + +### Отрицательные / ограничения + +- Денормализация `sources_json`/`external_urls_json` не позволяет нативного `WHERE 'osm' = ANY(sources)`. Фильтр source — постфильтр на Python после bbox-prefetch (приемлемо: BRD §6 показывает ≤ 500 треков на bbox). +- Дублирование `tile_to_bbox` / `wkb_to_coords` между `main.py` и `gps_tracks/mvt.py` — технический долг (`10-tech-risks.md` R-7). При следующем добавлении MVT-источника обязательно вынести в shared util. +- Cross-DB запросы между OSM-данными и GPS-треками невозможны без `ATTACH DATABASE`. На горизонте MVP таких запросов нет, но это блокер для будущей фичи «маршрут предпочитает реально-езженые дороги» (PH-3). +- Дублирование bbox-полей (`min_lon`/`max_lon`/`min_lat`/`max_lat`) в строке трека + R-tree-индексе — избыточные ~32 байта на трек; на 5000 треков ничтожно, осознанный compromise ради быстрого «бросить трек до парсинга WKB». + +### Технический долг + +- Если объём вырастает > 2 ГБ (расширение на всю РФ), перевод на PostGIS. Контракт API `/api/gps-tracks/*` стабилен; меняется только `db.py`. Backend-код, фронтенд, миграции — без изменений. +- Возможный future-rewrite на shared `src/api/tiles_util.py` (см. §8 решения). + +## Классификация изменения + +**Major change.** Введение **новой БД** на сервере явно перечислено в правилах для агентов (CLAUDE.md, эскалация: «новый сервис, новая БД → arch:major-change»). Лейбл `arch:major-change` выставлен. Обязательный архитектурный approve — да. + +## Связанные документы + +- `docs/work-items/ET-008/01-brd.md` §7 «БД» +- `docs/work-items/ET-008/02-trz.md` REQ-F-09 «Схема БД» +- `docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md` +- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` +- `docs/work-items/ET-008/07-infra-requirements.md` §4 «Хранилища данных» +- `docs/work-items/ET-008/08-data-requirements.md` §3 «Серверные данные» +- `docs/work-items/ET-008/10-tech-risks.md` R-3, R-4, R-7 diff --git a/docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md b/docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md new file mode 100644 index 0000000..774535f --- /dev/null +++ b/docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md @@ -0,0 +1,149 @@ +--- +type: adr +work_item_id: ET-008 +adr_id: ADR-006 +title: "ADR-006: Дедупликация публичных GPS-треков — bbox+length+date bucket-hash, мерж sources при коллизии, без геометрических метрик" +status: accepted +created_at: 2026-06-01 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: [] +--- + +# ADR-006 — Алгоритм дедупликации публичных GPS-треков + +## Статус + +Accepted + +## Контекст + +Один и тот же реальный трек может быть выложен автором на несколько платформ (BRD §6 риск №3): тот же маршрут пользователь публикует в EnduroRussia.ru и в Wikiloc, дублирует в OSM Public GPS Traces при разборе и т.п. Цель ET-008 (BRD §1) — «одна запись на реальный трек, с union'ом источников и ссылок». Метрика — BRD §5: ≤ 5% дублей при ручной проверке 100 случайных треков. + +Архитектурно нужно выбрать: + +1. **Какой признак считать «тот же трек».** Координаты на платформах округлены / прорежены / иногда обработаны (сглаживание); полное совпадение точек — редкое. +2. **Сложность алгоритма.** На 5000 треков допустим O(n²); на 50 000+ при расширении на РФ — нет. Нужно либо O(n log n), либо хэш O(n). +3. **Поведение при отсутствии метаданных.** У OSM-треков нет «активности», у скрейпленых страниц иногда нет даты — что делать. +4. **Что фиксируется при коллизии** — кто из источников «выиграл» в полях `name`/`user`/`activity_type`. + +## Рассмотренные варианты + +### Вариант A — Bucket-hash по bbox + length + date (выбран; совпадает с TRZ REQ-F-08) + +```python +def compute_dedup_key(geom: LineString, meta: dict) -> str: + w, s, e, n = geom.bounds + bbox_round = (round(w, 2), round(s, 2), round(e, 2), round(n, 2)) # ≈ 1.1 км + length_bucket = round(meta["length_m"] / 1000) * 1000 # 1 км + date_bucket = (meta.get("created_at") or "")[:10] # YYYY-MM-DD + return f"{bbox_round}|{length_bucket}|{date_bucket}" +``` + +- Сложность: **O(1)** на трек, **O(n)** на пайплайн. Идеально для INSERT с `UNIQUE(dedup_key)` ON CONFLICT. +- Точность: для треков с известной датой — высокая (BBox-проекция отлично различает соседние «утренний эндуро в Калужской» vs «вечерний в Подмосковье»; на одной дате одинаковая длина в одном bbox — это почти всегда тот же трек). +- Ложные коллизии: треки без даты в одном bbox с похожей длиной — будут смерджены. По BRD §6 это явный риск (пользователь может потерять «свой» вариант трека). Митигация — `08-data-requirements.md` §6 и AC-03 «Треки без даты от разных источников». +- Ложные не-коллизии: один и тот же трек у двух источников с расхождением даты на 1+ день (один источник датирует загрузку, другой — запись GPS) — не смердживается. На практике источники сохраняют дату GPS из самого файла; расхождение редкое. + +### Вариант B — Frechet/Hausdorff-расстояние между LineString (отклонён) + +- Сложность: O(n²) на регион при наивной реализации; даже с R-tree-префильтром по bbox остаётся O(n × k), где k — кандидаты в 1-км окне. +- Реалистичный pipeline-overhead: для 5000 треков с медианой 1240 точек — ~30 минут вычислений на регион. Это съедает половину cron-окна (6 ч). +- Преимущества — устойчивость к шумам в координатах; недостатки — высокая стоимость, и при ≥ 50 000 треков становится непригодным. + +### Вариант C — Хэш resampled-points (отклонён) + +```python +sampled = resample(geom, every_n_meters=100) +key = sha256(",".join(f"{lat:.4f},{lon:.4f}" for lat, lon in sampled)) +``` + +- Сложность: O(n) на трек, O(n) на пайплайн. Хорошо. +- Точность: хуже A — на платформах с разным сглаживанием те же 100-метровые точки могут отличаться в 4-м знаке после запятой → хэши не совпадают. То есть метод нестабилен между источниками. +- Можно округлять до 3 знаков (≈ 100 м), но тогда два соседних трека по той же лесной просеке дают одинаковый хэш — снова коллизии. + +### Вариант D — Гибрид: bucket-hash как первичный фильтр + Frechet как тай-брейкер (отклонён) + +- Соблазнительно: A для скорости, B на коллизиях. +- Сложность реализации высокая: при коллизии bucket-hash нужно подтянуть из БД полную геометрию обоих треков, посчитать Frechet, принять решение. Это блокирующий round-trip в SQLite на каждый коллидирующий INSERT. +- На MVP это over-engineering. Если метрика BRD §5 «≤ 5%» не выполнится — заводится отдельный work item «улучшение dedup». + +## Решение + +**Принимается Вариант A — bucket-hash O(1)**, в точности по формуле TRZ REQ-F-08, с уточнениями: + +1. **Гранулярность `bbox_round`** — 2 знака после запятой (≈ 1.1 км). Не 1 знак (≈ 11 км — слишком грубо, ложные коллизии для коротких треков в одном городе) и не 3 знака (≈ 110 м — слишком точно, не сходится между источниками с разным сглаживанием). + +2. **Гранулярность `length_bucket`** — 1 км. На треках длиной 5–50 км это 2–20% разброс, что покрывает межисточниковую разницу подсчёта (округление координат → разные интегралы длины). На очень коротких треках (< 1 км) `length_bucket = 0` для всех таких треков — что даст переслияние «всех коротких в одном km²-bbox в одной дате»; вероятность такого совпадения от двух разных авторов исчезающе мала. + +3. **Гранулярность `date_bucket`** — день (YYYY-MM-DD). Не «час» (источники часто хранят только дату), не «месяц» (слишком грубо — есть популярные маршруты, которые ездят сотнями раз). + +4. **Отсутствие `created_at`** — `date_bucket = ""` для обоих треков → они считаются одним ключом. Это сознательный consenrvative-merge: + - Источники, не отдающие дату, обычно отдают её отдельно (OSM публикует timestamp загрузки; ttrails — дату публикации; EnduroRussia — дату поездки). После анализа лог-сэмплов BRD §5 ожидаем, что > 95% треков имеют дату. + - Без даты — мы и не отличим «два разных трека с одинаковой геометрией» от «один и тот же выложенный дважды». Merge — меньшее зло, чем дубль; при ошибке достаточно дополнительно показать оба `external_urls` в popup (REQ-F-18). + - Документировано в AC-03 «Треки без даты — дедуп срабатывает». + +5. **Поведение при коллизии — мерж, а не replace:** + - `sources_json` ← union существующих + нового `[source_id]`. + - `external_urls_json` ← union существующих + нового `[external_url]`. + - `name`, `description`, `user`, `tags`, `activity_type` — берутся **по приоритету источника в `gps_sources.yaml`** (порядок объявления = приоритет). Если у нового источника приоритет выше — поля перезаписываются; иначе сохраняются старые. Это даёт стабильный детерминированный результат независимо от порядка обхода в pipeline. + - `length_m`, `points_count`, `geom` — берутся от **первого** источника (того, кто первым создал запись). Не пересчитываются при мерже. Это снижает риск «джиттера» геометрии трека от прогона к прогону. + - `updated_at` — обновляется на текущее время прогона. + +6. **Реализация в коде** — SQL-уровень: + + ```sql + INSERT INTO tracks (dedup_key, name, ..., sources_json, external_urls_json, ...) + VALUES (?, ?, ..., ?, ?, ...) + ON CONFLICT(dedup_key) DO UPDATE SET + sources_json = (SELECT json_union(sources_json, excluded.sources_json)), + external_urls_json = (SELECT json_union(external_urls_json, excluded.external_urls_json)), + name = CASE WHEN excluded._priority > _priority THEN excluded.name ELSE name END, + ... + updated_at = excluded.updated_at; + ``` + + Поскольку SQLite без JSON1 не имеет `json_union`, мерж массивов реализуется на Python в `db.py::upsert_track()` (read-merge-write в одной транзакции). Производительность достаточная: O(1) на трек, < 5 мс на upsert. + +7. **Валидация метрики BRD §5 «< 5% дублей»** — отдельный скрипт `scripts/dedup_audit.py` (отсэмплировать 100 треков, вывести в JSON для ручной проверки). Этот скрипт — артефакт фазы тестирования (`04-test-plan.yaml`), не runtime. + +8. **План отступления.** Если метрика < 5% не выполнится на реальном датасете: + - Сузить `length_bucket` до 500 м. + - Добавить `activity_type` в ключ (но тогда сломается «OSM без активности vs EnduroRussia с активностью=enduro» — merge не сработает; нужно явно маппить пропуски в общий слот). + - В крайнем случае — гибрид A+B (Вариант D выше). + Эти эволюции — отдельный ADR, не блокируют ET-008 MVP. + +## Последствия + +### Положительные + +- O(1) per track, O(n) per pipeline — никакого квадратичного blow-up. +- Реализуется одним SQL ON CONFLICT + Python-мерж массивов; < 100 строк кода. +- Детерминированный результат при перезапуске pipeline (порядок источников фиксирован конфигом). +- Соответствует BRD-метрике «< 5%» на ожидаемом датасете (валидируется QA в фазе теста). + +### Отрицательные / ограничения + +- **Ложные коллизии для треков без даты.** Принято осознанно (см. §4 решения). +- **Ложные коллизии для одного маршрута, проехавшего в разные дни** двумя разными людьми с похожей длиной — это **не баг, а ограничение**: один и тот же популярный 30-км маршрут, проехавший двумя гонщиками в один день, будет смерджен в одну запись. Бизнес-смысл сохраняется (пользователь увидит «по этой тропе ездят»), но статистика «сколько раз проехали» — потеряна. Это out of scope MVP; в BRD §5 «плотность треков» — отдельная фича. +- **Length-bucket не работает на круговых треках** с малой длиной по прямой — но bbox-проекция эти случаи всё равно различает по координатам. +- **При наследовании MVP-кода на регионы с миллионом треков** ложные коллизии могут вырасти. Митигация — `10-tech-risks.md` R-2; метрика отслеживается на каждом прогоне в `pipeline_runs.errors_json`. + +### Технический долг + +- Если QA-метрика провалится — план отступления §8 решения. +- Возможный future-rewrite на Вариант D (hybrid) — задокументирован, но не выполняется в MVP. + +## Классификация изменения + +**Minor change.** Алгоритм — внутренний contract pipeline'а, не виден ни наружу API, ни во фронтенде. Любая будущая правка `compute_dedup_key()` требует полного re-collect (отбросить БД и пересобрать), но это операционная процедура; затрагивает только `data/gps_tracks.sqlite`. `arch:major-change` не требуется. + +## Связанные документы + +- `docs/work-items/ET-008/02-trz.md` §6.1 «compute_dedup_key» +- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-03 +- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §3 (sources_json) +- `docs/work-items/ET-008/08-data-requirements.md` §3.2 (dedup_key) +- `docs/work-items/ET-008/10-tech-risks.md` R-2 (ложные коллизии) diff --git a/docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md b/docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md new file mode 100644 index 0000000..ade763b --- /dev/null +++ b/docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md @@ -0,0 +1,233 @@ +--- +type: adr +work_item_id: ET-008 +adr_id: ADR-007 +title: "ADR-007: Pipeline сбора GPS-треков — отдельный docker-compose service с profiles:[batch], запускаемый host cron'ом mva154; per-source изоляция; без message queue" +status: accepted +created_at: 2026-06-01 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "arch:major-change" +--- + +# ADR-007 — Архитектура pipeline'а сбора GPS-треков + +## Статус + +Accepted + +## Контекст + +ET-008 вводит первый в проекте **офлайн-pipeline** — периодический сбор GPS-треков с внешних публичных платформ (BRD §3 F-01, BRD §7 «Pipeline»). Требования: + +- Запускается 1–2 раза в неделю по cron (BRD §3 Out of scope «Real-time»). +- ≤ 6 часов на полный прогон ЦФО+Чувашию (REQ-NF-02). +- Падение одного источника **не валит** остальные (AC-02 «scenario 3»). +- Pipeline не блокирует и не деградирует production API `/api/*` во время прогона. +- Pipeline пишет в `data/gps_tracks.sqlite` (ADR-005), читатели API видят консистентный снэпшот (WAL). +- Не использовать message queue (BRD § «Запрещено»: «Добавлять message queue без явной необходимости»). +- Минимум зависимостей (BRD § «Принципы»: «Минимум зависимостей»). + +Архитектурно нужно решить: + +1. **Где исполнять pipeline** — внутри FastAPI-контейнера (background task), отдельный контейнер, или host-Python. +2. **Чем запускать** — host cron, in-process scheduler (APScheduler/Celery beat), systemd-timer. +3. **Как изолировать ошибки источника** — отдельные процессы, asyncio с try/except, отдельные контейнеры. +4. **Где жить конфигам и логам.** +5. **Стратегия retry / backoff / rate-limit** (отдельный субкомпонент или встроено в per-source модули). + +## Рассмотренные варианты + +### Вариант X (eXecution) — где исполнять + +- **X-A — отдельный docker-compose service `gps-collector`** в том же `docker-compose.yml`, использующий тот же image что и `app`, с `profiles: [batch]` чтобы не стартовать вместе с API. Запуск — `docker compose --profile batch run --rm gps-collector`. (Выбран.) + Плюсы: + - Никакого нового образа, никаких новых зависимостей в самом API-контейнере. Из контейнера API исключены HTTP-скрейперы — пользователи не имеют шансов вызвать парсер через SSRF. + - Изоляция CPU/RAM: процесс pipeline не делит память с API; OOM в pipeline не убивает API. + - Использует ту же кодовую базу (`COPY src/api/`, `COPY scripts/` в Dockerfile); deploy один. + - Точка расширения: при росте до многоконтейнерной сборки (PostGIS в будущем) — pipeline уже отдельный сервис. + Минусы: + - Лёгкое усложнение `docker-compose.yml` (+1 service-блок ≈ 15 строк). + - Host cron должен знать команду `docker compose --profile batch run`. + +- **X-B — background task внутри FastAPI** (APScheduler в lifespan). Отклонён: + - Pipeline жрёт CPU/память на API-контейнере → деградация запросов во время прогона. + - Сложно остановить отдельно от API. + - При перезапуске API теряется состояние прогона (если пайплайн не идемпотентный). + - Запрещено BRD «Добавлять X без явной необходимости» — это де-факто in-process scheduler. + +- **X-C — host-Python venv + системный cron** (вне Docker). Отклонён: + - Нарушает BRD «Всё в Docker». + - Дублирование зависимостей: один venv в Docker, второй на хосте. + - Усложняет CI/CD: pipeline не покрывается тем же `make build`. + +- **X-D — Celery worker + Redis** (queue-based). Отклонён прямо BRD «Запрещено: Добавлять message queue». Не нужен — задача одна, без распараллеливания. + +### Вариант S (Scheduling) — чем запускать + +- **S-A — host cron на mva154** (выбран). Запись в `/etc/cron.d/enduro-gps`: + ```cron + # GPS tracks pipeline — ET-008 + 0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1 + ``` + Плюсы: + - Часть базовой ОС, не требует доп. установок. + - Лог в файл — оператор может `tail -f`. + - Если прогон завис — `kill ` штатно убивает контейнер; следующий cron-тик запустит заново. +- **S-B — systemd timer** на хосте. Отклонён: даёт более тонкий контроль (зависимости, рестарты), но это инфра-апгрейд за гранью BRD «минимум зависимостей»; cron достаточно. +- **S-C — in-container scheduler** (APScheduler). Отклонён (см. X-B). +- **S-D — Gitea Actions self-hosted scheduled workflow**. Отклонён: CI/CD контейнер не должен делать write в production-данные. + +### Вариант I (Isolation) — изоляция ошибок per-source + +- **I-A — try/except на уровне источника в asyncio-loop** (выбран). Один процесс python, для каждого `(region, source)` отдельный `try/except`; на падении пишется в `pipeline_runs.errors_json`, цикл идёт дальше к следующему источнику. +- **I-B — отдельный процесс per-source** (subprocess + JSON pipe). Отклонён: усложнение без существенной выгоды; OOM одного source при умеренных лимитах не валит весь python-процесс. +- **I-C — отдельный контейнер per-source**. Отклонён: гросс over-engineering для 3 источников. + +### Вариант R (Rate-limit) — где живёт rate-limit-логика + +- **R-A — в per-source модуле** через `asyncio.sleep(rate_limit_sec)` после каждого HTTP (выбран; совпадает с TRZ §1 REQ-F-03). Простой, явный, контролируется конфигом `gps_sources.yaml`. +- **R-B — глобальный rate-limiter** (semaphore на all-sources). Отклонён: rate-limit per-source, у каждого источника свой ToS-лимит. Глобальный лимитер только усложнит. +- **R-C — внешний прокси с rate-limit** (HAProxy / nginx-limit-req). Отклонён: новая инфра-зависимость. + +### Вариант C (Config) — где конфиг + +- **C-A — YAML в репозитории** `config/gps_sources.yaml`, `config/gps_regions.yaml` (выбран; совпадает с TRZ REQ-F-01/02). Источник истины — git; ревью изменений идёт стандартным PR-флоу. +- **C-B — в БД, редактирование через админ-UI**. Отклонён: над-инжиниринг для MVP; добавляет attack surface. +- **C-C — в env-переменных docker-compose**. Отклонён: не масштабируется на 3+ источников. + +## Решение + +Принимается комбинация: **X-A + S-A + I-A + R-A + C-A**. + +1. **Pipeline — отдельный docker-compose service `gps-collector`** в `docker-compose.yml`: + + ```yaml + services: + gps-collector: + build: . + profiles: ["batch"] + volumes: + - ./data:/app/data + - ./config:/app/config:ro + - /var/log/enduro-trails:/var/log/enduro-trails + environment: + - GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite + - GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml + - GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml + - HTTPX_LOG_LEVEL=INFO + command: ["python", "-m", "scripts.gps_collect"] + restart: "no" + ``` + + - `profiles: ["batch"]` — service **не стартует** при штатном `docker compose up -d` (важно: API uptime не зависит от pipeline). + - Запускается командой `docker compose --profile batch run --rm gps-collector` (запись — `host cron`). + - Использует **тот же image**, что и `app` — сборка одна, пакет тот же. + - Конфиги примонтированы read-only — `gps-collector` их не пишет. + - `/var/log/enduro-trails` шарится с хостом; stdout/stderr ловит cron в `gps-collect.log`, а pipeline пишет structured JSON-лог в `/var/log/enduro-trails/pipeline-.jsonl`. + +2. **Cron на mva154** — `/etc/cron.d/enduro-gps`: + ``` + 0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1 + ``` + - Mon + Thu 03:00 UTC (BRD §7 «Cron на mva154»). + - Логи ротируются стандартным `logrotate` (см. `07-infra-requirements.md` §10). + - Простого «flock» против overlapping runs **не нужно**: cron-окно 3-дневное, реальная длина прогона ≤ 6 ч. + +3. **GC-прогон** — отдельная команда `docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --gc`. Запускается раз в месяц host cron'ом отдельной строкой `0 4 1 * * root ...`. Удаляет треки с `updated_at < NOW() - 5 years` (REQ-NF-03). + +4. **Per-source модули в `src/api/gps_tracks/sources/`** реализуют **абстрактный контракт** `base.py::SourceParser`: + + ```python + class SourceParser: + MAPPING: dict[str, str] # source-category → ACTIVITY_TYPE + async def collect(self, bbox: BBox, ctx: PipelineContext) -> AsyncIterator[Track]: ... + ``` + + Главная петля `scripts/gps_collect.py::run_pipeline()`: + + ```python + for region in regions_enabled: + for source_id in region.sources: + parser = load_parser(source_id) + run = pipeline_runs.start(region.id, source_id) + try: + async for track in parser.collect(region.bbox, ctx): + db.upsert_track(track) # ADR-006 dedup-логика + run.tracks_new_or_updated += 1 + except Exception as e: + run.status = "error" + run.errors_json = serialize_exc(e) + logger.exception("source %s failed", source_id) + finally: + run.finalize() + ``` + + - Падение `parser.collect()` локализовано в один `try/except` — следующий источник стартует без рестарта процесса. + - `parser.collect()` — асинхронный генератор; pipeline pulls треки по одному, не накапливает в памяти больше одного. + +5. **Per-source rate-limit и backoff** реализованы в `base.py::SourceParser._http_get()` через `asyncio.sleep(rate_limit_sec)` после каждого запроса и `tenacity`-стиль retry с exponential backoff (TRZ §6.3). `User-Agent` берётся из `gps_sources.yaml` per-source. + +6. **Лицензионные guard'ы.** Перед `load_parser(source_id)` pipeline **проверяет**: `config/gps_sources.yaml::sources[id].license_adr` указывает на файл `docs/work-items/ET-008/06-adr/ADR-NNN--licensing.md` со статусом `accepted`. Если файл не найден или статус не `accepted` → exception → source пропускается; запись `pipeline_runs.status = "skipped_license"`. Это превращает BRD §4 «Юридический минимум» в **runtime-enforced** правило, не «обещание разработчика». См. `10-tech-risks.md` R-9. + +7. **Cache-invalidation тайлов после прогона.** В конце успешного прогона pipeline делает HTTP-запрос: + `POST http://app:5556/api/gps-tracks/cache/clear` + (внутренняя сеть docker-compose). API сбрасывает LRU-кэш MVT-тайлов. Если API недоступен — лог-предупреждение, не ошибка прогона (REQ-NF-04). + +8. **Health-эндпоинт `/api/gps-tracks/health`** (REQ-F-12) **читает** последнюю запись `pipeline_runs` из БД (не имеет прямой связи с процессом pipeline; уже остановленный pipeline продолжает быть «виден» через свою историю в БД). + +9. **WAL и concurrent reads.** Pipeline пишет в БД в WAL-mode (ADR-005 §5). FastAPI читает ту же БД, видит последний checkpoint. Pipeline вызывает `PRAGMA wal_checkpoint(PASSIVE)` после каждого `(region, source)` чтобы WAL-файл не разрастался. + +10. **C4 / архитектурная диаграмма.** В `docs/architecture/README.md` добавляется раздел «GPS Tracks Pipeline»: новый компонент `gps-collector` (внутри docker-compose, не стартует штатно), новые внешние зависимости (OSM API + 2 source-сайта), новая БД `gps_tracks.sqlite`. Mermaid C4-диаграммы в проекте отсутствуют; следуем прецеденту ADR-004 §8 — текстовое описание. + +## Последствия + +### Положительные + +- Pipeline и API изолированы по контейнерам, по процессам, по CPU/RAM. Pipeline не может уронить API. +- Расширение списка источников = добавить файл `src/api/gps_tracks/sources/.py` + запись в `gps_sources.yaml` + ADR-licensing. Никакого кода pipeline не правится (BRD-метрика «расширяемость без правки Python-кода» выполняется). +- Расширение списка регионов = одна запись в `gps_regions.yaml` ≤ 30 строк (BRD-метрика выполняется). +- Сбой одного парсера не останавливает остальные (AC-02 выполняется через try/except на per-source уровне). +- `profiles: ["batch"]` гарантирует, что pipeline никогда не стартует автоматически с `docker compose up` — нулевая вероятность случайного «pipeline скачивает на проде» во время рестарта API. +- Простой деплой: тот же `make build` собирает образ; новый сервис сразу доступен. +- Лицензионные guard'ы (§6 решения) делают BRD §4 «Юридический минимум» **enforceable**, не на честное слово разработчика. + +### Отрицательные / ограничения + +- Pipeline зависит от установленного на mva154 `docker compose` (v2 plugin). Это **уже выполняется** — на mva154 docker compose v2 используется для штатного деплоя. +- Логи живут на хосте (`/var/log/enduro-trails/`) — не в Docker. Это сознательно: ротация через `logrotate`, доступ через ssh, не требует доп. log-агрегатора. +- При смене image (новой версии Python / новой системной зависимости) нужно `docker compose --profile batch build gps-collector` — но `--profile batch` теперь должен быть в команде, что легко забыть. Митигация: smoke-проверка в deploy-runbook (`07-infra-requirements.md` §7). +- Pipeline не имеет UI/админки — оператор работает через ssh + cron logs. На MVP это приемлемо; админ-UI — отдельная задача после PH-3 при необходимости. + +### Технический долг + +- Если в будущем понадобится распараллелить источники для скорости — заменить `for source_id ... await parser.collect()` на `asyncio.gather([parser.collect(...) for source_id ...])`. Контракт `SourceParser.collect()` уже асинхронный — изменение локально. +- Если понадобится централизованная очередь / распределённый pipeline — заменить cron+single-container на Celery/Redis. Контракт `pipeline_runs` в БД останется; меняется только запуск. +- Если на масштабе РФ понадобится дробить регион на параллельные шарды — расширение `gps_regions.yaml` поддерживает это (subregions); меняется только runner. + +## Классификация изменения + +**Major change.** Pipeline вводит: +- Первый scheduled-job на mva154 для проекта (cron-запись). +- Первый outbound-скрейпинг (правовой режим, rate-limit-обязательства перед третьими сторонами). +- Новый docker-compose service. +- Новую БД (через ADR-005, отдельно). + +Каждый из этих пунктов сам по себе **не** требует `arch:major-change` (по правилам CLAUDE.md новый сервис / новая БД — да). Лейбл `arch:major-change` выставлен. Обязательный архитектурный approve — да. + +## Связанные документы + +- `docs/work-items/ET-008/01-brd.md` §7 «Pipeline», §3 F-01..F-03, F-12, F-17 +- `docs/work-items/ET-008/02-trz.md` §1 REQ-F-01..REQ-F-03, REQ-F-07, REQ-F-12, §6.2, §6.3 +- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-01, AC-02 +- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` +- `docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md` +- `docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md` +- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` +- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` +- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md` +- `docs/work-items/ET-008/07-infra-requirements.md` +- `docs/work-items/ET-008/10-tech-risks.md` R-1, R-5, R-6, R-9 diff --git a/docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md b/docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md new file mode 100644 index 0000000..f5f3a19 --- /dev/null +++ b/docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md @@ -0,0 +1,185 @@ +--- +type: adr +work_item_id: ET-008 +adr_id: ADR-008 +title: "ADR-008: Двухрежимная отдача публичных треков — MVT-тайлы на z ≤ 11, GeoJSON по bbox на z ≥ 12; клиентское переключение по zoom; общий cache-invalidation" +status: accepted +created_at: 2026-06-01 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: [] +--- + +# ADR-008 — Стратегия отдачи треков клиенту: MVT vs GeoJSON + +## Статус + +Accepted + +## Контекст + +Слой публичных треков (BRD §3 F-05..F-09) должен: + +- Показываться на широком диапазоне zoom — от z=8 (вся область региона видна сразу) до z=16+ (один трек крупно). +- Поддерживать **клик с popup** на трек (REQ-F-18) — то есть feature должна быть «настоящей», а не растровой. +- Поддерживать **клиентскую фильтрацию** по активности и источнику без сетевого запроса (REQ-F-14, AC-08). +- Уложиться в p95 ≤ 300 мс для GeoJSON-ответа (BRD-метрика). +- Не штормить сервер запросами при быстром pan (AC-14). + +На низком zoom (z=8) в видимую область могут попасть тысячи треков. Отдавать их одним GeoJSON-ответом неприемлемо: payload в 10–100 МБ → сетевой p95 проседает; парсинг GeoJSON блокирует main thread браузера; MapLibre перерисовывает каждое pan-move. + +На высоком zoom (z ≥ 12) в видимую область попадают десятки треков, и пользователь ждёт interactive popup + точную геометрию. + +Архитектурно нужно выбрать стратегию отдачи и переключения между режимами. + +## Рассмотренные варианты + +### Вариант M (Mode) — единый режим отдачи + +- **M-A — только GeoJSON для всех zoom**. Отклонён: + - На z=8 payload неприемлем (см. контекст). + - Не использует существующий MVT-кэш-паттерн `main.py` для слоя `trails` — теряем уже отлаженный механизм для аналогичной задачи. +- **M-B — только MVT для всех zoom**. Отклонён: + - MVT не даёт удобного `popup` с богатыми метаданными: `properties` MVT-тайла ограничены (плюс через MapLibre `queryRenderedFeatures` доступ есть, но фильтр feature-level через `setFilter` требует чтобы все нужные поля сидели в MVT-фиче — а у нас `sources` массив, который в MVT нативно не представляется). + - Клиентская фильтрация по `source` через `setFilter` работает только на одной колонке source (REQ-F-16 «первый source»); для multi-source filtering на MVT-фиче без множественной колонки — компромисс. +- **M-C — гибрид: MVT на z ≤ 11, GeoJSON на z ≥ 12** (выбран, совпадает с TRZ REQ-F-11 финальной формулировкой). + - На z ≤ 11 — MVT, серверный LRU-кэш, ограниченное упрощение геометрии. Клиент видит «общий ландшафт» — где много треков, плотность, какие источники доминируют. + - На z ≥ 12 — GeoJSON по bbox, полные точные координаты, полные `sources_json`/`external_urls_json` для popup. + - Cutoff z=12 — реалистичный порог: 1 тайл z=11 ≈ 19 × 12 км (на широте 55°), z=12 ≈ 10 × 6 км. В bbox z=12 типично попадает ≤ 500 треков → GeoJSON ≤ 2 МБ → влезает в SLA 300 мс. + +### Вариант T (Tile generation) — как генерировать MVT + +- **T-A — реальное время по запросу + LRU-кэш** (выбран; совпадает с архитектурой текущего слоя `trails` в `main.py`): + - На запрос `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`: + 1. Проверить LRU-кэш (1024 записи). + 2. На промахе — выполнить SELECT из `tracks` по bbox тайла, упростить геометрии по `simplify_coords(coords, z)`, отдать через `mapbox-vector-tile`. + 3. Записать результат в LRU. + - Cache-invalidation — `POST /api/gps-tracks/cache/clear` после успешного pipeline-прогона (ADR-007 §7). + - Cold-cache p95 ≤ 200 мс (REQ-NF-02). Hot-cache ≤ 20 мс. +- **T-B — pre-generated tile cache** (после pipeline сразу генерируется весь z=8..z=11 grid на диск). Отклонён: + - 4ˡ tiles на каждом zoom — z=8 = 16 tiles, z=9 = 64, z=10 = 256, z=11 = 1024 → ≈ 1.4k тайлов. Несложно, но: при росте региона до РФ — десятки тысяч; диск растёт без необходимости. + - Cold-cache при первой загрузке после прогона всё равно нужен (LRU прогревается естественно). + - Усложняет cache-invalidation: нужно удалять файлы вместо `_tile_cache.clear()`. +- **T-C — внешний tile server** (tilelive/tilemaker/Tegola). Отклонён: новый сервис, новая инфра-зависимость; mapbox-vector-tile в Python уже умеет всё, что нужно. + +### Вариант G (GeoJSON limit) — как обрезать GeoJSON + +- **G-A — фиксированный limit=500, truncated=true в payload** (выбран; совпадает с TRZ REQ-F-10). + - На z ≥ 12 типично ≤ 500 треков в bbox → truncated:false. + - На редких плотных bbox (10+ треков/км²) сервер возвращает первые 500 (LIMIT в SQL), `truncated:true`, клиент показывает в UI «показано 500 из 743, увеличьте zoom». + - Простая семантика, нет surprise для разработчика API. +- **G-B — server-side pagination cursor**. Отклонён: над-инжиниринг; для visualisation-слоя пагинация не интуитивна; пользователю удобнее zoom, а не next-page. +- **G-C — server-side clustering для overflow**. Отклонён: track — это LineString, кластеризация по линейным сущностям нетривиальна; out of scope. + +### Вариант F (Filter location) — где фильтровать по activity/source + +- **F-A — серверный фильтр в SQL** (по `activity_type`) + Python-постфильтр (по `sources_json`); итоговое FeatureCollection уже отфильтровано (выбран для GeoJSON, совпадает с TRZ REQ-F-10). + - Сервер сразу возвращает только нужное → меньше трафика. + - Но: смена фильтра в UI → новый запрос. Это ОК для GeoJSON (z ≥ 12, < 500 треков) — REQ-NF-06 «≤ 200 мс» выполнимо при cache miss. +- **F-B — клиентский фильтр через `setFilter`** на уже загруженной выборке (выбран **дополнительно**, для MVT-режима). + - На z ≤ 11 — MVT уже содержит всё; смена фильтра — мгновенный `setFilter` без сетевого запроса. AC-08 «фильтрация мгновенная (≤ 200 мс)». + - На z ≥ 12 — клиентский setFilter работает поверх загруженного GeoJSON; для повторного fetch при следующем `moveend` уже учитываются новые фильтры. + +### Вариант D (Debounce) — защита от шторма запросов + +- **D-A — клиентский debounce 500 мс + AbortController** (выбран; совпадает с TRZ §6.4): + - На `moveend` карта запускает 500-мс таймер; новые `moveend` сбрасывают его. + - Старые in-flight запросы отменяются `AbortController.abort()`. + - Server-side rate-limit не нужен — фронтенд сам себя ограничивает. +- **D-B — server-side rate-limit middleware**. Отклонён: усложняет API, не нужно при D-A. + +### Вариант H (Halo on satellite) — гибридный слой через MVT/GeoJSON + +- **H-A — две `'source'`-привязки в MapLibre**: одна на `gps-tracks-tiles` (vector source MVT), вторая на `gps-tracks-geo` (GeoJSON source). Один и тот же слой `gps-tracks-layer` нельзя привязать к двум sources одновременно. Поэтому **два параллельных слоя**: `gps-tracks-layer-mvt` (visible на z ≤ 11) и `gps-tracks-layer-geo` (visible на z ≥ 12). Переключение через `setLayoutProperty('visibility')` по `zoomend`. (Выбран — единственный нормально работающий способ.) +- **H-B — переключать `setData` на одном слое**. Отклонён: GeoJSON-source и vector-source — разные типы в MapLibre; нельзя «переключить» source у layer'а без `removeLayer` + `addLayer`. + +## Решение + +Принимается комбинация: **M-C + T-A + G-A + F-A + F-B + D-A + H-A**. + +1. **Двухрежимная отдача:** + - `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` — векторные тайлы, **только для клиента**, который добавил vector-source `gps-tracks-tiles`. Клиент использует на z ≤ 11. + - `GET /api/gps-tracks?bbox=&activity=&source=&limit=` — GeoJSON FeatureCollection, для z ≥ 12. + +2. **Cutoff z=12** — выбран как баланс между «MVT даёт обзор + кэш» и «GeoJSON даёт полный popup-data». Cutoff фиксирован в клиенте константой `GPS_TRACKS_ZOOM_CUTOFF = 12`. + +3. **MVT-слой клиента:** + - Source: `vector` type, `tiles: ['/api/gps-tracks/tiles/{z}/{x}/{y}.mvt']`, `minzoom: 8`, `maxzoom: 11`. На z < 8 слой полностью скрыт (TRZ REQ-F-20). + - Layer: `gps-tracks-layer-mvt`, `source-layer: 'gps_tracks'`, paint по REQ-F-17. + - Properties фичи: `id, activity, source (первый), sources (comma-separated), length_km, name, ext_url` (TRZ §4.3). `sources` как comma-string, потому что MVT не поддерживает массивы. + +4. **GeoJSON-слой клиента:** + - Source: `geojson`, `data: { type: 'FeatureCollection', features: [] }` (пустой при инициализации). + - Layer: `gps-tracks-layer-geo`, `source: 'gps-tracks-geo'`, paint по REQ-F-17. + - На `moveend` (debounced 500 мс через AbortController) — `fetch('/api/gps-tracks?bbox=...&activity=...&source=...&limit=500')` → `getSource().setData(json)`. + +5. **Переключение по zoom:** + - `zoomend` listener: `if (z < 12) hide(geo); show(mvt); else show(geo); hide(mvt);`. + - `visibility` управляется `setLayoutProperty`. + - Кратко: оба source и layer всегда **существуют** при включённом чекбоксе; меняется только видимость. + - На z < 8 — оба невидимы (REQ-F-20); статус-баннер «Зум 8+». + +6. **Серверный MVT-кэш:** + - LRU-словарь в памяти процесса FastAPI, ёмкость **1024** записи (как для слоя `trails`). + - Ключ — `(z, x, y)`. Значение — байты `.mvt`. + - На промахе SELECT идёт через R-tree (Spatialite `idx_tracks_geom`) с bbox тайла + 5% padding. + - Упрощение геометрии — `simplify_coords(coords, z)` (Douglas-Peucker tolerance зависит от zoom). + - LIMIT тайла — как у `trails` (3000/8000/15000 на z ≤ 7/9/11). + +7. **Cache-invalidation:** + - `POST /api/gps-tracks/cache/clear` — единственный POST в этом семействе эндпоинтов, авторизуется по сетевому пути (только из docker-compose internal network; через `/enduro/` proxy не маршрутизируется — см. `07-infra-requirements.md` §3). + - Pipeline вызывает его при успешном завершении (ADR-007 §7). + +8. **Сервер GeoJSON (`GET /api/gps-tracks`):** + - SQL: `SELECT * FROM tracks WHERE ROWID IN (SELECT pkid FROM idx_tracks_geom WHERE ... bbox ...) [AND activity_type IN (...)] ORDER BY length_m DESC LIMIT N` — длинные треки первыми (полезнее для overview). + - `source` фильтр — постфильтр на Python после получения < 500 строк (`'osm' in json.loads(sources_json)`). + - Total — отдельный `COUNT(*)` запрос с теми же WHERE-условиями (без LIMIT) для `total_in_bbox`. + - Response — GeoJSON по REQ-F-10 со всеми properties. + - p95 ≤ 300 мс — выполнимо на bbox с ≤ 500 треков (запросы R-tree + N парсингов WKB по 1.5 КБ). + +9. **Atomic state в клиенте** через объект `window.gpsTracksLayer` (TRZ §4.4). Поля state на 100% derived из (`localStorage` + `map.getZoom()` + последний GeoJSON-ответ); восстановление в `rebuildMapOverlays() → restorePublicTracksState()` (REQ-F-19). + +10. **Halo на спутнике (REQ-F-15, ET-007 §7.2 паттерн):** + - Для **обоих** клиентских слоёв (MVT и GeoJSON) — свои halo: + - `gps-tracks-halo-mvt-satellite` — halo поверх `gps-tracks-tiles`. + - `gps-tracks-halo-geo-satellite` — halo поверх `gps-tracks-geo`. + - Видимость halo управляется хелпером `applyGpsHaloVisibility()` по правилу: halo видим ⇔ `(public-tracks ON) AND (zoom band matches) AND (base === 'satellite')`. + - Hook добавляется в `applyBaseLayer()` (ET-007) — по тому же паттерну, что halo для trails (ADR-004 §9). + +## Последствия + +### Положительные + +- Соответствует SLA: MVT cold p95 ≤ 200 мс, GeoJSON p95 ≤ 300 мс при разумном bbox. +- Низкий зум — обзор; высокий зум — полный popup. Пользователь получает оптимум на каждом масштабе. +- Кэш-стратегия идентична существующему слою `trails` — оператор уже знаком; единый паттерн. +- AbortController + debounce защищают от шторма запросов независимо от того, насколько быстро юзер pan'ит карту. +- Cache-invalidation после прогона — пользователь видит свежие данные при следующем pan/zoom. + +### Отрицательные / ограничения + +- **Два source / два layer на один логический слой** — небольшое усложнение клиентского кода (sync visibility, sync filter). Кодовое разбиение — в `src/web/gps_tracks.js`; внутренняя сложность не «протекает» наружу. +- **Жёсткий cutoff z=12.** На границе (z=11.5) пользователь может видеть мигание: MVT-тайлы упрощены до 1км, GeoJSON покажет точные кривые. Сглаживание — `transition` на opacity (UI-микро-улучшение, не блокер). +- **`source` в MVT — только первый из dedup-list.** Цвет по источнику (REQ-F-16) показывает «первый по приоритету»; реальное мульти-источникство видно только в popup на z ≥ 12. Принято: «дедупный мульти-источникный» трек редок (< 10% по оценке BRD §5); цвет по «первому источнику» интуитивен. +- **Серверный кэш сбрасывается ТОЛЬКО pipeline'ом.** Если оператор вручную `UPDATE tracks` — кэш не инвалидируется. Митигация — оператор знает про эндпоинт; в runbook (`07-infra-requirements.md` §8). На практике вручную в БД лазать не предполагается. + +### Технический долг + +- `tile_to_bbox` / `wkb_to_coords` / `simplify_coords` дублируются между `main.py` и `gps_tracks/mvt.py` (см. ADR-005 §8). При появлении третьего MVT-источника — вынести в shared util. +- Если в будущем понадобится фильтр по multiple `source` непосредственно в MVT (для multi-color по источникам трека) — необходимо переработать схему MVT properties (массив через JSON-string или через несколько колонок). Не блокер MVP. + +## Классификация изменения + +**Minor change.** Стратегия отдачи — внутренний контракт клиент↔API, всё в пределах FastAPI и фронтенда. Новых сервисов, БД, очередей не вводит. `arch:major-change` не требуется. + +## Связанные документы + +- `docs/work-items/ET-008/02-trz.md` §1 REQ-F-10, REQ-F-11, REQ-F-13, REQ-F-17, REQ-F-20, §6.4 +- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-04, AC-05, AC-13, AC-14 +- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §8 (общий tile-utility) +- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §7 (cache-clear hook) +- `docs/work-items/ET-008/07-infra-requirements.md` §3 (network) +- `docs/work-items/ET-008/10-tech-risks.md` R-7, R-8 +- `docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md` §5, §9 (halo-паттерн) diff --git a/docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md b/docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md new file mode 100644 index 0000000..1427057 --- /dev/null +++ b/docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md @@ -0,0 +1,146 @@ +--- +type: adr +work_item_id: ET-008 +adr_id: ADR-009 +title: "ADR-009: Источник OSM Public GPS Traces — лицензия ODbL, документированный API, акцептовано для MVP" +status: accepted +created_at: 2026-06-01 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "ET-008:source-licensing" +--- + +# ADR-009 — OSM Public GPS Traces: licensing review + +## Статус + +Accepted + +## Контекст + +BRD §4 ET-008 требует обязательный ADR licensing-review для каждого внешнего источника **до** активации его в pipeline. Без `status: accepted` в этом ADR — pipeline отказывается загружать source-parser (см. ADR-007 §6). + +Источник: **OpenStreetMap Public GPS Traces**. + +- Endpoint: `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=W,S,E,N&page=P`. +- Endpoint метаданных: `GET https://api.openstreetmap.org/api/0.6/gpx/{id}`. +- Документирован: . +- Лицензия данных: **ODbL 1.0** — Open Database License (). +- Атрибуция: «© OpenStreetMap contributors (ODbL)». + +## Чеклист по BRD §4 + +### 1. ToS источника по поводу скрейпинга / массовой загрузки GPX + +OSM API имеет **документированный публичный contract**. Использование `bbox + page` пагинации — штатный сценарий, не «скрейпинг» (это публичный API). + +Operational limit, опубликованный OSM: +- bbox area ≤ 0.25 deg² на запрос (жёсткий серверный лимит). +- Public usage policy (): «Heavy use must be at least 1 sec between requests, no faster». Рекомендация — `1 req/sec`, что и зафиксировано в `gps_sources.yaml::osm.rate_limit_sec = 1`. +- При злоупотреблении OSM Operations Team вправе временно блокировать IP. Митигация в `10-tech-risks.md` R-5. + +**Вывод:** массовая выгрузка по bbox разрешена при соблюдении rate-limit. + +### 2. robots.txt + +`https://api.openstreetmap.org/robots.txt`: +``` +User-agent: * +Disallow: +``` + +Все эндпоинты API доступны без ограничений robots. + +### 3. Условия публикации чужих треков + +ODbL даёт «свободу копировать, изменять, использовать и предоставлять третьим лицам» при условии: +- **Attribution.** Атрибуция OSM contributors с указанием ODbL. +- **Share-alike.** Производное произведение должно распространяться на условиях, совместимых с ODbL. +- **Keep open.** Если производное произведение публикуется, source-data не должна закрываться. + +Применительно к ET-008: +- Атрибуция OSM выводится MapLibre автоматически при наличии source с правильным `attribution` (уже работает для базового слоя «Схема»). +- В `gps_sources.yaml::osm.attribution = "© OpenStreetMap contributors (ODbL)"` дополнительно выставляется на ВСЕ агрегированные данные. +- В popup трека (REQ-F-18) выводится ссылка на оригинал `https://www.openstreetmap.org/user/{user}/traces/{id}`. +- Share-alike относится к опубликованной нами производной БД. `data/gps_tracks.sqlite` **не публикуется наружу** — отдаётся только через FastAPI как агрегированный сервисный слой. Это попадает под «Produced Work» определение ODbL и атрибуция здесь обязательна, share-alike — нет. + +**Имя автора** (`user`) — публичное поле OSM-трека (видно на странице трека); сохранение `user` не нарушает ToS, при этом — см. §5 ниже. + +### 4. Rate-limit + +- Конфигурация `gps_sources.yaml::osm.rate_limit_sec = 1` (1 запрос в секунду). +- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` — соответствует требованию OSM API «provide a clear user agent with contact information». +- Backoff на 429/503 — экспоненциальный 2^n до 3 попыток (TRZ §6.3). +- Per-source максимальное число запросов на прогон — не ограничено явно; ЦФО+Чувашия ≈ 700 cells × 5 pages × 1 сек ≈ 1 час реального времени (REQ-NF-02). Это < 6-часового cron-окна и существенно меньше «heavy use» порога OSM. + +### 5. Метаданные, запрещённые к сохранению + +ODbL не накладывает ограничений на сохранение публично доступных полей. Однако: + +- **`user` (имя автора)** — публикуется OSM на странице трека; сохранение разрешено. **Решение ET-008: сохраняем**, потому что это даёт пользователю credit в popup; это семантика самой OSM. +- **`description`, `tags`** — публичные, сохраняем. +- **GPS-точки** — публичные (трек загружен автором как public; private/trackable треки не отдаются в `trackpoints` API). Сохраняем как геометрию. +- **`email`, `display_name` отдельно от `user`** — OSM API таких полей в `gpx`-эндпоинте не отдаёт; сохранять нечего. + +### 6. Удаление по требованию автора + +Если автор удалит трек на OSM (PUT visibility=private или DELETE): +- Следующий полный прогон pipeline по тому же bbox не найдёт этот `gpx_id` → запись в нашей БД останется (stale). +- Митигация: per-source GC-проход (отдельная команда `gps_collect.py --gc-stale`) сравнивает наши `external_id` со списком актуальных id OSM и удаляет stale. +- На MVP **только реактивно**: при ручном запросе автора через issue tracker оператор может удалить запись по `external_id = "osm-"`. Автоматический GC-проход — отдельный work item. + +### 7. Полученное юридическое заключение + +OSM Public GPS Traces — **самый изученный** open-data источник; используется тысячами open-source проектов (OsmAnd, JOSM, Strava Routes, и т.д.) для аналогичных целей. ODbL — стандартизованная лицензия фондом Open Knowledge. Внешнего юридического review не требуется для MVP. + +## Решение + +**Источник OSM Public GPS Traces включается в pipeline как `enabled: true` в `gps_sources.yaml`** со следующими параметрами: + +```yaml +- id: osm + name: "OSM Public GPS Traces" + enabled: true + license_adr: "docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md" + base_url: "https://api.openstreetmap.org/api/0.6" + rate_limit_sec: 1 + user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)" + attribution: "© OpenStreetMap contributors (ODbL)" + parser_module: "src.api.gps_tracks.sources.osm" + save_user_field: true # ADR-009 §5 разрешает + external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}" +``` + +Атрибуция автоматически выводится MapLibre в правом нижнем углу карты при включённом source (REQ-NF-06). + +## Последствия + +### Положительные + +- Самый стабильный источник: документированный API, ODbL — общепринятая open-data лицензия, нет коммерческих условий, нет API-ключей. +- BRD-метрика «≥ 3 источника, отдающих данные» закрывается через OSM + 2 других после ADR-010/011. +- OSM-треки — единственный гарантированно доступный источник; даже если ADR-010/011 будут отклонены, OSM в одиночку покрывает BRD-минимум. + +### Отрицательные / ограничения + +- OSM-treki не имеют `activity_type` — у нас по умолчанию `other`. Уточнение возможно через `tags` (если автор пометил «moto/enduro/mtb»). Mapping в `osm.py::MAPPING` (TRZ REQ-F-07). Часть треков останется `other` — это ожидаемо. +- IP-сервера mva154 будет «известен» OSM как scraper. Это допустимо при честном User-Agent + соблюдении rate-limit. +- Stale-tracks (удалённые автором, оставшиеся у нас) — GC задача для post-MVP. + +## Классификация изменения + +**Minor change.** Источник со стандартной open-лицензией, без скрейпинга HTML, без коммерческих условий. `arch:major-change` не требуется на уровне отдельного licensing-ADR (общая major-классификация — на ADR-005 и ADR-007). + +## Связанные документы + +- `docs/work-items/ET-008/01-brd.md` §4 «Источники», «Юридический минимум» +- `docs/work-items/ET-008/02-trz.md` REQ-F-04 +- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (licensing guard) +- `docs/work-items/ET-008/08-data-requirements.md` §5 (персональные данные) +- `docs/work-items/ET-008/10-tech-risks.md` R-5 (rate-limit), R-9 (licensing enforcement) +- +- +- diff --git a/docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md b/docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md new file mode 100644 index 0000000..cf1bf28 --- /dev/null +++ b/docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md @@ -0,0 +1,142 @@ +--- +type: adr +work_item_id: ET-008 +adr_id: ADR-010 +title: "ADR-010: Источник EnduroRussia.ru — БЛОКИРОВАН до завершения ToS/robots-ревью; pipeline пропускает source до перехода status=accepted" +status: proposed +created_at: 2026-06-01 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "ET-008:source-licensing" + - "blocking" +--- + +# ADR-010 — EnduroRussia.ru: licensing review (БЛОКИРУЮЩИЙ) + +## Статус + +**Proposed** — заблокирован до полного review. + +> Pipeline (`scripts/gps_collect.py`) при загрузке `enduro_russia` parser **проверяет** этот ADR. Пока `status: proposed` — source пропускается с записью `pipeline_runs.status = "skipped_license"`. См. ADR-007 §6 — licensing guard. + +## Контекст + +BRD §4 ET-008 требует обязательный ADR licensing-review для каждого внешнего источника **до** активации в pipeline. Источник `EnduroRussia.ru` упомянут BRD как один из приоритетных (категория «эндуро-treki по регионам»), но в отличие от OSM (ADR-009) не имеет документированного публичного API, а отдаёт треки через HTML + прямые GPX-ссылки на страницах. + +Этот ADR — **шаблон для completion**. До тех пор пока не выполнен полный чеклист ниже (включая получение явных ответов от платформы при их недоступности из robots/ToS), source находится в состоянии `proposed` и pipeline его пропускает. + +## Чеклист по BRD §4 (открытые вопросы) + +### 1. ToS источника по поводу скрейпинга / массовой загрузки + +**ОТКРЫТО.** Необходимо: + +- Извлечь актуальную версию пользовательского соглашения с `enduro-russia.ru/agreement` или аналогичной страницы. +- Найти/получить ответ на вопросы: + - Разрешён ли автоматизированный сбор страниц? + - Разрешено ли массовое скачивание GPX-файлов, опубликованных пользователями платформы? + - Допускается ли передача / републикация GPX третьим лицам (т.е. отдача через наш API)? +- При отсутствии явного разрешения — отправить запрос администратору платформы по контактам (`info@enduro-russia.ru` или эквивалент) с описанием цели использования; **получить письменное подтверждение** (email или его архив). + +**Принимаемый статус:** +- Если ToS явно разрешает или администратор подтверждает → §7 решения переключается на `accepted`. +- Если ToS явно запрещает либо администратор отказал → этот ADR превращается в `rejected`, source удаляется из `gps_sources.yaml` (или остаётся `enabled: false`). +- При неоднозначности — `deferred`; source не включается в MVP, повторное review через 6 месяцев. + +### 2. robots.txt + +**ОТКРЫТО.** Прочитать `https://enduro-russia.ru/robots.txt` и зафиксировать выписку в этот раздел при completion. + +Принимаемое правило: +- `Disallow: /treki/` или `Disallow: /` → source отклоняется автоматически. +- `Crawl-delay: N` — `rate_limit_sec` в конфиге выставляется не меньше N. +- Отсутствие robots.txt — трактуется как «нет явного запрета» (но не «явное разрешение» — см. §1). + +### 3. Условия публикации чужих треков + +**ОТКРЫТО.** Установить: +- Какая лицензия применяется к user-generated content на платформе. +- Указано ли в ToS, что платформа предоставляет автору право выкладывать на других площадках. +- Содержат ли GPX-метаданные явный copyright notice/CC-лицензию автора. + +Если лицензия не CC-by или совместимая → сохраняем **только** геометрию и обезличенные поля; полей `user`, `name` автора, `description` — **не сохраняем** (`save_user_field: false`, `save_description: false`). + +### 4. Rate-limit + +Предварительная установка (до получения данных §1–§2): + +- `rate_limit_sec: 5` (5 сек между запросами; консервативно). +- Per-source максимум на прогон — 1000 новых треков (BRD §6 риск трафика). +- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` с контактным URL. +- Backoff на 429/503: exponential 2^n, 3 попытки. +- При 4 неудачных прогонах подряд — алерт в health-эндпоинт (TRZ REQ-F-12); оператор приостанавливает source вручную (`enabled: false`). + +### 5. Метаданные, запрещённые к сохранению + +**Default до §3 review** — сохраняем только: +- `external_id` (id записи на платформе). +- `external_url` (ссылка на страницу трека на платформе). +- `geom` (геометрия). +- `length_m`, `points_count` (производные). +- `activity_type` (категория с самой платформы → ACTIVITY_TYPES через `MAPPING`). +- `created_at` (дата трека, если публично доступна). + +Не сохраняем без явного зелёного света §3: +- `user` (имя автора). +- `name` трека. +- `description`. +- Любые координаты waypoint, отдельные от основной геометрии (точки «домой»/«стоянка»). + +### 6. Удаление по требованию автора + +- Сохраняем `external_url` и `external_id` — это гарантирует точечное удаление по запросу. +- При полном пере-сборе pipeline записи, не найденные на источнике, помечаются как stale → удаляются GC-проходом. +- Реактивное удаление по issue — оператор через ssh: `DELETE FROM tracks WHERE external_urls_json LIKE '%%'`. + +### 7. Решение licensing + +**Текущее: proposed (БЛОКИРОВАН).** Pipeline source `enduro_russia` находится в `gps_sources.yaml` как `enabled: false` (или отсутствует) пока этот ADR не переключён в `accepted`. + +**Critical path для разблокировки:** +1. Аналитик/PO завершает §1–§3 (получение/архивирование ответа от платформы). +2. Архитектор обновляет этот ADR: §1/§2/§3 заполнены, status → `accepted`, добавляются принятые параметры. +3. В `gps_sources.yaml` source переключается на `enabled: true`. +4. Следующий cron-прогон pipeline начинает собирать треки. + +Без завершения шага 1 source **не включается** в MVP. Это соответствует BRD §4 «Источник без явного зелёного света в ADR — не включается». + +## Решение (до review) + +Source `enduro_russia` в `gps_sources.yaml` присутствует с `enabled: false`. Parser-модуль `src/api/gps_tracks/sources/enduro_russia.py` **разработан и протестирован** (TRZ REQ-F-05), но pipeline до accepted-status не загружает его. + +Это даёт два полезных эффекта: +- Код парсера живёт в репозитории — review/security audit возможны до активации. +- Активация — однострочное изменение конфига после ADR-апрува, не требует деплоя кода. + +## Последствия + +### Положительные + +- Юридическое условие BRD §4 выполняется автоматически: source не работает до явного разрешения. +- Тех-долг minimal: парсер уже написан и покрыт тестами с фикстурами; активация = один YAML-флаг. + +### Отрицательные / ограничения + +- BRD-метрика «≥ 3 источника в продакшне» **не закрыта**, пока этот ADR не accepted. На MVP — закроется через OSM (ADR-009) + ttrails (ADR-011) при условии что любой из этих двух или этот один достигнет accepted. +- Затягивание review = source не виден пользователю. Это сознательный compromise: лучше задержать фичу, чем нарушить ToS. + +## Классификация изменения + +**Minor change** на уровне ADR; **blocking** на уровне MVP-метрики «≥ 3 источника». + +## Связанные документы + +- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум» +- `docs/work-items/ET-008/02-trz.md` REQ-F-05 +- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (runtime-guard) +- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (паттерн ADR licensing для сравнения) +- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md` +- `docs/work-items/ET-008/10-tech-risks.md` R-9 diff --git a/docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md b/docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md new file mode 100644 index 0000000..aa2f02f --- /dev/null +++ b/docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md @@ -0,0 +1,91 @@ +--- +type: adr +work_item_id: ET-008 +adr_id: ADR-011 +title: "ADR-011: Источник Тропинки.ру / ttrails.ru — БЛОКИРОВАН до завершения ToS/robots-ревью; pipeline пропускает source до перехода status=accepted" +status: proposed +created_at: 2026-06-01 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "ET-008:source-licensing" + - "blocking" +--- + +# ADR-011 — ttrails.ru (Тропинки.ру): licensing review (БЛОКИРУЮЩИЙ) + +## Статус + +**Proposed** — заблокирован до полного review. + +> Pipeline (`scripts/gps_collect.py`) при загрузке `ttrails` parser **проверяет** этот ADR. Пока `status: proposed` — source пропускается с записью `pipeline_runs.status = "skipped_license"`. См. ADR-007 §6 — licensing guard. + +## Контекст + +Источник `ttrails.ru` (Тропинки.ру, эндуро-категория) — публичная платформа с GPX-загрузками без авторизации (BRD §4 #3). Структурно повторяет случай EnduroRussia.ru (ADR-010): не имеет документированного API, доступ через HTML-страницы + ссылки на GPX-файлы. + +Принципы и чеклист — те же, что в ADR-010. Здесь — только специфика ttrails. + +## Чеклист по BRD §4 + +### 1. ToS источника по поводу скрейпинга / массовой загрузки + +**ОТКРЫТО.** Аналогично ADR-010 §1: +- Найти и архивировать ToS платформы (`ttrails.ru/about`, `/agreement` или эквивалент). +- При отсутствии разрешения — связаться с администратором, получить письменный ответ. + +### 2. robots.txt + +**ОТКРЫТО.** Прочитать `https://ttrails.ru/robots.txt`, зафиксировать выписку. + +### 3. Условия публикации чужих треков + +**ОТКРЫТО.** Установить лицензию user-generated content. Default — пока не подтверждено иное: +- Сохраняем только обезличенные поля (геометрия, length, points_count, activity_type, created_at если публично доступна). +- Не сохраняем `user`, `name`, `description`. + +### 4. Rate-limit + +Предварительная установка: +- `rate_limit_sec: 5` (консервативно). +- Per-source максимум на прогон — 1000 треков. +- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)`. +- Backoff на 429/503 — exponential 2^n, 3 попытки. + +### 5. Метаданные, запрещённые к сохранению + +Default — как ADR-010 §5. Пересмотр после §3 review. + +### 6. Удаление по требованию автора + +- `external_url` + `external_id` сохраняются → точечное удаление по запросу автора. +- Stale-GC — отдельный work item. + +### 7. Решение licensing + +**Текущее: proposed (БЛОКИРОВАН).** Source `ttrails` в `gps_sources.yaml` остаётся `enabled: false` или отсутствует. + +**Critical path для разблокировки:** см. ADR-010 §7. + +## Решение (до review) + +Source `ttrails` в `gps_sources.yaml` присутствует с `enabled: false`. Parser-модуль `src/api/gps_tracks/sources/ttrails.py` разрабатывается и тестируется (TRZ REQ-F-06), но не активен. + +## Последствия + +См. ADR-010 §«Последствия». Идентичная логика. + +## Классификация изменения + +**Minor change** на уровне ADR; **blocking** на уровне MVP-метрики «≥ 3 источника» (вместе с ADR-010). + +## Связанные документы + +- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум» +- `docs/work-items/ET-008/02-trz.md` REQ-F-06 +- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (licensing guard) +- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (паттерн ADR licensing accepted) +- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` (одно-к-одному паттерн) +- `docs/work-items/ET-008/10-tech-risks.md` R-9 diff --git a/docs/work-items/ET-008/07-infra-requirements.md b/docs/work-items/ET-008/07-infra-requirements.md new file mode 100644 index 0000000..88f8dbd --- /dev/null +++ b/docs/work-items/ET-008/07-infra-requirements.md @@ -0,0 +1,323 @@ +--- +type: infra-requirements +work_item_id: ET-008 +title: "Инфраструктурные требования — ET-008: GPS-треки с публичных платформ" +version: 1 +status: approved +created_at: 2026-06-01 +authors: + - "agent:architect" +--- + +# Инфраструктурные требования — ET-008 + +## 1. Резюме + +В отличие от ET-007 (только-фронтенд), ET-008 — **серверная фича со scheduled-pipeline**. Изменения охватывают: + +- Новый docker-compose service `gps-collector` (тот же образ, что `app`, с `profiles: [batch]`). +- Новый файл БД на mva154: `data/gps_tracks.sqlite` (≤ 2 ГБ). +- Новая cron-запись на хосте mva154. +- Новый каталог логов `/var/log/enduro-trails/`. +- Новые Python-зависимости в общем образе: `defusedxml`, `pyyaml`. +- Новые исходящие HTTPS-вызовы из контейнера `gps-collector` к 1–3 внешним источникам. + +Все изменения помещаются в существующий docker-compose стек без введения новых контейнеров API/нового reverse-proxy/новой БД-движка. Эскалация: `arch:major-change` (см. ADR-005, ADR-007). + +## 2. Контейнеры и сервисы + +| Аспект | Требование | +|---|---| +| Новый сервис `app` (FastAPI) | Не вводится; существующий API расширяется новыми routes `/api/gps-tracks/*` через регистрацию роутера из `src/api/gps_tracks/endpoint.py` | +| Новый сервис `gps-collector` | **Да.** docker-compose service, `profiles: ["batch"]`, тот же `build: .`, command `python -m scripts.gps_collect`, `restart: "no"`. Не стартует штатно при `docker compose up -d`. Активируется только запуском `docker compose --profile batch run --rm gps-collector` | +| Изменение `Dockerfile` | `COPY scripts/ ./scripts/`, `COPY config/ ./config/`. Текущий Dockerfile (`COPY src/api/ src/api/`, `COPY src/web/ src/web/`) не содержит `scripts/` и `config/` — нужно добавить две `COPY`-строки | +| Новый блок в `docker-compose.yml` | ≈ 15 строк (см. ADR-007 §1) | +| Изменения OSRM, nginx | Нет | +| Перезапуск API после деплоя | Нужен (новые routes регистрируются при старте FastAPI) — стандартный `docker compose up -d --no-deps app` | +| Простой API | ≤ 5 секунд (рестарт контейнера API). Pipeline-сервис independent — его запуск/остановка не аффектит API | + +### 2.1 Зависимости между сервисами + +- `gps-collector` **не** имеет `depends_on: [app]`. Он работает с БД-файлом напрямую через примонтированный volume `/app/data`. +- В конце прогона pipeline дёргает HTTP `POST http://app:5556/api/gps-tracks/cache/clear` (внутренняя docker-сеть). Если `app` недоступен — pipeline пишет WARNING в лог, успех прогона не отменяется (ADR-007 §7). +- Сетевое имя `app` доступно потому что оба сервиса в одной default-сети docker-compose. + +### 2.2 Конфликт с production API во время прогона + +- Pipeline пишет в `data/gps_tracks.sqlite` в WAL-mode (ADR-005 §5). API читает ту же БД — видит снэпшот checkpoint'а; конкуренция не блокирует читателей. +- CPU/RAM: pipeline ограничен через docker-cgroup limits (см. §9 ниже). Параллельный API не деградирует. + +## 3. Сеть + +| Аспект | Требование | +|---|---| +| Новые серверные порты на mva154 | Нет | +| Изменения reverse proxy (`/enduro/` в nginx) | **Минимальные.** Новые routes `/api/gps-tracks/*` уже попадают под существующий `location /api/` proxy_pass. Дополнительных правил не нужно | +| Внутренние DNS / docker-сеть | Стандартная default-сеть docker-compose. Service-name `app` резолвится в адрес API-контейнера; используется pipeline для cache-clear | +| **Endpoint `POST /api/gps-tracks/cache/clear`** | **Ограничен docker-internal**: блок `RealIPFromTrustedProxy` в nginx (proxy mva154) **не пропускает** `POST` на этот endpoint извне. Деталь: в nginx-конфиге `location = /api/gps-tracks/cache/clear { allow 172.0.0.0/8; deny all; }` — допуск только из docker-сетей | +| Новые исходящие HTTPS-вызовы из mva154 | **Да.** Из контейнера `gps-collector`:
• `api.openstreetmap.org` (ADR-009) — всегда;
• `enduro-russia.ru` (ADR-010) — пока accepted;
• `ttrails.ru` (ADR-011) — пока accepted | +| Firewall mva154 | Исходящие HTTPS уже разрешены (BRD §7); правил не добавляется | +| Внешние входящие | Только существующий `/enduro/` через nginx — без изменений | + +### 3.1 Ограничение cache-clear + +Cache-clear endpoint **должен быть закрыт от внешнего интернета** (он сбрасывает производительный кэш, потенциальный DoS-вектор). Реализация: + +```nginx +# /etc/nginx/sites-available/openclaw — добавляется в существующий server { } для /enduro/ +location = /enduro/api/gps-tracks/cache/clear { + allow 172.16.0.0/12; # docker default networks + allow 127.0.0.1; + deny all; + proxy_pass http://app:5556/api/gps-tracks/cache/clear; +} +``` + +Pipeline дёргает endpoint напрямую через docker-сеть (`http://app:5556/...`), не через nginx → реальный путь обходит правило allow/deny и работает. Snippet выше защищает только публичный путь через `/enduro/`. + +## 4. Хранилища данных + +| Аспект | Требование | +|---|---| +| Новая БД | `data/gps_tracks.sqlite` (SQLite + Spatialite extension) | +| Расположение на хосте | `/home/slin/enduro-trails/data/gps_tracks.sqlite` (`./data` в `docker-compose.yml`) | +| Расположение в контейнерах | `/app/data/gps_tracks.sqlite` | +| Создание | Pipeline создаёт при первом запуске; миграция `migrations/gps_tracks_001_init.sql` применяется автоматически (см. §4.2) | +| Размер | Ожидаемо ≤ 500 МБ для ЦФО+Чувашии при 5000 треков; верхний предел операционный — **2 ГБ** (REQ-NF-03). Алерт > 2 ГБ — см. `10-tech-risks.md` R-4 | +| Spatialite-extension | Уже доступен в python-образе через `pysqlite3-binary`? Нет: текущий образ использует stdlib `sqlite3`. Нужно установить системный пакет `libsqlite3-mod-spatialite` (см. §4.3) | +| Изменения схемы существующей `centralfederal.sqlite` | Нет | +| Миграции существующих таблиц | Нет | + +### 4.1 Зачем отдельная БД + +См. ADR-005 §«Решение D-A». Изоляция backup-цикла, ротации, риска повреждения, write-конкуренции. + +### 4.2 Миграция + +`migrations/gps_tracks_001_init.sql` — IDempotent CREATE TABLE IF NOT EXISTS + R-tree creation. Применяется автоматически из `src/api/gps_tracks/db.py::ensure_schema()` при первом коннекте (ленивая инициализация). Никакого `alembic` или внешнего раннера миграций. + +### 4.3 Установка Spatialite в Docker-образе + +Изменение `Dockerfile`: + +```dockerfile +FROM python:3.12-slim +WORKDIR /app +# ET-008: Spatialite extension для slot.api.gps_tracks.db +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsqlite3-mod-spatialite \ + && rm -rf /var/lib/apt/lists/* +COPY src/api/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY src/api/ ./src/api/ +COPY src/web/ ./src/web/ +COPY scripts/ ./scripts/ # ET-008 +COPY config/ ./config/ # ET-008 +ENV STATIC_DIR=/app/src/web +ENV PORT=5556 +EXPOSE 5556 +CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "5556"] +``` + +Образ увеличится на ≈ 30 МБ (модуль Spatialite). На размер production-нагрузки не влияет. + +### 4.4 Backup + +- **Ежедневный snapshot** через cron на mva154: + ```cron + 0 5 * * * root sqlite3 /home/slin/enduro-trails/data/gps_tracks.sqlite ".backup /home/slin/enduro-trails/backups/gps_tracks-$(date +\%F).sqlite" + ``` +- Retention 14 дней — отдельный `find ... -mtime +14 -delete`. +- Pipeline-running во время backup допустим: `.backup` в sqlite3 — атомарный, использует WAL. +- Восстановление: остановить `gps-collector` запуски, `cp` snapshot в `data/gps_tracks.sqlite`, перезапустить API (cache-clear автоматически). + +### 4.5 Клиентское хранилище + +| Ключ localStorage | Значение | Default | +|---|---|---| +| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` | +| `gps-tracks-activities` | JSON-array | все ACTIVITY_TYPES | +| `gps-tracks-sources` | JSON-array | все enabled source IDs | +| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` | + +Суммарный объём ≤ 256 байт. Конвенция имён согласуется с существующими (`enduro-theme-mode`, `terrain-*`, `trails-*`, `map-base-layer`). + +Подробности — `08-data-requirements.md` §4. + +## 5. Конфигурация и секреты + +| Аспект | Требование | +|---|---| +| Новые env-переменные API-контейнера | `GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite` | +| Новые env-переменные gps-collector | `GPS_TRACKS_DB_PATH`, `GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml`, `GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml` | +| Новые секреты / API-ключи | **Нет** — все источники без авторизации (см. ADR-009, ADR-010, ADR-011 — outside source без ключа; платные API явно out of scope BRD §3) | +| Новые конфиг-файлы в репозитории | `config/gps_sources.yaml`, `config/gps_regions.yaml` — оба под git-контролем | +| Изменения reverse-proxy / nginx | Только cache-clear защита (§3.1) | +| Изменения OSRM | Нет | + +## 6. Зависимости + +| Аспект | Требование | +|---|---| +| Python-пакеты (`src/api/requirements.txt`) — добавить | `defusedxml==0.7.1` (безопасный XML-парсинг GPX), `pyyaml==6.0.1` (конфиги pipeline) | +| Python-пакеты — НЕ добавлять | `lxml` (упомянут в BRD §7 как опция; для GPX-парсинга достаточно `defusedxml.ElementTree`; экономит ≈ 8 МБ образа). `tenacity` — реализуем backoff inline (≈ 30 строк, TRZ §6.3) чтобы не вводить ещё один пакет | +| Системные библиотеки в Dockerfile | `libsqlite3-mod-spatialite` (см. §4.3) | +| Версия Python | 3.12, без изменений | +| Новые third-party runtime-зависимости (внешние сервисы) | • `api.openstreetmap.org` — OSM API (ADR-009)
• `enduro-russia.ru` — после ADR-010 accepted
• `ttrails.ru` — после ADR-011 accepted | +| Альтернативные источники / fail-over | Не закладывается; каждый source изолирован (ADR-007 §I-A); падение одного не валит других | + +## 7. Сборка и деплой + +- **Pipeline CI:** существующий Gitea Actions (`make lint` + `make test` + `make build`). Новые backend-tests (`tests/api/test_gps_tracks_*.py`) добавляются в существующий pytest. Новые frontend-tests — в существующий ESLint и JS-test pipeline. +- **Артефакт:** Docker-образ. После ET-008 один образ запускается **двумя сервисами** (`app` и `gps-collector` через `profiles`). Это стандартный паттерн docker-compose. +- **Деплой шаг-за-шагом:** + 1. `git pull origin main` на mva154. + 2. `docker compose build` (пересобирает образ с `libsqlite3-mod-spatialite`). + 3. `docker compose up -d --no-deps app` (перезапускает только API; `gps-collector` profile-disabled). + 4. Установить cron-запись (см. §8). + 5. Первый ручной запуск pipeline в dry-run: + `docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --dry-run` + 6. Проверить `/api/gps-tracks/health` — БД создана, пуста. + 7. Запустить production-сбор: + `docker compose --profile batch run --rm gps-collector` (≤ 6 часов). + 8. Smoke: открыть `/enduro/`, включить чекбокс «Публичные треки», убедиться что слой виден. + +- **Время простоя API:** ≤ 5 секунд на шаге 3. +- **Время простоя pipeline:** не применимо — pipeline не daemon. + +### 7.1 Cron-запись + +`/etc/cron.d/enduro-gps` (root-owned, 0644): +```cron +# ET-008: GPS Tracks Pipeline +# Mon + Thu 03:00 UTC — full collection +0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1 + +# 1-е число каждого месяца 04:00 UTC — GC stale tracks +0 4 1 * * root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --gc >> /var/log/enduro-trails/gps-gc.log 2>&1 +``` + +Никаких отдельных `flock` / `lockfile` — cron-окно (3 дня) > длительности прогона (≤ 6 ч). + +### 7.2 Rollback + +| Откат | Действие | Время | +|---|---|---| +| Откат кода (revert + redeploy) | `git revert && docker compose up -d --build app` | ≈ 2 мин | +| Откат БД (повреждение / неверная схема) | Остановить `gps-collector` cron, `cp backups/gps_tracks-.sqlite data/gps_tracks.sqlite`, рестарт API | ≈ 1 мин | +| Полный отказ от фичи (kill switch) | Закомментировать cron-строки, удалить `gps-tracks-cb` checkbox в UI через `display:none` | ≈ 1 мин | +| Откат от pipeline без отката API | Закомментировать cron-строки — API продолжает отдавать собранное | мгновенно | + +Скрипт `scripts/disable_gps_pipeline.sh` (TODO в `04-test-plan.yaml`) автоматизирует «kill switch». + +## 8. Cron / scheduled jobs + +См. §7.1. + +**Мониторинг cron:** +- При сбое cron-job отправляется email на адрес администратора через стандартный `cron MAILTO=` (mva154 уже настроен). Опционально — алерт в Telegram, но это outside scope (если в проекте уже есть алерт-канал — используется он). +- `/api/gps-tracks/health` отдаёт `last_pipeline_run.sources_error` — оператор видит при ручной проверке/мониторинге. + +## 9. Ресурсы (CPU / RAM / диск) + +### 9.1 API-контейнер + +- **CPU:** +5% от текущего baseline за счёт MVT-генерации нового слоя. На существующем mva154 (по BRD §1 одиночный сервер) — не критично. +- **RAM:** +50 МБ baseline (новые модули) + до 64 МБ LRU-кэш MVT-тайлов (1024 × ~64 КБ). Итого +120 МБ. Текущий API использует ≈ 200 МБ; после ET-008 — ≈ 320 МБ. +- **Network egress:** +0 (внутри сервера; клиент скачивает с того же mva154). + +### 9.2 gps-collector контейнер (во время прогона) + +- **CPU:** ограничен docker-compose cgroup `cpus: "1.0"` (один логический CPU) — pipeline не вытесняет API. +- **RAM:** ограничен `mem_limit: 512m`. На практике pipeline + asyncio + httpx + shapely + спарс одного парсера ≤ 200 МБ; запас 2.5×. +- **Network egress (mva154 → external):** для OSM ≈ 100 МБ за прогон (≤ 5000 треков × ≤ 20 КБ), для скрейпинга — порядок 10–100 МБ. Полная стоимость cron-прогона ≈ 200 МБ / неделю — пренебрежимо. +- **Network ingress:** не применимо. + +```yaml +# docker-compose.yml фрагмент +services: + gps-collector: + # ... + cpus: "1.0" + mem_limit: 512m + pids_limit: 256 +``` + +### 9.3 Диск + +- `data/gps_tracks.sqlite` — ≤ 2 ГБ. +- Лог-файлы `/var/log/enduro-trails/*.log` — ротация через logrotate, default 14 дней × ≤ 50 МБ = ≤ 700 МБ. +- Backup-снапшоты — ≤ 14 × 2 ГБ = ≤ 28 ГБ (с retention; см. §4.4). +- Сумма: + ≈ 30 ГБ на текущий disk-budget mva154. + +## 10. Наблюдаемость + +| Артефакт | Источник | Использование | +|---|---|---| +| `GET /api/gps-tracks/health` | API (читает `pipeline_runs` из БД) | Оператор проверяет вручную или через monitoring | +| `/var/log/enduro-trails/gps-collect.log` | Cron stdout/stderr | Лог cron-выполнений: успех/код возврата/исключения | +| `/var/log/enduro-trails/pipeline-.jsonl` | Pipeline structured log | Per-run JSON-lines: source, region, статус, tracks_new | +| `pipeline_runs` в БД | Pipeline-side | Историческая трассировка для health-эндпоинта | +| Docker `docker compose logs app` | API stdout | Запросы `/api/gps-tracks/*`, ошибки SQL | + +### 10.1 Алерты + +- **Cron MAILTO** при ненулевом exit code прогона — стандартный механизм. +- **2 неудачных прогона подряд для одного source** — `pipeline_runs` собирает; алерт **не автоматический** (out of MVP), оператор увидит при ручной проверке `/health` или в weekly review. Алерт-канал — отдельный work item. +- **db_size_mb > 2 ГБ** — health отдаёт значение; внешний мониторинг (если есть) пинает. +- **Ошибка лицензионного guard'а** (`status: "skipped_license"`) — оператор видит в `pipeline_runs`; не алерт-кейс, нормальное поведение до accepted-ADR. + +### 10.2 Logrotate + +``` +# /etc/logrotate.d/enduro-gps +/var/log/enduro-trails/*.log { + daily + rotate 14 + compress + missingok + notifempty +} +/var/log/enduro-trails/pipeline-*.jsonl { + weekly + rotate 8 + compress + missingok + notifempty +} +``` + +## 11. Безопасность + +- **Парсинг XML на сервере (GPX)** — через `defusedxml.ElementTree` (защита XXE / billion laughs). `lxml` не используется. +- **Endpoint `POST /api/gps-tracks/cache/clear`** — ограничен docker-internal сетью на уровне nginx (§3.1). Pipeline ↔ API остаются связаны через docker-сеть. +- **Скрейпинг — только outgoing** с mva154. Никаких open ports. +- **Атаки на pipeline через подделанные GPX** (источник вернул malformed XML, exploding XML) — митигируется `defusedxml` и timeout `httpx.get(timeout=30)`. Per-track exception isolated в pipeline-loop. +- **CSP-заголовок** — в проекте отсутствует (см. ET-007 §3.2). ET-008 ничего не меняет. + +## 12. Влияние на C4 / архитектурную документацию + +Изменения состава компонентов: + +- **Новый компонент** в стеке mva154: docker-compose service `gps-collector` (batch). +- **Новая БД** `data/gps_tracks.sqlite`. +- **Новые внешние зависимости рантайма**: 1–3 платформы (OSM всегда + 0/1/2 после ADR-010/011). +- **Новые scheduled-jobs**: 2 cron-записи. + +`docs/architecture/README.md` обновляется новым разделом «GPS Tracks Pipeline (ET-008)» с описанием компонента, БД, внешних зависимостей и расписания. + +`docs/architecture/adr/README.md` пополняется записями ADR-005..ADR-011. + +C4 mmd-диаграмм в проекте нет — текстовое описание (по прецеденту ADR-004 §8). + +## 13. Вывод + +ET-008 — **major-change** на инфра-уровне: +- Новый docker-compose service. +- Новый файл БД. +- Первые scheduled jobs (cron) на mva154. +- Новые исходящие сетевые соединения с обязательными licensing-ADR. + +Все элементы — расширение существующего стека (не новый stack). Реверсная процедура и rollback — однострочные операции. + +Эскалация: лейбл `arch:major-change` выставлен на ADR-005 и ADR-007. Архитектурный approve обязателен перед merge. diff --git a/docs/work-items/ET-008/08-data-requirements.md b/docs/work-items/ET-008/08-data-requirements.md new file mode 100644 index 0000000..b4d1162 --- /dev/null +++ b/docs/work-items/ET-008/08-data-requirements.md @@ -0,0 +1,382 @@ +--- +type: data-requirements +work_item_id: ET-008 +title: "Требования к данным — ET-008: GPS-треки с публичных платформ" +version: 1 +status: approved +created_at: 2026-06-01 +authors: + - "agent:architect" +--- + +# Требования к данным — ET-008 + +## 1. Резюме + +ET-008 вводит: + +- **Новую серверную БД** `data/gps_tracks.sqlite` (Spatialite) с двумя таблицами: `tracks`, `pipeline_runs`. +- **Контракт публичного API GeoJSON** и **MVT layer schema** (см. TRZ §4.2, §4.3 — здесь финализируется). +- **Внешние входные данные** — GPS-треки с 1–3 публичных платформ. +- **Клиентское хранилище** (`localStorage`) — 4 новых ключа состояния UI. +- **Персональные данные**: возможно `user` (имя автора публичного трека) для OSM (ADR-009 разрешает); для других источников — пока заблокировано (ADR-010, ADR-011). + +## 2. Архитектурные границы данных + +| Слой данных | Тип | Расположение | Владелец | Lifecycle | +|---|---|---|---|---| +| OSM-vector (`trails`, `centralfederal.sqlite`) | существующий | `/app/data/centralfederal.sqlite` | ET-001..006 | пересборка из OSM ad-hoc | +| Личные GPX треки (ET-006) | существующий | браузер (memory only) | ET-006 | сессия | +| **Публичные GPS треки** | **новый** | `/app/data/gps_tracks.sqlite` | **ET-008** | rebuild при необходимости + ежемесячный GC | +| OSRM-граф | существующий | `/app/data/enduro.osrm.*` | PH-2 | пересборка после OSM-обновления | +| User UI state | существующий + расширение | `localStorage` браузера | каждый work item | до явной очистки | + +Между новой БД и существующей `centralfederal.sqlite` **нет cross-DB запросов** на горизонте MVP (см. ADR-005 §9). + +## 3. Серверные данные — `gps_tracks.sqlite` + +### 3.1 Таблица `tracks` + +```sql +CREATE TABLE tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dedup_key TEXT NOT NULL UNIQUE, + name TEXT, + description TEXT, + activity_type TEXT NOT NULL, -- ACTIVITY_TYPES (см. §3.4) + user TEXT, -- ADR-009 разрешает; null для ADR-010/011 до accepted + created_at TEXT, -- ISO date YYYY-MM-DD; nullable + length_m REAL NOT NULL, + points_count INTEGER NOT NULL, + min_lon REAL NOT NULL, + min_lat REAL NOT NULL, + max_lon REAL NOT NULL, + max_lat REAL NOT NULL, + geom BLOB NOT NULL, -- WKB LineString (Spatialite) + sources_json TEXT NOT NULL, -- JSON-array ["osm", "enduro_russia"] + external_urls_json TEXT NOT NULL, -- JSON-array URLs + tags_json TEXT, -- JSON-array string tags + inserted_at TEXT NOT NULL, -- ISO datetime + updated_at TEXT NOT NULL -- ISO datetime +); + +CREATE UNIQUE INDEX idx_tracks_dedup ON tracks(dedup_key); +CREATE INDEX idx_tracks_activity ON tracks(activity_type); +CREATE INDEX idx_tracks_created ON tracks(created_at); + +-- Spatialite R-tree +SELECT CreateSpatialIndex('tracks', 'geom'); +``` + +Поля `min_lon`/`max_lon`/`min_lat`/`max_lat` денормализованы из `geom` для **раннего отбрасывания** треков в MVT-генерации без парсинга WKB (ADR-005 §2). + +### 3.2 `dedup_key` + +Алгоритм — ADR-006. Формат строки: +``` +((w, s, e, n), length_bucket, "YYYY-MM-DD") +``` +где координаты округлены до 2 знаков после запятой, `length_bucket` = `round(length_m / 1000) * 1000`. UNIQUE индекс обеспечивает ON CONFLICT логику. + +### 3.3 `sources_json` и `external_urls_json` + +JSON-массивы строк. Длина ≤ 8 элементов (источников после дедупа). Порядок — стабильный по приоритету в `gps_sources.yaml`. Первый элемент `sources_json` — «первичный» источник; его id попадает в `properties.source` MVT-фичи для цветовой палитры по умолчанию (REQ-F-16). + +Пример: +```json +sources_json = ["osm", "enduro_russia"] +external_urls_json = ["https://www.openstreetmap.org/user/Vasya/traces/12345", + "https://enduro-russia.ru/treki/678"] +``` + +Запись фиксирует **тот же индекс** = тот же источник: `external_urls_json[i]` — это URL `sources_json[i]`. + +### 3.4 ACTIVITY_TYPES + +Закрытый enum (TRZ REQ-F-07): + +| code | label-ru | +|---|---| +| `enduro` | Эндуро | +| `moto` | Мото | +| `offroad` | Off-road | +| `bicycle` | Велосипед | +| `hike` | Пешком | +| `ski` | Лыжи | +| `other` | Другое | + +`MAPPING` per source — константа в `.py`. Категории источника, не найденные в MAPPING → `other`. На MVP `MAPPING` для OSM фиксирован: парсим OSM-tags (`tag: enduro` → `enduro`, `tag: motorbike` → `moto`, `tag: mtb`/`tag: bike` → `bicycle`, etc.). Точная таблица — в коде, ревью при ADR-апруве. + +### 3.5 Таблица `pipeline_runs` + +```sql +CREATE TABLE pipeline_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + region_id TEXT NOT NULL, + source_id TEXT NOT NULL, + status TEXT NOT NULL, -- ok | partial | error | skipped_license + tracks_new INTEGER DEFAULT 0, + tracks_updated INTEGER DEFAULT 0, + errors_json TEXT -- JSON object {error_type: count} +); + +CREATE INDEX idx_pipeline_started ON pipeline_runs(started_at); +``` + +История прогонов. Read-only для API; пишет только pipeline. Используется `/api/gps-tracks/health`. + +### 3.6 Размер БД + +| Объём | Оценка | +|---|---| +| Среднее число точек на трек | 1240 (по BRD §3 F-13 popup; реалистично) | +| Геометрия WKB на трек | ≈ 16 байт/точка × 1240 = 20 КБ | +| Метаданные на трек | ≈ 1 КБ | +| Итого на трек | ≈ 21 КБ | +| 5000 треков MVP | ≈ 105 МБ | +| 50 000 треков (через год при расширении) | ≈ 1.05 ГБ | +| Лимит REQ-NF-03 | 2 ГБ | + +Запас 2× от MVP-объёма до операционного лимита. При превышении — миграция на PostGIS (отдельный work item, тех-долг в ADR-005). + +### 3.7 Ротация и GC + +- Команда `python -m scripts.gps_collect --gc` (ADR-007 §3) — удаляет треки `WHERE updated_at < NOW() - 5 years`. +- Параметр `5 years` зашит в `config/gps_sources.yaml::retention_years` (default 5; per-source override возможен). +- Cron — 1-е число каждого месяца 04:00 UTC. +- Stale-cleanup (трек удалён на источнике) — отдельный GC-режим `--gc-stale`; на MVP не входит (см. ADR-009 §6). + +### 3.8 Backup + +См. `07-infra-requirements.md` §4.4. Ежедневный `.backup`, retention 14 дней. + +## 4. Клиентское хранилище + +| Ключ | Значение | Default | Расход | +|---|---|---|---| +| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` | ≤ 5 байт | +| `gps-tracks-activities` | JSON-array из ACTIVITY_TYPES | все 7 значений | ≤ 70 байт | +| `gps-tracks-sources` | JSON-array source IDs | все enabled на момент первого открытия | ≤ 80 байт | +| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` | ≤ 8 байт | +| **Итого на браузер** | | | ≤ 256 байт | + +- **Чтение**: `restorePublicTracksState()` в `rebuildMapOverlays()` (REQ-F-19); инициализация при старте приложения. +- **Запись**: каждое изменение checkbox / segmented control в `#sheet-gps-filters`. +- **Миграция со старых значений**: не требуется (ключи новые). +- **Невалидные значения**: ignore + restore defaults; не вызывают исключение. + +### 4.1 Конвенция имён + +Префиксация — `gps-tracks-*`. Согласуется с существующими (`terrain-*`, `trails-*`, `map-base-layer`). + +### 4.2 Не-персистентное состояние в памяти браузера + +```js +window.gpsTracksLayer = { + enabled: false, + filters: { + activities: [...ACTIVITY_TYPES], + sources: [...enabledSourceIds], + colorMode: 'source' + }, + sourceId: 'gps-tracks-tiles', // vector source for MVT mode + sourceGeoId: 'gps-tracks-geo', // geojson source for GeoJSON mode + layerMvtId: 'gps-tracks-layer-mvt', + layerGeoId: 'gps-tracks-layer-geo', + haloMvtId: 'gps-tracks-halo-mvt-satellite', + haloGeoId: 'gps-tracks-halo-geo-satellite', + geojsonAbortController: null, + geojsonReqDebounceTimer: null, + stats: { total: 0, shown: 0 }, + activeMode: 'mvt' | 'geo' | 'hidden' // derived from zoom +}; +``` + +Конкретное содержимое и переходы — TRZ §4.4 + ADR-008. + +## 5. Внешние входные данные + +### 5.1 OSM Public GPS Traces (ADR-009) + +| Параметр | Значение | +|---|---| +| Endpoint | `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=...&page=...` | +| Metadata | `GET https://api.openstreetmap.org/api/0.6/gpx/{id}` | +| Формат | XML (GPX 1.1) — `` + `` + meta | +| Лицензия | ODbL 1.0 | +| Атрибуция | `© OpenStreetMap contributors (ODbL)` | +| Rate-limit | 1 req/sec (per OSM policy) | +| Объём для ЦФО+Чувашии (оценка) | ≈ 50 000–100 000 точек, ≈ 1 000–5 000 треков | +| User-Agent | `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` | + +### 5.2 EnduroRussia.ru (ADR-010 — БЛОКИРОВАН) + +До accepted-status — pipeline пропускает. + +### 5.3 ttrails.ru (ADR-011 — БЛОКИРОВАН) + +До accepted-status — pipeline пропускает. + +## 6. Контракт публичного API + +### 6.1 `GET /api/gps-tracks` + +**Query params:** + +| Параметр | Тип | Обязательность | Default | Валидация | +|---|---|---|---|---| +| `bbox` | 4 float comma-separated | required | — | -180 ≤ lon ≤ 180, -85 ≤ lat ≤ 85, west < east, south < north, площадь ≤ 10 deg² | +| `activity` | comma-string из ACTIVITY_TYPES | optional | all | каждое значение — известный enum | +| `source` | comma-string source IDs | optional | all enabled | значения сверяются с `gps_sources.yaml` | +| `limit` | int | optional | 500 | 1 ≤ limit ≤ 2000 | + +**Response 200 (`Content-Type: application/json`):** + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": 12345, + "geometry": { + "type": "LineString", + "coordinates": [[lon, lat], ...] + }, + "properties": { + "name": "Утренний эндуро", + "activity_type": "enduro", + "user": "Vasya", + "created_at": "2024-05-12", + "length_km": 47.3, + "points_count": 1240, + "sources": ["osm", "enduro_russia"], + "external_urls": ["https://...", "https://..."], + "tags": ["forest", "river"] + } + } + ], + "total_in_bbox": 743, + "returned": 500, + "truncated": true +} +``` + +**Error responses:** + +| Code | Условие | +|---|---| +| 400 | невалидный bbox / activity / source / limit | +| 503 | БД отсутствует или Spatialite не загрузился | + +### 6.2 `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` + +**Path params:** `z` 0..18, `x`/`y` валидны для z. + +**Response:** +- 200 `Content-Type: application/x-protobuf`, тело — `mapbox-vector-tile`-encoded MVT. +- 200 + пустое тело — если в тайле нет треков. +- 304 — стандартная HTTP cache на ETag (опционально, MVP — не реализуется). +- Header `X-Cache: HIT | MISS` — для observability. + +**Layer schema:** + +| Layer | Geometry | Properties | +|---|---|---| +| `gps_tracks` | LineString | `id (int)`, `activity (string)`, `source (string, первый)`, `sources (string, comma-separated)`, `length_km (float)`, `name (string)`, `ext_url (string, первый)` | + +Properties — упрощены под MVT-ограничения (нет массивов). + +### 6.3 `GET /api/gps-tracks/health` + +**Response 200:** + +```json +{ + "db_path": "/app/data/gps_tracks.sqlite", + "db_size_mb": 124.5, + "tracks_total": 8421, + "tracks_by_source": {"osm": 5234, "enduro_russia": 2102, "ttrails": 1085}, + "tracks_by_activity": {"enduro": 2104, "moto": 850, "offroad": 305, "bicycle": 3201, "hike": 1810, "other": 151}, + "last_pipeline_run": { + "started_at": "2026-05-30T03:00:00Z", + "finished_at": "2026-05-30T05:14:00Z", + "regions": ["tsfo_plus_chuvashia"], + "sources_ok": ["osm"], + "sources_error": [{"source": "ttrails", "error": "HTTP 503"}], + "sources_skipped_license": ["enduro_russia"] + }, + "tile_cache_size": 412, + "tile_cache_max": 1024 +} +``` + +**Response 503:** если БД отсутствует или Spatialite не доступен. + +### 6.4 `POST /api/gps-tracks/cache/clear` + +**Auth:** ограничен docker-internal сетью (`07-infra-requirements.md` §3.1). + +**Response 200:** +```json +{"cleared": 412} +``` + +Запрос идемпотентен, вызывается только pipeline'ом в конце прогона. + +## 7. Персональные данные (PII) + +| Канал | PII | Условия | +|---|---|---| +| `tracks.user` (имя автора) | да, **публичное** имя | сохраняется **только** если ADR соответствующего источника явно разрешает (`save_user_field: true` в `gps_sources.yaml`). По ADR-009 OSM — разрешено. ADR-010, ADR-011 — пока запрещено | +| `tracks.geom` (координаты трека) | низкий риск; **публично выложенные** автором | сохраняются всегда | +| `tracks.created_at` | дата проезда | публичная; сохраняется всегда | +| `tracks.description`, `tracks.tags` | возможные следы PII в свободном тексте | сохраняются только при `save_description: true` в конфиге источника | +| Запросы к `api.openstreetmap.org` (исходящие с mva154) | IP **сервера mva154**, не клиента | да, mva154-IP становится известен OSM (стандартное поведение для скрейпера) | +| Запросы к `enduro-russia.ru`, `ttrails.ru` | то же | пока ADR не accepted — не происходит | +| `localStorage['gps-tracks-*']` | UI-настройки | нет PII | + +### 7.1 Право на удаление + +- Запись `external_urls_json` сохраняет ссылку на оригинал — оператор может удалить конкретную запись по запросу автора (`DELETE FROM tracks WHERE external_urls_json LIKE '%%'`). +- Pipeline уважает «удалённое на источнике» при `--gc-stale` (post-MVP). + +### 7.2 GDPR / РФ ФЗ-152 + +- ET-008 обрабатывает **только публично опубликованные** автором данные. +- Имя автора (`user`) — публичное на платформе источника (по ADR-009, ADR-010 для OSM/EnduroRussia это публикуется на странице трека). +- Контактные данные (email, телефон) — **не сохраняются ни при каких условиях**; платформы их не отдают в публичных GPX-эндпоинтах. +- Локация «дом»/«работа» как отдельная точка интереса — не сохраняется (waypoints без public-флага в OSM не отдаются; для скрейпленых источников — `save_waypoints: false`). +- DPO-ответственность minimal — нет сервиса регистрации/учёта пользователей; это публичный read-only слой. + +## 8. Атрибуция + +Обязательное требование BRD §5 «Атрибуция» и AC-15: + +- **На карте**: MapLibre автоматически отображает `attribution` из source-spec в правом нижнем углу. Каждый source (`gps-tracks-tiles`, `gps-tracks-geo`) указывает `attribution: "© OSM contributors (ODbL) | EnduroRussia.ru | ttrails.ru"` — динамически сформированную клиентом из `/api/gps-tracks/health.tracks_by_source` (только активные источники). +- **В popup трека**: ссылки на оригинал по `external_urls_json` (REQ-F-18). +- **В docs/architecture/README.md**: новый раздел «GPS Tracks Pipeline» содержит таблицу источников и их атрибуций. + +## 9. Backup и retention + +| Объект | Backup | Retention | +|---|---|---| +| `data/gps_tracks.sqlite` | Ежедневный `.backup` через cron на mva154 | 14 дней | +| `pipeline_runs` (внутри той же БД) | через backup БД | вечно (растёт медленно, ≤ 10⁴ строк/год) | +| `tracks` старше 5 лет | удаляются при `--gc` | retention configurable в `gps_sources.yaml` | +| `/var/log/enduro-trails/*.log` | через logrotate | 14 дней | +| Pipeline JSON-lines logs | через logrotate | 8 недель | + +## 10. Контракты, которые **нельзя ломать** + +1. `dedup_key` формула (ADR-006 §6) — менять можно только при полном rebuild БД. +2. `ACTIVITY_TYPES` enum — добавление новых значений требует UI-обновления (новый цвет, новая локализация); удаление — миграция существующих треков. +3. GeoJSON response shape (§6.1) — public API, ломающие изменения через v2-endpoint. +4. MVT layer name `gps_tracks` и properties (§6.2) — клиент завязан; ломающие — через новый layer-name. +5. `localStorage` keys (§4) — менять имя ключа требует миграцию (`gps-tracks-enabled-v2`). + +## 11. Вывод + +Серверная модель данных полностью локализована в `data/gps_tracks.sqlite`. Контракты API и MVT-схема финализированы. Клиентское хранилище — 256 байт UI-state. Персональные данные минимизированы по дизайну: только публичные поля от accepted-источников; default-deny для не-accepted. diff --git a/docs/work-items/ET-008/10-tech-risks.md b/docs/work-items/ET-008/10-tech-risks.md new file mode 100644 index 0000000..9df92a5 --- /dev/null +++ b/docs/work-items/ET-008/10-tech-risks.md @@ -0,0 +1,209 @@ +--- +type: tech-risks +work_item_id: ET-008 +title: "Технические риски — ET-008: GPS-треки с публичных платформ" +version: 1 +status: approved +created_at: 2026-06-01 +authors: + - "agent:architect" +--- + +# Технические риски — ET-008 + +Технические риски этапа разработки и эксплуатации. Бизнес-риски — в BRD §6 (пересечение есть, здесь акцент на технические митигации). Шкала: вероятность (Н/С/В) × влияние (Н/С/В). + +## R-1 — Парсер источника ломается при изменении HTML + +- **Описание:** ADR-010/011 источники (`enduro_russia`, `ttrails`) скрейпят HTML-страницы. Платформа может в любой момент изменить разметку (новый шаблон, JS-rendering) → парсер перестаёт извлекать треки. +- **Вероятность / Влияние:** В / С. +- **Митигация:** + - Каждый source в отдельном модуле (`src/api/gps_tracks/sources/.py`); падение одного не валит других (ADR-007 §I-A). + - Pipeline пишет `status=error` в `pipeline_runs`; оператор видит через `/api/gps-tracks/health`. + - Параметризированные тесты с фикстурами HTML-снапшота — при первом упавшем прогоне разработчик обновляет фикстуру и парсер за 1 итерацию. + - При двух неудачных прогонах подряд — алерт (`07-infra-requirements.md` §10.1). На MVP — ручная проверка. + - Конфиг `gps_sources.yaml::enabled: false` — мгновенное отключение источника без deploy. + +## R-2 — Ложные коллизии дедупа + +- **Описание:** ADR-006 алгоритм `bbox+length+date bucket` детерминированно мерджит треки с похожими параметрами. На треках без `created_at` (от источников без даты) — гарантированный merge всех таких треков в одном bbox/length. На дата-датасете — возможны коллизии для популярных маршрутов (двое разных гонщиков проехали тот же 30-км круг в один день). +- **Вероятность / Влияние:** С / С. +- **Митигация:** + - BRD §5 фиксирует допустимую метрику «< 5% дублей»; QA-скрипт `scripts/dedup_audit.py` проверяет на выборке 100 треков (`04-test-plan.yaml`). + - При провале метрики — план отступления ADR-006 §8 (сузить length-bucket, добавить activity в ключ). + - Если меняется формула dedup_key — полный rebuild БД (`rm + python -m scripts.gps_collect`); регенерация ≤ 6 часов. + - Документация в `08-data-requirements.md` §3.2 для оператора. + +## R-3 — Pipeline повреждает БД + +- **Описание:** Бaг в Python-коде upsert (ADR-006 §6) при ON CONFLICT может оставить БД в несогласованном состоянии (битый JSON в `sources_json`, частично записанная transaction). SQLite + WAL обычно atomic per-statement, но composite upsert может рассогласоваться. +- **Вероятность / Влияние:** Н / В. +- **Митигация:** + - Все upsert операции — внутри SQLite `BEGIN IMMEDIATE / COMMIT` (atomic transaction). + - Ежедневный backup `data/gps_tracks.sqlite` (`07-infra-requirements.md` §4.4). + - При повреждении: `cp backups/gps_tracks-.sqlite data/gps_tracks.sqlite` + cache-clear API. RTO ≈ 1–2 минуты. + - Полный rebuild: `rm gps_tracks.sqlite && docker compose --profile batch run --rm gps-collector` — ≤ 6 часов. + - Изоляция в отдельной БД (ADR-005 D-A) гарантирует, что повреждение не затронет `centralfederal.sqlite` (OSM-данные). + +## R-4 — Размер БД превышает 2 ГБ + +- **Описание:** REQ-NF-03 предел `data/gps_tracks.sqlite` — 2 ГБ. На MVP-объёме (5000 треков ≈ 105 МБ) запас 20×. Но при расширении на РФ или при отсутствии работающего GC размер может вырасти линейно. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - Health-эндпоинт отдаёт `db_size_mb` — оператор видит. + - Месячный GC `--gc` удаляет треки старше 5 лет (`07-infra-requirements.md` §7.1). + - При устойчивом росте > 2 ГБ — миграция на PostGIS (отдельный work item; контракт API стабилен, см. ADR-005 §«Технический долг»). + - Алерт `db_size_mb > 2000` — пока ручная проверка (post-MVP — автоматический). + +## R-5 — IP mva154 банится источником + +- **Описание:** Скрейпер с фиксированного IP может попасть в чёрный список платформы (особенно при ошибках rate-limit). Pipeline начинает возвращать 429/403 на все запросы → source не пополняется. +- **Вероятность / Влияние:** С / С. +- **Митигация:** + - Rate-limit в `gps_sources.yaml` per-source (1 сек для OSM, 5 сек для скрейп-источников). + - Корректный User-Agent с контактом — платформа может связаться, прежде чем банить. + - Backoff на 429 (`TRZ §6.3`) — exponential до 3 попыток. + - `pipeline_runs.errors_json` фиксирует HTTP-коды → оператор видит. + - При бане — приостановить source (`enabled: false`), связаться с платформой, при необходимости отключить полностью. + - **Прокси через сторонний IP** — не закладывается (нарушает дух прозрачности). + +## R-6 — Pipeline жрёт ресурсы и деградирует API во время прогона + +- **Описание:** На время прогона `gps-collector` контейнер активен, скачивает GPX, парсит, пишет в БД. Если ресурсы не ограничены — `httpx` + `shapely` могут уйти в GC-storm; SQLite write lock конкурирует с API readers. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - Docker `cpus: "1.0"`, `mem_limit: 512m` (`07-infra-requirements.md` §9.2). Pipeline не вытесняет API даже на одно-CPU-сервере. + - WAL-mode позволяет API читать БД во время записи pipeline'а (ADR-005 W-A). + - Cron в 03:00 UTC = 06:00 MSK — низкий traffic. + - Async-генератор `parser.collect()` — pipeline pulls треки по одному, не накапливает в памяти больше одного (ADR-007 §4). + +## R-7 — Дублирование tile-утилит между `main.py` и `gps_tracks/mvt.py` + +- **Описание:** ADR-005 §8 принимает дублирование `tile_to_bbox` / `wkb_to_coords` / `simplify_coords` (≈ 100 строк) ради избежания риска регрессии существующего слоя `trails`. Любая правка формулы упрощения требует синхронной правки в двух местах. +- **Вероятность / Влияние:** С / Н. +- **Митигация:** + - Комментарий в обоих файлах `# ET-008/ADR-005-§8: дубль из main.py; при добавлении третьего MVT-источника — вынести в src/api/tiles_util.py`. + - Code review-чеклист: при правке `simplify_coords` в одном файле — проверить второй. + - При появлении третьего MVT-источника — обязательный рефакторинг (отдельный work item). + +## R-8 — GeoJSON-эндпоинт превышает SLA на плотных bbox + +- **Описание:** REQ-NF-02 предел 300 мс p95 на bbox с ≤ 500 треков. На реальной географии возможны bbox в плотных регионах (например, Подмосковье на z=12) где `total_in_bbox > 2000`. SQL даже с R-tree может проигрывать при ORDER BY + post-filter source. +- **Вероятность / Влияние:** С / Н. +- **Митигация:** + - Cutoff `limit=500` обрезает результат на уровне SQL. + - Cutoff zoom 12 — на z=11 уходим в MVT-кэш, нагрузки на GeoJSON-endpoint нет. + - R-tree обеспечивает O(log n) bbox-prefetch. + - Дополнительный индекс по `length_m DESC` для ORDER BY (длинные треки приоритетнее) — фиксируется в коде; SQLite сделает sort быстро на 500 строках. + - Если SLA не выполняется — server-side кэширование GeoJSON-ответов по `(bbox_quantized, activity, source)` (post-MVP). + +## R-9 — Лицензионный ADR не enforced + +- **Описание:** ADR-007 §6 требует, чтобы pipeline отказывался загружать source-parser без `accepted`-ADR. Если разработчик обходит проверку (например, забывает добавить `license_adr:` поле в `gps_sources.yaml`) — pipeline пойдёт скрейпить без юридического подтверждения. BRD §4 явно требует «зелёного света». +- **Вероятность / Влияние:** Н / В. +- **Митигация:** + - Pydantic-валидация `gps_sources.yaml` — поле `license_adr` обязательное, отсутствие → exception при старте pipeline. + - Дополнительная проверка в runtime: `license_adr` должен указывать на существующий файл; YAML frontmatter `status: accepted`. Иначе source skip с `status: skipped_license`. + - Code review-чеклист в `12-review.md`: при добавлении source в `gps_sources.yaml` обязательна ссылка на accepted-ADR. + - QA-кейс: `tests/api/test_gps_tracks_licensing_guard.py` — поднимает pipeline с `proposed`-ADR, проверяет что source пропускается. + +## R-10 — Cache-clear endpoint доступен извне + +- **Описание:** `POST /api/gps-tracks/cache/clear` сбрасывает LRU. Если эндпоинт доступен через `/enduro/` — атакующий может вызывать его в цикле, обнуляя кэш и заставляя сервер постоянно перегенерировать тайлы (DoS). +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - `07-infra-requirements.md` §3.1: nginx-правило `location = /enduro/api/gps-tracks/cache/clear { allow 172.16/12; deny all; }`. + - Pipeline ↔ API дёргает endpoint напрямую через docker-сеть, минуя nginx → работает. + - При появлении CSP-заголовка — `connect-src 'self'` блокирует внешние POST'ы из браузера (но это уже есть). + +## R-11 — Pipeline зависает (вечная проблема скрейперов) + +- **Описание:** Парсер одного источника попадает в бесконечный pagination loop или висит на медленном HTTP. Cron-job не завершается, следующий cron-тик попадает на ту же задачу. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - `httpx.AsyncClient(timeout=30)` — таймаут на каждый запрос. + - Per-source максимум треков на прогон (`max_tracks_per_run` в `gps_sources.yaml`, default 5000) — стопорит pagination loop. + - Cron-окно (3 дня между прогонами) > потенциального hang-окна; overlapping runs — два docker container'а, ресурсы изолированы; следующий cron не блокируется первым. + - Опционально: `timeout 21600 docker compose ...` в cron — kill после 6 часов (REQ-NF-02). На MVP — не обязательно, но рекомендовано. + +## R-12 — Несогласованность UI/style при `setStyle()` + +- **Описание:** При переключении тёмной темы / спутника `map.setStyle()` сбрасывает все runtime-добавленные source/layer. `rebuildMapOverlays()` пересоздаёт; если порядок вызовов нарушен — слой публичных треков может оказаться поверх маршрута или ниже спутника. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - `restorePublicTracksState()` вызывается в `rebuildMapOverlays()` после `restoreTrailsState()`, до `restorePoiState()` и маршрута/GPX (TRZ REQ-F-19). + - AC-12 «Переживание setStyle()» проверяет: чекбокс работает после смены темы. + - Идемпотентные `if (!map.getSource(id)) map.addSource(...)` — паттерн из ADR-004 R-6. + +## R-13 — Конфликт с ET-006 (личные GPX) + +- **Описание:** ET-006 хранит личные GPX треки в `window.gpxTracks` и отображает как `gpx-layer-*`. Если ET-008 случайно использует тот же layer-id или event-handler — взаимная коллизия. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - Префикс `gps-tracks-*` для всех новых id (source, layer, halo) — конфликт исключён. + - `window.gpsTracksLayer` ≠ `window.gpxTracks` (TRZ §4.4). + - Z-order: `gps-tracks-layer-*` < `gpx-layer-*` (личные приоритетнее, как уточняется в TRZ §7.1). + - AC-10 «Совместимость с ET-006» проверяет совместное отображение. + +## R-14 — Конфликт с ET-007 (спутник + halo) + +- **Описание:** ET-007 уже реализовал паттерн halo для trails на спутнике через `applyTrailHaloVisibility()` (ADR-004 §9). ET-008 добавляет два новых halo (`gps-tracks-halo-mvt-satellite`, `gps-tracks-halo-geo-satellite`) и расширяет `applyBaseLayer()`. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - Новые halo-слои добавляются в оба `style.json` / `style-dark.json` с `visibility: none` — по тому же паттерну ET-007. + - `applyBaseLayer()` (ET-007) расширяется одним блоком (см. TRZ §7.2): + ```js + const gpsHaloOn = (currentBase === 'satellite' && layerState.publicTracks); + setLayoutProperty('gps-tracks-halo-mvt-satellite', 'visibility', gpsHaloOn && activeMode === 'mvt' ? 'visible' : 'none'); + setLayoutProperty('gps-tracks-halo-geo-satellite', 'visibility', gpsHaloOn && activeMode === 'geo' ? 'visible' : 'none'); + ``` + - AC-11 «Halo на спутнике» проверяет. + +## R-15 — Pipeline не находит зависимости (defusedxml, pyyaml) + +- **Описание:** При смене образа без полного rebuild — `gps-collector` стартует с старым `requirements.txt` → ImportError. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - Deploy-runbook (§7) явно требует `docker compose build` перед запуском нового pipeline. + - CI-job собирает образ при каждом push → новые зависимости видны на CI, а не в production. + +## R-16 — Атрибуция теряется при включении/выключении источников + +- **Описание:** BRD-метрика «атрибуция каждого активного источника видна». При динамическом изменении набора enabled-источников (например, оператор временно выключил `ttrails` в `gps_sources.yaml`) клиент может продолжать показывать атрибуцию, потому что в БД уже есть треки с `sources_json` содержащим `ttrails`. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - Атрибуция формируется на клиенте из `/api/gps-tracks/health.tracks_by_source` (только source с tracks_count > 0). Если в БД остались `ttrails` записи — атрибуция корректно отображает. + - Если source удалён + треки удалены — `tracks_by_source` его не содержит → атрибуция корректно скрывается. + - AC-15 проверяет. + +## Сводная таблица + +| ID | Риск | Вер. | Влияние | Класс | Статус | +|---|---|---|---|---|---| +| R-1 | Парсер ломается при смене HTML | В | С | Высокий | принят + per-source изоляция + алерт | +| R-2 | Ложные коллизии dedup | С | С | Средний | принят + метрика BRD + план отступления | +| R-3 | Pipeline повреждает БД | Н | В | Средний | atomic tx + ежедневный backup + rebuild за 6 ч | +| R-4 | Размер БД > 2 ГБ | Н | С | Низкий | GC + health + миграция на PostGIS | +| R-5 | IP mva154 банится | С | С | Средний | rate-limit + UA + backoff + отключение источника | +| R-6 | Pipeline деградирует API | Н | С | Низкий | cgroup limits + WAL + ночное окно | +| R-7 | Дублирование tile-утилит | С | Н | Низкий | принят + комментарии в коде + review-чеклист | +| R-8 | GeoJSON SLA на плотных bbox | С | Н | Низкий | limit + zoom-cutoff + R-tree | +| R-9 | Licensing-ADR не enforced | Н | В | Высокий | runtime-guard + Pydantic-валидация + тест | +| R-10 | Cache-clear доступен извне | Н | С | Низкий | nginx allow/deny | +| R-11 | Pipeline зависает | Н | С | Низкий | httpx timeout + max_tracks_per_run + (опц.) timeout cron | +| R-12 | UI несогласован после setStyle | Н | С | Низкий | паттерн ADR-004 + AC-12 | +| R-13 | Конфликт с ET-006 (GPX) | Н | С | Низкий | префикс + параллельные модели + AC-10 | +| R-14 | Конфликт с ET-007 (halo) | Н | С | Низкий | новые halo по тому же паттерну + AC-11 | +| R-15 | Зависимости pipeline | Н | Н | Низкий | CI-build + runbook | +| R-16 | Атрибуция теряется | Н | Н | Низкий | health-derived rendering | + +**Высокие классы:** +- R-1 — операционный, ожидаемый для скрейп-источников; митигация — per-source изоляция и быстрое отключение через конфиг. +- R-9 — критический для legal compliance; митигация многослойная (Pydantic + runtime check + тест). + +**Блокирующих рисков нет.** R-1 и R-9 требуют внимания разработки и code review, но не блокируют merge. + +## Эскалация + +- **arch:major-change** — выставлен на ADR-005 (новая БД) и ADR-007 (новый сервис + cron). Требует архитектурного approve перед merge. +- **back-to:analysis** — не требуется. ТЗ полное, BRD ясный, ADR-010 и ADR-011 явно блокирующие до закрытия licensing review (это операционный pre-requisite, не дефект анализа).