Skip to content

Премортем: ADR-016 geo suggest engine

Контекст

  • Предмет: ADR-016 — архитектурное решение для /store/geo/suggest. Meilisearch v1.10 primary + DaData fallback decorator. PHP Slim 4 backend. MVP single-node. Pipeline: SQL suggest_normalize → expand geo-synonyms.yaml → Meilisearch search → fallback при 0 hits. Index sync 2×/неделю.
  • Аудитория: backend dev (DEV-386 имплементация), DevOps (Meilisearch в prod), архитектор (review/Accept), конечные пользователи маркетплейса автозапчастей СПб (продавцы в форме регистрации, покупатели в каталог-фильтре)
  • Успех: 50/50 gold-set hit-rate top-3 + P95 ≤ 200ms @ 100 RPS (DEV-428 SLO)
  • Reference class: ADR-009 — Meilisearch для product search (тот же проект, Phase 2, не реализован), DEV-430 spike — benchmark pg_trgm vs Meilisearch на 4.45M ГАР записей

Дыры

H-001: DaData fallback нелегитимен — центральный механизм цели мертворождён

  • Угол зрения: Клиент, Исполнитель, Допущения
  • Найдена: 2026-06-03 (запуск 1)
  • Важность: высокая
  • Уверенность: подкреплено контекстом
  • Статус: ПРИНЯТО
  • Режим: полный
  • Описание: ADR-016 закладывает DaDataSuggestFallback decorator как механизм закрытия gap 38/50 → 50/50 (heavy typos, mix-script, metro, district queries). Проектная политика — free/OSS only, DaData нелегитимен. Без fallback hit rate остаётся 38/50 by design, UX страдает в форме регистрации и каталог-фильтре, деплой блокируется на ревью политики. Помощники H1-Клиент / H2-Исполнитель / H3-Допущения нашли одну дыру с трёх углов — root cause общая.
  • Решение: Г: закрыть gap через city rows (DEV-379) + metro_stations (DEV-427) + mix-script normalization (DEV-386) — внутри Meilisearch без fallback. 2 heavy-typo queries вынести в явно документированное 'known limitation' с acceptance.
    • Зачем выбрали: Сохраняем политику (без DaData, без Nominatim как нового сервиса), достигаем 48/50 — почти DoD, оставшиеся 2 = осознанный compromise. Закрывает критический root issue не вводя новые риски.
    • Первые шаги (prevent):
      1. Переписать ADR-016 §«Эскалация» и §«Действия»: убрать DaDataSuggestFallback decorator. Заменить раздел: gap closure через scope expansion в DEV-379/386/427
      2. Пересмотреть DoD DEV-430: 48/50 как MVP gate с явным списком 2 known limitations (heavy typos в коротких словах ≥2 edit distance в 6-char)
      3. Обновить cost model: убрать DaData строку (1-2K ₽/мес), убрать дозу alert на dadata_fallback_count метрику
    • Исполнитель: оба
    • Сигнал успеха (detect): На k6 прогоне DEV-428 + повторе gold-set после DEV-379+386+427: hit rate ≥ 48/50 без обращений к external services
    • Стоп-условие: Если после DEV-379+386+427 hit rate < 45/50 → пересмотреть либо DoD (down к 45/50 для MVP), либо ввести OSS-альтернативу (Nominatim self-hosted) как отдельный ADR
    • Если уже случилось (limit damage): Если случилось в прод — добавить в FAQ маркетплейса 'почему не находит мой адрес' с инструкцией для пользователя (city + улица как 2 поля вместо одного). Параллельно расширить geo-synonyms.yaml weekly review топ-100 пустых запросов
    • Открытые вопросы:
      • Принимает ли продакт DoD 48/50 или требует 50/50 absolute
      • Что если архитектор настаивает на DaData (политика непонятна или ослаблена)
  • История статуса:
    • 2026-06-03: ПРИНЯТО (запуск 1) — Принято решение Г: закрыть gap через scope expansion без external fallback

