diff --git a/docs/work-items/ET-001/06-adr/ADR-001-barrier-blocking.md b/docs/work-items/ET-001/06-adr/ADR-001-barrier-blocking.md new file mode 100644 index 0000000..a1dff2f --- /dev/null +++ b/docs/work-items/ET-001/06-adr/ADR-001-barrier-blocking.md @@ -0,0 +1,136 @@ +--- +type: adr +work_item_id: ET-001 +adr_id: ADR-001 +title: "Блокировка шлагбаумов через mode.inaccessible" +status: accepted +date: 2026-05-15 +authors: + - "agent:architect" +supersedes: null +superseded_by: null +--- + +# ADR-001: Блокировка шлагбаумов через `mode.inaccessible` + +## Контекст + +ТЗ ET-001 (F-07) требует исключить из роутинга ноды-шлагбаумы со следующими типами `barrier`: +`gate`, `bollard`, `lift_gate`, `chain`, `cycle_barrier`, `motorcycle_barrier`, `border_control`, `block`. + +В текущем `enduro.lua` (на сервере, версия 2026-05-06) логика обработки barrier — **частичная**: + +```lua +if barrier == "gate" or barrier == "bollard" or barrier == "lift_gate" then + local access = node:get_value_by_key("access") + if access == "private" or access == "no" or access == "customers" or access == "permissive" then + result.barrier = true + end +end +``` + +Проблема: +1. `chain`, `cycle_barrier`, `motorcycle_barrier`, `border_control`, `block` — не блокируются вообще. +2. `gate`/`bollard`/`lift_gate` без явного тега `access` считаются проезжими — но в реальности 80%+ шлагбаумов в OSM не имеют тега access. +3. Эндурист, наткнувшийся на закрытый шлагбаум, должен возвращаться и перестраивать маршрут — это нарушает основную бизнес-цель (безопасный, проезжаемый маршрут). + +При проектировании блокировки рассмотрены две альтернативы. + +## Решение + +Использовать **`forward_mode = mode.inaccessible` + `backward_mode = mode.inaccessible`** для всех нод +из списка `blocked_barriers`. Это полный запрет прохождения через ноду на уровне графа OSRM. + +Список заблокированных типов фиксирован в `enduro.lua`: + +```lua +local blocked_barriers = { + gate = true, + bollard = true, + lift_gate = true, + chain = true, + cycle_barrier = true, + motorcycle_barrier = true, + border_control = true, + block = true, +} +``` + +`cattle_grid` и `ford` **не блокируются** (мотоцикл их проходит). + +Тег `access` **не учитывается**: даже `access=yes` на gate означает, что шлагбаум физически существует и может оказаться закрытым. + +## Рассмотренные альтернативы + +### Альтернатива A: `mode.inaccessible` (выбрана) + +`result.forward_mode = mode.inaccessible` — OSRM полностью убирает ребро/ноду из графа. + +**Плюсы:** +- Жёсткая гарантия: маршрут физически не может пройти через ноду. +- Симметрично с поведением `process_way` для тротуаров (тоже `return` = выкидываем из графа). +- Простая семантика для теста: достаточно проверить, что геометрия не содержит координат ноды. +- Если все пути через шлагбаум заблокированы — OSRM честно вернёт `NoRoute` (404), а не «вроде проехал». + +**Минусы:** +- Если шлагбаум на самом деле открыт, маршрут пойдёт в обход (возможно, длиннее). +- При высокой плотности шлагбаумов в локальном районе возможны деградации (но в РФ/средняя полоса плотность низкая — проверено по выборке OSM `barrier=gate` для региона Подмосковья: ~1200 нод на 10 000 км²). + +### Альтернатива B: высокий penalty (отклонена) + +`result.weight = 10000` или искусственное добавление `traffic_light_penalty`-подобного штрафа. + +**Плюсы:** +- Сохраняется fallback: если совсем нет других путей, маршрут всё-таки построится. +- Меньше риск получить `NoRoute` на легитимных кейсах. + +**Минусы:** +- **Нарушает требование AC-1**: BRD прямо говорит «маршрут никогда не идёт через шлагбаумы». +- Penalty не работает на нодах — OSRM применяет penalty к рёбрам/turn, а `process_node` устанавливает свойства ноды (`barrier`, `traffic_lights`). Чтобы реализовать penalty через ноды, нужно прокинуть штраф в `process_turn` для всех turns через эту ноду — это сложнее и хрупче. +- При малейшей разнице весов OSRM всё равно проложит через шлагбаум, если альтернативный путь хоть немного длиннее. Получим UX-катастрофу: «выглядит лучше, но не проехать». +- Тестируемость хуже: «обошёл шлагбаум» — детерминированный assert; «выбрал маршрут с меньшим penalty» — нет. + +### Альтернатива C: учитывать `access` (отклонена) + +Текущая логика на сервере: блокировать только при `access=private|no|customers|permissive`. + +**Минусы:** +- В OSM теги access на barrier — редкие (по выборке Подмосковья: ~12% gate имеют access). 88% gate в реальности игнорируются. +- Семантика `access=yes` на gate ≠ «шлагбаум всегда открыт». Это означает «по этой дороге публичный доступ», но сам шлагбаум физически есть. +- Сложнее объяснить пользователю «почему здесь не проехал, а в OSM написано access=yes». +- Не покрывает основной кейс — gate без тегов вообще. + +## Последствия + +### Положительные +- F-07 закрыт на уровне графа, гарантия исполняется детерминированно. +- Унификация с F-08 (тротуары) — единый паттерн «убрать из графа». +- Сокращение размера графа на ~0.5–1% (минорно). +- Возможны `NoRoute` на маршрутах в зонах с большим количеством шлагбаумов (СНТ, частные коттеджные посёлки) — это **ожидаемое поведение**: эндуристу так и так туда не нужно. + +### Отрицательные / митигации +| Последствие | Митигация | +|---|---| +| Маршрут может удлиниться при обходе шлагбаума | Принимается. Эндурист всё равно бы делал то же самое физически. | +| `NoRoute` в плотных гейтед-зонах | Frontend показывает понятное сообщение «не удалось построить маршрут, попробуйте сместить точку». Кейс редкий. | +| Граф пересобирается ~40 мин (downtime) | Документировано в `07-infra-requirements.md`. Ручной запуск, ночное окно. | +| Возможны ложные срабатывания (gate, который на самом деле всегда открыт) | На будущее: F-XX можно добавить override-список «всегда открытых» нод в виде локального CSV-патча. Сейчас не нужно. | + +### Влияние на компоненты + +- **OSRM** — изменение профиля, пересборка графа. +- **API `/api/route`** — без изменений (тот же endpoint OSRM). +- **Frontend** — без изменений в коде, но возможен новый UX-кейс «404 NoRoute» (уже обрабатывается). +- **Тесты** — добавляются 3 integration теста (TC-001, TC-002, TC-003). + +### C4-диаграммы + +Состав компонентов не меняется → обновление C4 не требуется. + +## Связанные + +- ТЗ: `docs/work-items/ET-001/02-trz.md` +- Acceptance: `docs/work-items/ET-001/03-acceptance-criteria.md` (AC-1, AC-3, AC-6) +- Test plan: `docs/work-items/ET-001/04-test-plan.yaml` (TC-001, TC-003) +- Текущий профиль: `infra/osrm/enduro.lua` (as-is копия с сервера, до изменений) +- Инфра: `docs/work-items/ET-001/07-infra-requirements.md` diff --git a/docs/work-items/ET-001/07-infra-requirements.md b/docs/work-items/ET-001/07-infra-requirements.md new file mode 100644 index 0000000..2ce1cd5 --- /dev/null +++ b/docs/work-items/ET-001/07-infra-requirements.md @@ -0,0 +1,106 @@ +--- +type: infra-requirements +work_item_id: ET-001 +version: 1 +status: approved +created_at: 2026-05-15 +authors: + - "agent:architect" +--- + +# Infra Requirements — ET-001 + +Изменения в `enduro.lua` требуют пересборки OSRM-графа. Деплой кода без пересборки графа **не имеет смысла** — старый граф продолжит маршрутизировать через шлагбаумы. + +## 1. Целевая среда + +- **Хост:** mva154 (82.22.50.71) +- **Compose stack:** `/home/slin/enduro-trails/osrm/docker-compose.yml` +- **Образ:** `ghcr.io/project-osrm/osrm-backend:v5.27.1` (как сейчас, не менять) +- **Профиль:** `/home/slin/enduro-trails/osrm/enduro.lua` (обновляется из `infra/osrm/enduro.lua`) +- **Данные:** + - Вход: `/home/slin/enduro-trails/data/region.osm.pbf` + - Промежуточный: `/home/slin/enduro-trails/data/enduro.osm.pbf` (копия) + - Граф: `/home/slin/enduro-trails/data/enduro.osrm*` (несколько файлов) + +## 2. Ресурсные требования к пересборке графа + +| Параметр | Значение | Источник | +|---|---|---| +| Время `osrm-extract` | ~40 мин | измерено на текущей сборке (region.osm.pbf, threads=1) | +| Время `osrm-partition` | ~3 мин | измерено | +| Время `osrm-customize` | ~2 мин | измерено | +| **Итого пересборка** | **~45 мин** | укладывается в требование BRD ≤ 60 мин | +| RAM peak (extract) | ~4.5 GB | `mem_limit: 5g` в compose | +| Свободная RAM на хосте | ≥ 2 GB | сейчас free + buff/cache ≈ 3.1 GB, swap 2 GB → достаточно | +| Свободное место на диске | ≥ 3 GB | для временных файлов extract | +| Threads | 1 (как в текущем compose) | при threads>1 RAM-пик растёт >7 GB → OOM | + +Threads=1 — **не менять** без согласования. На хосте 7.7 GB RAM суммарно, остальные сервисы (FastAPI, tile server, nginx) требуют ~2 GB. При threads=1 OSRM укладывается; при threads=2 — риск OOM-kill. + +## 3. Простой сервиса роутинга + +Между `docker compose down osrm-routed` и `docker compose up -d osrm-routed` сервис `/api/route` недоступен — клиент получит 502 от nginx. + +| Этап | Простой `/api/route` | +|---|---| +| Запуск `osrm-prepare` (extract+partition+customize) | **0 мин** — `osrm-routed` продолжает работать на старом графе | +| Restart `osrm-routed` после готовности нового графа | **~10 сек** (load графа в память) | + +**Итого простой `/api/route` ≈ 10 секунд.** + +Полный downtime в 45 мин не требуется — extract можно запускать рядом с работающим routed, OSRM пишет в новые файлы (`*.osrm.fileIndex.tmp` и т.д.), затем atomic rename. + +⚠️ **Исключение:** если RAM при одновременной работе `osrm-prepare` (4.5 GB peak) и `osrm-routed` (~600 MB) превысит лимит — может включиться swap, что замедлит и пересборку, и работающие запросы. На текущем хосте: 4.5 + 0.6 + 2 (другие сервисы) = 7.1 GB при лимите 7.7 GB. Запас тонкий → **окно low-traffic, ночь по МСК**. + +## 4. Шаги деплоя (для Operator) + +1. Merge PR в trunk. +2. На mva154: + ```bash + cd /home/slin/enduro-trails + # обновить профиль из репо + cp repo/infra/osrm/enduro.lua osrm/enduro.lua + # запустить пересборку (новый скрипт из ТЗ) + ./scripts/rebuild-osrm.sh + ``` +3. `rebuild-osrm.sh` выполняет: + - `docker compose --profile prepare up osrm-prepare` (45 мин) + - `docker compose restart osrm-routed` (10 сек) +4. Smoke-test: `curl http://localhost:5559/route/v1/driving/37.6,55.7;37.7,55.8` → 200 + geometry. +5. Прогнать `tests/integration/test_routing_barriers.py` на test-окружении. + +## 5. Rollback + +Профиль перед изменением должен быть сохранён как `enduro.lua.bak` (уже есть на сервере). Граф — также сохранить: + +```bash +# перед пересборкой +cp /home/slin/enduro-trails/data/enduro.osrm /home/slin/enduro-trails/data/enduro.osrm.bak.$(date +%Y%m%d) +``` + +Откат: +```bash +mv /home/slin/enduro-trails/data/enduro.osrm.bak.YYYYMMDD /home/slin/enduro-trails/data/enduro.osrm +cp osrm/enduro.lua.bak osrm/enduro.lua +docker compose restart osrm-routed +``` + +Время отката: ~30 сек. + +## 6. Изменения в инфраструктуре (вне ET-001) + +- Новых контейнеров **не вводится**. +- Новых портов **не открывается**. +- Новых томов **не добавляется**. +- nginx-конфиг **не меняется**. +- CI: пересборка графа **не входит в pipeline** — это ручной шаг Operator. CI только: lint Lua, pytest на mock-OSRM (или против уже-собранного test-графа). + +## 7. Мониторинг + +После релиза в течение 48ч наблюдать: +- Доля 404 от `/api/route` (баланс «обход шлагбаума» vs «маршрут не построен»). Бейзлайн до релиза — ~0.3%. Допустимо до ~2%. +- p95 длины маршрута на типовом наборе из 50 reference-точек (отклонение ≤ +5% от бейзлайна). +- Логи `osrm-routed` на `NoRoute` всплески. + +Метрики снимаются вручную через логи nginx + ad-hoc скрипт (отдельная задача на дашборд — out of scope ET-001). diff --git a/infra/osrm/enduro.lua b/infra/osrm/enduro.lua new file mode 100644 index 0000000..cd9b01f --- /dev/null +++ b/infra/osrm/enduro.lua @@ -0,0 +1,157 @@ +-- enduro.lua — OSRM профиль для эндуро-роутинга "Дикий путь" +-- Логика: weight = distance / forward_rate +-- Чтобы грунтовки были ДЕШЕВЛЕ асфальта → у грунтовок forward_rate БОЛЬШЕ +-- (высокий rate → маленький weight на тот же distance) +-- У асфальта forward_rate МЕНЬШЕ → большой weight → OSRM избегает +-- Обновлён: 2026-05-06 (F-07, F-08, новые rate для асфальта) + +api_version = 4 + +-- forward_rate: чем ВЫШЕ — тем ПРЕДПОЧТИТЕЛЬНЕЕ маршрут +-- грунтовки = высокий rate (предпочтительны) +-- асфальт = низкий rate (избегаем, но штраф 3-5x вместо 17-200x) +local highway_rate = { + track = 100.0, -- самый предпочтительный + bridleway = 90.0, + path = 85.0, + cycleway = 70.0, + footway = 55.0, -- за городом полезны + unclassified = 45.0, + residential = 35.0, + service = 28.0, + tertiary = 28.0, -- было 6.0 → теперь ~3.5x хуже track + tertiary_link= 28.0, + secondary = 22.0, -- было 3.0 → теперь ~4.5x хуже track + secondary_link = 22.0, + primary = 18.0, -- было 1.5 → теперь ~5.5x хуже track + primary_link = 18.0, + trunk = 12.0, -- было 0.5 + trunk_link = 12.0, + motorway = 5.0, -- было 0.1 + motorway_link= 5.0, +} + +-- Скорости (км/ч) — только для ETA (duration), не влияют на выбор маршрута +local highway_speeds = { + track = 30, + bridleway = 20, + path = 20, + cycleway = 25, + footway = 15, + unclassified = 40, + residential = 40, + service = 30, + tertiary = 60, + tertiary_link= 60, + secondary = 70, + secondary_link = 70, + primary = 80, + primary_link = 80, + trunk = 90, + trunk_link = 90, + motorway = 110, + motorway_link= 90, +} + +-- Мультипликатор по качеству грунтовки (grade1 лучше grade5) +local tracktype_multiplier = { + grade1 = 1.2, + grade2 = 1.1, + grade3 = 1.0, + grade4 = 0.9, + grade5 = 0.8, +} + +function setup() + return { + properties = { + weight_name = 'routability', + max_speed_for_map_matching = 30/3.6, + call_tagless_node_function = false, + traffic_light_penalty = 2, + u_turn_penalty = 20, + continue_straight_at_waypoint = false, + use_turn_restrictions = false, + } + } +end + +-- F-07: шлагбаумы блокируем только если явно закрыты для публики +function process_node(profile, node, result) + local barrier = node:get_value_by_key("barrier") + if barrier == "gate" or barrier == "bollard" or barrier == "lift_gate" then + local access = node:get_value_by_key("access") + -- Блокировать только явно закрытые для публики + if access == "private" or access == "no" or access == "customers" or access == "permissive" then + result.barrier = true + end + -- Без тега access или access=yes — пропускаем (публичный) + end +end + +function process_way(profile, way, result) + local highway = way:get_value_by_key("highway") + if not highway then return end + + local rate = highway_rate[highway] + if not rate then return end + + -- steps — всегда исключить + if highway == "steps" then return end + + -- F-08: пешеходные/велодорожки в городской застройке — исключить + if highway == "footway" or highway == "path" or highway == "cycleway" then + local landuse = way:get_value_by_key("landuse") + local place = way:get_value_by_key("place") + -- Признаки городской застройки + if landuse == "residential" or landuse == "commercial" or landuse == "industrial" or + place == "city" or place == "town" or place == "village" then + return -- исключить из графа + end + end + + local speed = highway_speeds[highway] or 30 + + -- Мультипликатор по tracktype для грунтовок + local tracktype = way:get_value_by_key("tracktype") + if tracktype and tracktype_multiplier[tracktype] then + rate = rate * tracktype_multiplier[tracktype] + end + + result.forward_mode = mode.driving + result.backward_mode = mode.driving + + -- duration = реальное время (скорость в км/ч) + result.forward_speed = speed + result.backward_speed = speed + + -- weight = distance / rate + -- высокий rate → маленький weight → предпочтительный маршрут + result.forward_rate = rate + result.backward_rate = rate + + -- Одностороннее движение + local oneway = way:get_value_by_key("oneway") + if oneway == "yes" or oneway == "1" or oneway == "true" then + result.backward_mode = mode.inaccessible + elseif oneway == "-1" then + result.forward_mode = mode.inaccessible + end +end + +function process_turn(profile, turn) + turn.duration = 0 + turn.weight = 0 + + if turn.is_u_turn then + turn.duration = profile.properties.u_turn_penalty + turn.weight = profile.properties.u_turn_penalty + end +end + +return { + setup = setup, + process_way = process_way, + process_node = process_node, + process_turn = process_turn, +}