---
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 <pid>` штатно убивает контейнер; следующий 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-<run_id>.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-<source>-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/<name>.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