H-002: Конкурентный gap — metro/districts/stale sync vs Avito/Cian/Yandex

  • Угол зрения: Конкурент
  • Найдена: 2026-06-03 (запуск 1)
  • Важность: высокая
  • Уверенность: требует проверки
  • Статус: ПРИНЯТО
  • Режим: полный
  • Описание: ADR-016 на старте не включает metro_stations (DEV-427) и district rows (DEV-379), sync 2×/неделю даёт stale до 3.5 суток. Avito/Cian/Yandex.Маркет закрывают метро, районы и новые ЖК 'из коробки'. Для маркетплейса автозапчастей в СПб метро критично как ориентир — без него suggest выглядит обрезанно vs прямые конкуренты.
  • Решение: Г: daily sync (вместо 2×/неделю) + включить metro_stations и districts в MVP scope перед prod-лончем
    • Зачем выбрали: Без метро/районов СПб-маркетплейс выглядит обрезанно vs конкурентов на ключевом UX-элементе. Daily sync дёшев — Meilisearch indexing 50 секунд на 4.45M проверено на спайке. Совместимо с H-001 решением (там же используем DEV-379/427)
    • Первые шаги (prevent):
      1. DEV-379 ETL: добавить city-level + district-level rows в addresses (уже было в roadmap, поднять приоритет в blocking для launch)
      2. DEV-427 metro_stations: индексировать в Meilisearch отдельным index 'metro' или совместить в 'addresses' с level='metro' (решить в DEV-386)
      3. Cron daily sync вместо 2×/неделю — корректировка sync hook strategy в DEV-386
    • Исполнитель: оба
    • Сигнал успеха (detect): Сравнительный gold-set v1.2 с топ-50 СПб запросов содержащих метро/районы — hit rate ≥ 80%
    • Стоп-условие: Если ГАР API rate-limit или Meilisearch indexing > 5 минут на полном корпусе — откатить на 2×/неделю + on-demand для критичных update events
    • Если уже случилось (limit damage): Если в проде заметили потерю запросов — hot patch: добавить топ-50 СПб улиц/метро вручную в geo-synonyms.yaml как stop-gap до full DEV-379
    • Открытые вопросы:
      • metro_stations индексировать в один Meilisearch index или отдельный — решение в DEV-386
      • Срок: можно ли DEV-379+427 параллелить с DEV-386 без блокировки launch
  • История статуса:
    • 2026-06-03: ПРИНЯТО (запуск 1) — Принято Г: daily sync + metro+districts в MVP перед prod

H-003: Synonyms YAML без versioning + observability — дебаг suggest невозможен через 6 мес

  • Угол зрения: Будущий поддерживающий
  • Найдена: 2026-06-03 (запуск 1)
  • Важность: высокая
  • Уверенность: требует проверки
  • Статус: ПРИНЯТО
  • Режим: краткий
  • Описание: Pipeline suggest_normalize → expand YAML → Meilisearch имеет 57 пар × ожидаемый ×3-×5 рост на 89 регионах. В ADR-016 нет: версионирования словаря, логирования какая пара сработала, связки query→expanded_terms→matched_doc_id в traces. Через 6-12 мес maintainer на тикет 'почему по X вернулся Y' не имеет данных для дебага. WYSIATI: нет голоса support-инженера в комнате.
  • Решение: А: минимальный структурный лог {query, normalized, expanded_terms[], hit_id, score, synonyms_version} на каждый suggest. + version hash YAML включить в начало лога pipeline + sample 10% запросов в полный trace.
    • Почему: Минимум достаточный для дебага без overkill. Версия YAML обязательна — позволяет понять какой словарь был активен. Sampling 10% — баланс между volume и observability.
    • Первый шаг: Добавить в DEV-386 PHP middleware: structured log на каждый /store/geo/suggest call + version hash YAML вычислять при load (lru cache, не каждый call)
    • Исполнитель: агент
  • История статуса:
    • 2026-06-03: ПРИНЯТО (запуск 1) — Принято А: минимум структурный лог

H-004: Публичный endpoint без rate limiting — scraping геобазы + DoS на single-node

  • Угол зрения: Противник
  • Найдена: 2026-06-03 (запуск 1)
  • Важность: высокая
  • Уверенность: подкреплено контекстом
  • Статус: ПРИНЯТО
  • Режим: полный
  • Описание: /store/geo/suggest публичный (GET, без CSRF/session), возвращает address_id для resolve, rate limiting не упомянут в ADR-016. Перебором префиксов вытягивается полный геосправочник + mapping query→address_id за часы (4.45M MVP → 12M Year 1). Параллельно single-node Meilisearch (SPOF) не тестировался под concurrent load — спайк прогонял single-threaded 50 queries.
  • Решение: Г: rate limit per IP (~30 req/min sliding window через Nginx или middleware) + добавить к DEV-428 k6 нагрузочный тест на 100 RPS concurrent на single-node Meilisearch.
    • Зачем выбрали: Минимум для prod — A решает scraping/DoS базово. K6 проверяет что P95 ≤ 200ms holds под concurrent load (не только single-threaded gold-set). Без проверки Plan B/В preferred позже когда вырастем.
    • Первые шаги (prevent):
      1. Nginx location /store/geo/suggest: limit_req zone=suggest burst=10 nodelay; (30/min per IP)
      2. DEV-428 k6 scenario: 100 concurrent users × 60 секунд на /store/geo/suggest со случайными query из gold-set + случайных префиксов 1-3 символа. Verify P95 ≤ 200ms
      3. Метрика geo.suggest.rate_limited_count + alert при росте > 5% от total traffic (сигнал scraping attempt)
    • Исполнитель: оба
    • Сигнал успеха (detect): K6 проходит SLO (P95 ≤ 200ms на 100 RPS) на single-node Meilisearch. Production alert: rate_limited_count > 5%
    • Стоп-условие: Если k6 показал P95 > 200ms на 100 RPS — пересмотреть Meilisearch sizing (CPU/RAM single-node) или перейти к 3-node replica в раннем roadmap
    • Если уже случилось (limit damage): Если scraping случился — анализ access log, временный block source ASN/IP range. Долгосрочно: soft auth (anonymous session cookie, Plan Б) + pre-aggregated cache на топ-prefixes (Plan В)
    • Открытые вопросы:
      • Where to apply rate limit: Nginx (DevOps) или PHP middleware (backend) — решит archi review
      • Метрика scraping detection — какой threshold rate_limited_count считать аномальным
  • История статуса:
    • 2026-06-03: ПРИНЯТО (запуск 1) — Принято Г: rate limit + k6 load test в DEV-428

Топ 1–3 — с чего начать

  1. H-001 — DaData fallback нелегитимен — центральный механизм цели мертворождён. Первый шаг: Переписать ADR-016 §«Эскалация» и §«Действия»: убрать DaDataSuggestFallback decorator. Заменить раздел: gap closure через scope expansion в DEV-379/386/427
  2. H-002 — Конкурентный gap — metro/districts/stale sync vs Avito/Cian/Yandex. Первый шаг: DEV-379 ETL: добавить city-level + district-level rows в addresses (уже было в roadmap, поднять приоритет в blocking для launch)
  3. H-004 — Публичный endpoint без rate limiting — scraping геобазы + DoS на single-node. Первый шаг: Nginx location /store/geo/suggest: limit_req zone=suggest burst=10 nodelay; (30/min per IP)

Bias-проверка топа

  • Главный риск искажения: availability bias — пользователь только что озвучил 'DaData нельзя', что подняло H-001 в топ; могла быть переоценена критичность относительно scaling на 12M записей
  • Коррекция: явно проверить с продактом: достаточно ли 48/50 для MVP gate (downward DoD adjustment), или 50/50 absolute — тогда сценарий H-001 решения Г требует пересмотра

Reverse премортем (если запущен)

Заполняется только если финальная рекомендация — отложить/отказаться.

«Прошёл горизонт. Не сделали. Оказалось зря — почему?»

  1. Если ADR-016 не принят и spike-DEV-430 + DEV-386/379/427 заморожены: маркетплейс не получает adress autocomplete вообще, продавцы вводят адрес text-only и допускают опечатки → невалидные адреса в базе, не работают city-фильтры. Через 3 месяца churn на форме регистрации продавцов 30-50%.
  2. Если откладываем pending новый ADR (DaData replacement / Nominatim): идём в прод с pg_trgm (33/50, P95 10s) или вовсе без suggest. Latency P95 10s ломает каталог-фильтр на каждой странице, bounce rate растёт, маркетплейс становится unusable.
  3. Если abort и переключаемся на DaData primary вопреки политике: 7-10K ₽/мес на 100K req/day становится 30-50K при росте до 500K/day Year 2; vendor lock-in делает migration позже невозможной без переделки контракта.

Что перевешивает: Идти вперёд с принятыми Г-решениями: 48/50 — приемлемый MVP gate, scope DEV-379+427 в blocking path для launch оправдан конкурентным gap (H-002) и решает H-001 одновременно.