diff --git a/ADR/01-goals.md b/ADR/01-goals.md new file mode 100644 index 0000000..6ee40c1 --- /dev/null +++ b/ADR/01-goals.md @@ -0,0 +1,7 @@ +# 1. Цели архитектуры + +1. Обеспечить воспроизводимый критичный поток `decide -> event -> report/guardrail` без ручных допущений в runtime. +2. Гарантировать безопасный lifecycle эксперимента: ревью, статусные переходы, блокировка конфликтных запусков. +3. Сохранить корректность данных для аналитики: идемпотентный приём событий, атрибуция через `decision_id`, обработка out-of-order через `PendingEvent`. +4. Поддержать операционную проверяемость: `health/readiness`, метрики, структурированные логи, фоновые задачи. +5. Дать артефакты, по которым жюри может пройти критерии `B1..B10` и выбранные `FX` без домысливания. diff --git a/ADR/02-context.md b/ADR/02-context.md new file mode 100644 index 0000000..c539999 --- /dev/null +++ b/ADR/02-context.md @@ -0,0 +1,26 @@ +# 2. Контекст и границы + +## Внутри системы + +- Один backend-контейнер (Django + Django Ninja API). +- Доменные модули в `apps/*`, HTTP-слой в `api/v1/*`. +- Хранение: реляционная БД (PostgreSQL в целевом окружении, SQLite по умолчанию локально). +- Кэш/брокер: Redis/Valkey в целевом окружении, LocMem cache локально/в тестах. +- Фоновые задачи: Celery worker/beat (guardrails, notifications, cleanup pending). + +## Внешние акторы + +- Product client: вызывает `POST /api/v1/decide`, отправляет `POST /api/v1/events`. +- Experimenter/Approver/Admin/Viewer: управляют экспериментами и смотрят результаты через API. +- Каналы уведомлений: Telegram API и SMTP. +- Система наблюдаемости: скрейпинг `/metrics`, чтение структурированных логов. + +## Служебные контракты + +- `GET /health`: liveness. +- `GET /ready`: readiness (cache, db, storage, celery ping). +- `GET /metrics`: prometheus endpoint. + +## Явная граница фактов + +Документация привязана к текущей реализации в `src/backend`. diff --git a/ADR/03-drivers-nfr.md b/ADR/03-drivers-nfr.md new file mode 100644 index 0000000..692d629 --- /dev/null +++ b/ADR/03-drivers-nfr.md @@ -0,0 +1,42 @@ +# 3. Драйверы и NFR + +## Корректность и воспроизводимость + +- Детерминированная раздача варианта через SHA-256 hash по `subject_id` и `experiment_id`. +- Один активный эксперимент на флаг (`running/paused`) через DB constraint. +- Строгие переходы lifecycle и блокировка недопустимых переходов. +- Идемпотентность событий по `event_id`. + +## Safety + +- Guardrails с порогом, окном наблюдения и автоматическим действием (`pause` или `rollback`). +- Ограничение участия пользователя: max concurrent + cooldown. +- Conflict domains для детерминированного разрешения коллизий экспериментов. + +## Целостность аналитики + +- Атрибуция через `decision_id`. +- Для событий с `requires_exposure=True`: без exposure событие уходит в `PendingEvent` и промотируется позже. +- Отчёт строится по вариантам и выбранным метрикам эксперимента. + +## Эксплуатация + +- Health/readiness probes. +- Prometheus-инструментация HTTP и бизнес-счётчиков. +- Structured logging с correlation id. +- Регламентные фоновые задачи Celery. + +## Производительность + +Реализовано: +- кэш флагов и активных экспериментов; +- кэш результата `decide` (TTL через `DECISION_RESULT_CACHE_TTL_SECONDS`); +- режим записи `Decision`: `sync|async|disabled` (`DECISION_WRITE_MODE`) с принудительным sync для `experiment_assigned`; +- async persistence через `events.persist_decision`; +- `reports` считает `average` через `Avg`, `percentile` через DB aggregate (`PERCENTILE_CONT` для PostgreSQL); +- в `reports` убрана materialization `decision_ids`, используется `Subquery`. + +Текущие ограничения: +- в режиме `async` устойчивость записи зависит от здоровья Celery worker/broker; +- SQL-ветка percentile зависит от СУБД (PostgreSQL/не-PostgreSQL fallback); +- отдельный нагрузочный benchmark-артефакт не приложен. diff --git a/ADR/04-decisions.md b/ADR/04-decisions.md new file mode 100644 index 0000000..59578ec --- /dev/null +++ b/ADR/04-decisions.md @@ -0,0 +1,45 @@ +# 4. Ключевые архитектурные решения + +## Базовые решения + +| ID | Решение | Зачем | Последствия | +|---|---|---|---| +| ADR-01 | Модульный monolith на Django + Django Ninja | Быстрый delivery и единая транзакционная модель | Вертикальное масштабирование, не микросервисы | +| ADR-02 | Разделение на `apps/*` и `api/v1/*` | Чёткие границы между domain и transport | Нужна дисциплина зависимостей | +| ADR-03 | Hash-based deterministic assignment в `decide` | Выполнение B2-4 (стабильность результата) | Чувствительность к качеству `subject_id` | +| ADR-04 | Review policy: `ApproverGroup` + fallback `ReviewSettings` | Выполнение требований по ревью и fallback | Политика плоская, без орг-иерархий | +| ADR-05 | Lifecycle как state machine + validators + DB constraint | Невалидные переходы и двойной запуск на флаг блокируются | Правила статусов централизованы | +| ADR-06 | Event ingestion с дедупом и `PendingEvent` | Идемпотентность и out-of-order атрибуция | Дополнительная сложность обработки pending | +| ADR-07 | Guardrails как first-class модели + auto action | Авто-реакция на деградацию и аудит триггера | Риск ложных срабатываний при шумных данных | +| ADR-08 | Conflict domains (`mutual_exclusion`, `priority`) | Детерминированное разрешение пересечений экспериментов | Дополнительные проверки на старте и в decide | +| ADR-09 | Базовая observability (`health`, `ready`, `/metrics`, JSON logs) | Проверяемость B9 и эксплуатационная диагностика | SLA readiness подтверждается только live-demo | +| ADR-10 | Проверка качества через `just` и автотесты | Воспроизводимая верификация B1/B8/B10 | - | +| ADR-11 | Кэш результата `decide` в cache backend (Redis/Valkey в целевом окружении) | Снизить CPU/DB в hot-path | Ниже latency на повторяющихся запросах; риск stale-ответа в рамках TTL | +| ADR-12 | Режим записи `Decision`: `sync\|async\|disabled`; async через `events.persist_decision`; `experiment_assigned` всегда sync | Снять write-pressure с hot-path без потери атрибуции | В `async` режиме нужна стабильность broker/worker и мониторинг очереди | +| ADR-13 | RBAC через роли `admin/experimenter/approver/viewer` + JWT bearer + endpoint guards | Выполнение требований доступа и ревью-ответственности из раздела 0 ТЗ | Вся авторизация централизована в role guards; нужен контроль качества секретов JWT в окружении | +| ADR-14 | Типизированные feature flags (`string/boolean/integer`) и публичный контракт обновления только `default_value` | Исключить несогласованные значения флагов и обеспечить безопасные переключения без релиза | Смена `key/value_type` решается созданием нового флага и миграцией использования | +| ADR-15 | Каталог типов событий (`EventType`) с `required_fields`, `is_exposure`, `requires_exposure` | Явно отделить схему событий от runtime-обработки и обеспечить корректную атрибуцию | Требуется дисциплина ведения каталога; неактивные типы событий блокируют ingestion | +| ADR-16 | Каталог метрик (`MetricDefinition`) + явная привязка метрик к эксперименту (`ExperimentMetric`) | Выполнение требований 5.4 ТЗ и единый расчёт для reports/guardrails | Изменение `calculation_rule` влияет на интерпретацию отчётов и требует управляемых изменений | +| ADR-17 | Завершение эксперимента через отдельную сущность `ExperimentOutcome` с обязательным `rationale` и валидируемыми outcome-сценариями | Выполнение требований 2.6 и B6-4/B6-5 | Решение по rollout/rollback/no_effect становится аудитируемым и воспроизводимым | +| ADR-18 | Уведомления как rule-based pipeline: `NotificationRule` -> `NotificationLog` -> async flush, с dedup/rate-limit по `event_key` | Закрыть FX notifications без “шторма” сообщений | Ограничение по каналам (`telegram`, `smtp`), требуется мониторинг failed delivery | +| ADR-19 | Поиск по эвристике среди Learnings | Реализовать поиск по базе | Поиск эвристический (веса/Jaccard), не ML и требует аккуратной интерпретации | + +## Неочевидные инварианты + +- В один момент времени на один `FeatureFlag` разрешён только один активный эксперимент (`running|paused`) через partial `UniqueConstraint`. +- После старта эксперимента заморожены поля, влияющие на раздачу (`traffic_allocation`, `targeting_rules`, `flag`), и запрещены изменения вариантов вне `draft`. +- Перед отправкой на review проверяются инварианты состава вариантов: минимум 2, ровно 1 control, сумма весов строго равна `traffic_allocation`. +- Один approver может оставить только одно одобрение по эксперименту (`unique_approval_per_user`). +- Для `requires_exposure=True` событие без exposure не атрибутируется сразу: уходит в `PendingEvent`, промотируется после прихода exposure, затем очищается по TTL. +- Одна метрика не может быть прикреплена к эксперименту дважды (`unique_experiment_metric`). +- `Learning` хранится в one-to-one связи с экспериментом. + +## Ключевые риски + +| Риск | Проявление | Смягчение | Остаток | +|---|---|---|---| +| Stale ответ из кэша `decide` | Кратковременный возврат устаревшего результата в пределах TTL | Revision-aware cache key + короткий TTL | Низкий/средний | +| Потеря throughput при проблемах Celery в `async` | Очередь растёт, запись `Decision` отстаёт | Режимы `sync|async|disabled`, force-sync для `experiment_assigned`, fallback на sync при ошибке enqueue | Средний | +| Тяжёлые запросы отчётов на больших данных | Рост latency для percentile/агрегаций | DB aggregate + `Subquery`, фильтрация attributed событий, индексы | Средний | +| Потеря отложенных атрибуций | `PendingEvent` истекает до прихода exposure | TTL 7 дней + cleanup + промоция при exposure | Средний | +| Сбой каналов уведомлений | Отправка не доходит в Telegram/SMTP | `NotificationLog` со статусами и ошибками | Средний | diff --git a/ADR/05-critical-path.md b/ADR/05-critical-path.md new file mode 100644 index 0000000..1491d4d --- /dev/null +++ b/ADR/05-critical-path.md @@ -0,0 +1,33 @@ +# 5. Критичный путь + +## Decide (`apps/decision/services.py`) + +1. `flag` из cache/DB. +2. `active_experiment` из cache/DB. +3. Формируется cache key результата (`flag`, `subject`, digest атрибутов, ревизии `flag/experiment`). +4. При cache hit возвращается тот же outcome/reason/value с новым `decision_id`. +5. При cache miss выполняются проверки: running experiment, targeting, participation limits, domain conflicts, traffic allocation. +6. Если назначен вариант, выбирается детерминированно по hash + weights. +7. Результат может быть закэширован (по whitelist reason). +8. Запись `Decision` зависит от `DECISION_WRITE_MODE`: `sync|async|disabled`; для `experiment_assigned` запись всегда sync. + +Reason-коды: `flag_not_found`, `no_active_experiment`, `targeting_mismatch`, `participation_limit`, `domain_conflict`, `outside_traffic_allocation`, `no_variants`, `experiment_assigned`. + +## Events (`apps/events/services.py`) + +1. Валидация типа события и payload. +2. Дедуп по `event_id`. +3. Exposure: `Exposure` + `Event`, затем промоция `PendingEvent`. +4. Conversion: при `requires_exposure=True` без exposure -> `PendingEvent` (TTL 7 дней), иначе attributed `Event`. + +## Reports (`apps/reports/services.py`) + +- По каждому варианту: `exposures`, `unique_subjects`, значения выбранных метрик. +- Поддерживается период `start/end`. +- `average` считает DB `Avg`, `percentile` - DB aggregate (`PERCENTILE_CONT` в PostgreSQL). +- Связь событий с экспозициями строится через `Subquery`, без materialization списка `decision_ids` в Python. + +## Guardrails (`apps/guardrails/services.py`) + +- Celery проверяет running эксперименты. +- При breach: `GuardrailTrigger` + действие (`pause`/`rollback`) + `ExperimentLog` + enqueue уведомления. diff --git a/ADR/06-operations.md b/ADR/06-operations.md new file mode 100644 index 0000000..f285efd --- /dev/null +++ b/ADR/06-operations.md @@ -0,0 +1,34 @@ +# 6. Эксплуатация и наблюдаемость + +## Endpoints + +- `/health` -> liveness (`health_check.Memory`). +- `/ready` -> readiness (`Cache`, `Database`, `Storage`, `Celery Ping`). +- `/metrics` -> Prometheus metrics. + +SLA `ready <= 180s` подтверждается только live-demo. + +## Метрики и логи + +- Инфраструктурные метрики через `django-prometheus` middleware. +- Бизнес-счётчики: `lotty_decide_requests_total`, `lotty_events_ingested_total`. +- Production logs: JSON + `Correlation-ID` (`django-guid`). + +## Celery задачи + +Периодические: +- `guardrails.check_all` - 60s. +- `notifications.flush_pending` - 30s. +- `events.cleanup_expired_pending` - 3600s. + +По запросу: +- `events.persist_decision` - асинхронная запись `Decision` при `DECISION_WRITE_MODE=async`. + +## Runtime knobs + +- `DECISION_RESULT_CACHE_TTL_SECONDS` (по умолчанию `60`). +- `DECISION_WRITE_MODE` (`sync|async|disabled`, по умолчанию `sync`). + +## Команды + +`cd src/backend && just run|test|test-coverage|lint|format` diff --git a/ADR/07-simplifications.md b/ADR/07-simplifications.md new file mode 100644 index 0000000..a7a4f83 --- /dev/null +++ b/ADR/07-simplifications.md @@ -0,0 +1,8 @@ +# 7. Явные упрощения + +1. Один backend-сервис (модульный monolith), без выделения микросервисов по доменам. +2. Отдельный statistical significance engine не реализован; решения фиксируются на метриках и guardrails. +3. Отчёты считаются синхронно в запросе, без pre-aggregation pipeline и materialized views (потому что нету ClickHouse). +4. Режим `DECISION_WRITE_MODE=sync` используется по умолчанию; `async`/`disabled` - эксплуатационные режимы. +5. Кэш результата `decide` основан на TTL и revision-aware ключе, без отдельной ручной инвалидции. +6. Similar learnings считаются эвристикой (Jaccard/веса), без ML-ранжирования. diff --git a/ADR/08-verification.md b/ADR/08-verification.md new file mode 100644 index 0000000..6fa248f --- /dev/null +++ b/ADR/08-verification.md @@ -0,0 +1,21 @@ +# 8. Проверяемость + +## Базовый набор + +1. `cd src/backend && just --list` +2. `cd src/backend && just test` +3. `cd src/backend && just test-coverage && just show-coverage` + +## Точечные проверки по доменам + +1. `cd src/backend && uv run python manage.py test apps.decision.tests.test_decide` +2. `cd src/backend && uv run python manage.py test apps.events.tests.test_services tests.integration.test_events` +3. `cd src/backend && uv run python manage.py test apps.reports.tests.test_reports apps.guardrails.tests.test_guardrails` +4. `cd src/backend && uv run python manage.py test apps.reviews.tests.test_reviews_policy apps.experiments.tests.test_services` +5. `cd src/backend && uv run python manage.py test apps.conflicts.tests.test_conflicts apps.notifications.tests.test_notifications apps.learnings.tests.test_learnings` + +## Runtime + +- `GET /health` -> `200` +- `GET /ready` -> `200` после готовности зависимостей +- `GET /metrics` -> есть инфраструктурные и бизнес метрики diff --git a/ADR/README.md b/ADR/README.md new file mode 100644 index 0000000..0520564 --- /dev/null +++ b/ADR/README.md @@ -0,0 +1,16 @@ +# ADR - LOTTY Backend + +## Состав + +| Файл | Содержание | +|---|---| +| `01-goals.md` | Цели архитектуры | +| `02-context.md` | Границы системы и акторы | +| `03-drivers-nfr.md` | Драйверы и NFR | +| `04-decisions.md` | Ключевые решения, неочевидные инварианты и ключевые риски | +| `05-critical-path.md` | Критичный поток `decide -> event -> report/guardrail` | +| `06-operations.md` | Эксплуатация и наблюдаемость | +| `07-simplifications.md` | Явные упрощения | +| `08-verification.md` | Как проверять критерии | + +Связанная матрица соответствия: [compliance-matrix.md](../compliance-matrix.md). diff --git a/MAP.md b/MAP.md index 102d988..4fb99a2 100644 --- a/MAP.md +++ b/MAP.md @@ -6,15 +6,17 @@ - Django settings: [src/backend/config/settings](./src/backend/config/settings) - URL root: [src/backend/config/urls.py](./src/backend/config/urls.py) - API wiring: [src/backend/api/urls.py](./src/backend/api/urls.py) -- API v1 router: [src/backend/api/v1/router.py](./src/backend/api/v1/router.py) +- API v1 роутер: [src/backend/api/v1/router.py](./src/backend/api/v1/router.py) - Команды и проверки: [src/backend/justfile](./src/backend/justfile) +- Файлы конфигурации деплоя: [deploy](./deploy) +- Файлы конфигурации для сервисов: [infrastructure/configs](./infrastructure/configs) ## 2. Основные доменные модули ([src/backend/apps](./src/backend/apps)) - [flags](./src/backend/apps/flags) - feature flags и типизация значений. - [experiments](./src/backend/apps/experiments) - жизненный цикл эксперимента, варианты. - [reviews](./src/backend/apps/reviews) - группы аппруверов, дефолтная политика апрува. -- [decision](./src/backend/apps/decision) - логика выбора варианта для флага (детерминизм, таргетинг, лимиты участия, конфликт-домены). +- [decision](./src/backend/apps/decision) - логика выбора варианта для флага (детерминизм, таргетинг, лимиты участия). - [events](./src/backend/apps/events) - обработка эвентов: валидация, дедуп, атрибуция, pending events. - [metrics](./src/backend/apps/metrics) - каталог и правила вычисления метрик. - [reports](./src/backend/apps/reports) - отчёты по экспериментам в разрезе вариантов/периода. diff --git a/README.md b/README.md index 3080b63..01f48ab 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,24 @@ Service for managing A/B testing experiments. Drive your tests without breaking ### Diagrams +#### C4 Context + +![C4 context](./assets/plantuml/rendered/c4_context.png) + +Source: [C4 context](./assets/plantuml/raw/c4-context.puml) + +#### C4 Container + +![C4 container](./assets/plantuml/rendered/c4_container.png) + +Source: [C4 container](./assets/plantuml/raw/c4-container.puml) + +#### C4 Component + +![C4 component](./assets/plantuml/rendered/c4_component.png) + +Source: [C4 component](./assets/plantuml/raw/c4-component-critical-path.puml) + ### Testing report Can be seen in GitLab CI/CD pipeline run diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..b7d6e02 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,204 @@ +# Runbook + +Этот runbook задаёт основной сценарий проверки по критериям `B1..B10` и выбранным `FX`. + +## 1. Что проверяем + +Сквозной поток: +- `decide` -> `events` -> `report`; +- lifecycle/review/guardrails; +- observability (`/health`, `/ready`, `/metrics`); +- инженерная дисциплина (tests, lint, format); +- выбранные FX: notifications, learnings, conflicts. + +Артефакты: +- `compliance-matrix.md` +- `ADR/*` +- `MAP.md` +- `assets/plantuml/raw/c4-context.puml` +- `assets/plantuml/raw/c4-container.puml` +- `assets/plantuml/raw/c4-component-critical-path.puml` + +## 2. Предусловия + +### 2.1 Основной сценарий (по умолчанию): Docker Compose + +Нужно: +- Docker +- Docker Compose + +Файлы: +- `compose.yaml` - базовый стек +- `compose.prod.yaml` - базовый стек + observability +- Детали по compose-конфигурации: `README.md` -> `Setup with docker compose`. + +Порты по умолчанию (см. `.env.template`): +- reverse proxy: `80` +- backend direct: `14609` +- static direct: `14610` + +### 2.2 Дополнительный локальный сценарий (для тестов/линтинга) + +Нужно: +- Python `>=3.13,<3.15` +- `uv` +- `just` + +Рабочая директория: +- `cd src/backend` + +## 3. Основной сценарий запуска (Docker Compose) + +### 3.1 Выбор стека + +Базовый стек: +```bash +docker compose -f compose.yaml up -d +``` + +Полный стек (с observability): +```bash +docker compose -f compose.prod.yaml --profile observability up -d +``` + +### 3.2 Проверка, что сервисы поднялись + +```bash +docker compose -f compose.yaml ps +# или +docker compose -f compose.prod.yaml --profile observability ps +``` + +Ожидаем `running/healthy` для `postgresql`, `valkey`, `backend`, `backend-celery-worker`, `backend-celery-beat`. + +### 3.3 Runtime sanity-check + +Через reverse proxy (`80`): +```bash +curl -i http://127.0.0.1/health +curl -i http://127.0.0.1/ready +curl -s http://127.0.0.1/metrics | head -n 40 +``` + +Альтернатива напрямую в backend (`14609`): +```bash +curl -i http://127.0.0.1:14609/health +curl -i http://127.0.0.1:14609/ready +curl -s http://127.0.0.1:14609/metrics | head -n 40 +``` + +### 3.4 Просмотр логов + +```bash +docker compose -f compose.yaml logs -f backend backend-celery-worker backend-celery-beat +# или +docker compose -f compose.prod.yaml --profile observability logs -f backend backend-celery-worker backend-celery-beat +``` + +## 4. Проверка критериев B1..B10 + +### B1. Запуск и воспроизводимость + +Compose runtime-подтверждение: +```bash +docker compose -f compose.yaml up -d +docker compose -f compose.yaml ps +curl -i http://127.0.0.1/health +curl -i http://127.0.0.1/ready +``` + +Показываем: +- сервисы запускаются без ручных скрытых шагов; +- backend жив и готов после зависимостей. + +### B2-B6, B8, B10. Функциональность, отчёты, тестирование и дисциплина + +Эти проверки выполняются локально из `src/backend` (dev-зависимости): +```bash +cd src/backend +just --list +just test +just test-coverage +just show-coverage +just lint +just format +``` + +Точечные наборы: +```bash +cd src/backend +uv run python manage.py test apps.decision.tests.test_decide +uv run python manage.py test apps.events.tests.test_services tests.integration.test_events +uv run python manage.py test apps.reports.tests.test_reports apps.guardrails.tests.test_guardrails +uv run python manage.py test apps.reviews.tests.test_reviews_policy apps.experiments.tests.test_services +uv run python manage.py test tests.integration.test_negative tests.integration.test_happy_path tests.integration.test_api_contract +``` + +### B7. Архитектурные артефакты + +Проверяем: +- `compliance-matrix.md` +- `ADR/README.md` +- `ADR/04-decisions.md` +- `MAP.md` +- `assets/plantuml/raw/c4-context.puml` +- `assets/plantuml/raw/c4-container.puml` +- `assets/plantuml/raw/c4-component-critical-path.puml` + +### B9. Эксплуатационная готовность и наблюдаемость + +Runtime через compose: +```bash +curl -i http://127.0.0.1/health +curl -i http://127.0.0.1/ready +curl -s http://127.0.0.1/metrics | head -n 40 +``` + +## 5. Проверка выбранных допфич (FX) + +Локально из `src/backend`: + +FX: Notifications +```bash +uv run python manage.py test apps.notifications.tests.test_notifications api.v1.notifications.tests.test_notifications_api +``` + +FX: Learnings +```bash +uv run python manage.py test apps.learnings.tests.test_learnings api.v1.learnings.tests.test_learnings_api +``` + +FX: Conflicts +```bash +uv run python manage.py test apps.conflicts.tests.test_conflicts api.v1.conflicts.tests.test_conflicts_api +``` + +Объединённый прогон: +```bash +uv run python manage.py test \ + apps.notifications.tests.test_notifications \ + apps.learnings.tests.test_learnings \ + apps.conflicts.tests.test_conflicts \ + api.v1.notifications.tests.test_notifications_api \ + api.v1.learnings.tests.test_learnings_api \ + api.v1.conflicts.tests.test_conflicts_api +``` + +## 6. Остановка и очистка + +Базовый стек: +```bash +docker compose -f compose.yaml down +``` + +Полный стек: +```bash +docker compose -f compose.prod.yaml --profile observability down +``` + +С удалением томов: +```bash +docker compose -f compose.yaml down -v +# или +docker compose -f compose.prod.yaml --profile observability down -v +``` diff --git a/assets/plantuml/raw/c4-component-critical-path.puml b/assets/plantuml/raw/c4-component-critical-path.puml index 04ff62a..a223cc1 100644 --- a/assets/plantuml/raw/c4-component-critical-path.puml +++ b/assets/plantuml/raw/c4-component-critical-path.puml @@ -7,20 +7,21 @@ title LOTTY Backend - C4 Component Container_Boundary(api, "Backend Container (Django + Django Ninja)") { Component(decision_endpoint, "Decision Endpoint", "api/v1/decision/endpoints.py", "POST /api/v1/decide") - Component(decision_service, "Decision Service", "apps/decision/services.py", "Flag lookup, targeting DSL, participation limits, hash-based variant assignment") + Component(decision_service, "Decision Service", "apps/decision/services.py", "Flag lookup, targeting DSL, limits/conflicts, hash-based assignment, decide-result cache, sync/async decision persistence") Component(events_endpoint, "Events Endpoint", "api/v1/events/endpoints.py", "POST /api/v1/events") Component(events_service, "Events Service", "apps/events/services.py", "Validation, dedup, exposure/conversion attribution, pending promotion") + Component(events_tasks, "Events Tasks", "apps/events/tasks.py", "persist_decision, cleanup pending") Component(reports_endpoint, "Reports Endpoint", "api/v1/reports/endpoints.py", "GET /reports/{experiment_id}") - Component(reports_service, "Reports Service", "apps/reports/services.py", "Per-variant metric calculation (ratio, count, average, percentile)") + Component(reports_service, "Reports Service", "apps/reports/services.py", "Per-variant metrics (ratio/count/average/percentile)") Component(guardrails_service, "Guardrails Service", "apps/guardrails/services.py", "Threshold checks, auto pause/rollback") } ContainerDb(db, "Relational DB", "PostgreSQL / SQLite", "Flags, experiments, variants, decisions, events, exposures") -Container(cache, "Cache", "Valkey / LocMem", "Flag and active experiment cache") -Container(worker, "Celery Beat", "Celery", "Periodic guardrail checks (60s)") +Container(cache, "Cache + Broker", "Valkey / Redis / LocMem", "Flag/active experiment/decide-result cache, Celery broker") +Container(worker, "Celery Worker / Beat", "Celery", "Periodic guardrails/notifications/cleanup + async decision persistence") Rel(decision_endpoint, decision_service, "Delegates") Rel(decision_service, cache, "Reads cached flag / active experiment") @@ -28,6 +29,7 @@ Rel(decision_service, db, "Reads variants, writes Decision") Rel(events_endpoint, events_service, "Delegates batch") Rel(events_service, db, "Writes Event / Exposure / PendingEvent") +Rel(events_tasks, db, "Writes Decision async") Rel(reports_endpoint, reports_service, "Builds report") Rel(reports_service, db, "Reads attributed events / exposures") diff --git a/assets/plantuml/raw/c4-container.puml b/assets/plantuml/raw/c4-container.puml index da5aaca..8a75ef8 100644 --- a/assets/plantuml/raw/c4-container.puml +++ b/assets/plantuml/raw/c4-container.puml @@ -11,8 +11,8 @@ Person(ops_user, "Experimenter/Approver/Admin/Viewer") System_Boundary(lotty_boundary, "LOTTY Backend") { Container(api, "Backend API", "Django + Ninja", "REST endpoints: flags, experiments, decide, events, reports, guardrails, notifications, conflicts, metrics, learnings, reviews, users, auth") ContainerDb(db, "PostgreSQL / SQLite", "Relational DB", "Experiments, variants, decisions, events, exposures, outcomes, guardrails, notifications, conflicts, logs") - Container(cache, "Valkey / LocMem Cache", "Cache + Celery broker", "Flag/experiment cache, Celery task broker") - Container(worker, "Celery Worker / Beat", "Background worker", "Periodic: guardrails check, notifications flush, pending events cleanup") + Container(cache, "Valkey / Redis / LocMem Cache", "Cache + Celery broker", "Flag/experiment/decide-result cache, Celery task broker") + Container(worker, "Celery Worker / Beat", "Background worker", "Periodic jobs + async decision persistence") } System_Ext(notifications, "Notification Channels", "Telegram / SMTP") @@ -20,9 +20,9 @@ System_Ext(notifications, "Notification Channels", "Telegram / SMTP") Rel(product_client, api, "Gets flag decisions, sends events", "HTTP/JSON") Rel(ops_user, api, "Admin/experiment/review calls", "HTTP/JSON") Rel(api, db, "Read/write application state", "SQL") -Rel(api, cache, "Read/write cache", "Redis protocol") +Rel(api, cache, "Read/write cache, enqueue async tasks", "Redis protocol") Rel(worker, db, "Read/write application state", "SQL") -Rel(worker, cache, "Receives tasks, reads cached state", "Redis protocol") +Rel(worker, cache, "Consumes tasks, reads cached state", "Redis protocol") Rel(worker, notifications, "Delivers queued notifications", "HTTP / SMTP") @enduml diff --git a/assets/plantuml/rendered/c4_component.png b/assets/plantuml/rendered/c4_component.png new file mode 100644 index 0000000..b8e0719 --- /dev/null +++ b/assets/plantuml/rendered/c4_component.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:103dccd81279154da66de78fcf9d4761b899476f2e0ef0f27303791830aa4e08 +size 140460 diff --git a/assets/plantuml/rendered/c4_container.png b/assets/plantuml/rendered/c4_container.png new file mode 100644 index 0000000..54ef5a5 --- /dev/null +++ b/assets/plantuml/rendered/c4_container.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:747499c4149064898f8233128b9476ff862f001ca8b8fa5b6fabfaa1582be13b +size 111643 diff --git a/assets/plantuml/rendered/c4_context.png b/assets/plantuml/rendered/c4_context.png new file mode 100644 index 0000000..ed359e1 --- /dev/null +++ b/assets/plantuml/rendered/c4_context.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db6acc4a023ffece373412d84a03e2de2900be865be145975af21c7c5839b856 +size 117237 diff --git a/compliance-matrix.md b/compliance-matrix.md new file mode 100644 index 0000000..d286e18 --- /dev/null +++ b/compliance-matrix.md @@ -0,0 +1,65 @@ +# Матрица соответствия: задание -> критерий -> реализация + +Статусы: +- `подтверждено` - есть прямая реализация и проверка тестами/командами. +- `частично (live-demo)` - реализация есть, но критерий окончательно закрывается только на живом запуске. + +| ID задания | ID критерия | Проблема/риск | Где реализовано | Как проверяется | Какие данные нужны | Статус | +|---|---|---|---|---|---|---| +| `D.4` | `B1-1` | Без предусловий жюри не сможет воспроизвести запуск | `RUNBOOK.md`, `src/backend/README.md` | Ручная проверка разделов предусловий | Python, uv, just, рабочая директория `src/backend` | подтверждено | +| `D.4` | `B1-2` | Неоднозначные команды старта ломают проверку | `RUNBOOK.md`, `src/backend/justfile` | `cd src/backend && just --list` | Доступ к `just` | подтверждено | +| `D.3` | `B1-3` | Скрытые ручные шаги делают запуск невоспроизводимым | `RUNBOOK.md`, `compose.yaml`, `compose.prod.yaml` | Полный прогон по runbook на стенде | Чистое окружение и runtime-сервисы | частично (live-demo) | +| `3.7` | `B1-4` | Сервис может стартовать, но быть неготовым к запросам | `src/backend/api/urls.py` (`/health`, `/ready`) | Runtime `curl /health` и `curl /ready` | Поднятый backend и зависимости | частично (live-demo) | +| `D.5` | `B1-5` | Без e2e happy-path нельзя доказать работоспособность | `src/backend/tests/integration/test_happy_path.py`, `src/backend/tests/integration/test_api_contract.py` | `cd src/backend && just test` | Тестовые фикстуры и встроенный test DB | подтверждено | +| `1.3, 3.4` | `B2-1` | Возврат не-default без активного эксперимента искажает контроль | `src/backend/apps/decision/services.py` | `apps.decision.tests.test_decide.DecideForFlagTest.test_no_active_experiment` | Флаг с default и без running эксперимента | подтверждено | +| `1.3, 2.7` | `B2-2` | Пользователь вне таргетинга не должен получать variant | `src/backend/apps/decision/services.py`, `src/backend/libs/dsl/*` | `apps.decision.tests.test_decide.TargetingRulesTest.test_targeting_fail_returns_default` | Эксперимент с targeting rules и mismatching subject | подтверждено | +| `1.3, 3.4` | `B2-3` | При применимом эксперименте нужен variant, а не default | `src/backend/apps/decision/services.py` | `apps.decision.tests.test_decide.DecideForFlagTest.test_running_experiment_assigns_variant` | Running experiment с вариантами | подтверждено | +| `3.5.1` | `B2-4` | Нестабильная выдача ломает статистику и UX | `src/backend/apps/decision/services.py` (`_hash_subject`) | `apps.decision.tests.test_decide.DecideForFlagTest.test_deterministic_assignment` | Повторные вызовы для одного subject | подтверждено | +| `2.2` | `B2-5` | Игнорирование weights делает тест нерепрезентативным | `src/backend/apps/decision/services.py` (`_select_variant`) | `apps.decision.tests.test_decide.SelectVariantTest.test_selects_by_weight` | Варианты с разными весами | подтверждено | +| `2.4` | `B3-1` | Без перехода в review нет управляемого процесса запуска | `src/backend/apps/experiments/services.py` (`experiment_submit_for_review`) | `apps.experiments.tests.test_services` | Draft experiment с валидными вариантами | подтверждено | +| `2.4` | `B3-2` | Без авто-перехода в approved ревью-процесс зависает | `src/backend/apps/experiments/services.py` (`experiment_approve`) | `apps.experiments.tests.test_services` | Approver + достигнутый порог approvals | подтверждено | +| `0.3, 2.4` | `B3-3` | Запуск без порога approvals делает процесс небезопасным | `src/backend/apps/experiments/services.py`, `src/backend/tests/integration/test_negative.py` | `InvalidLifecycleTransitionsTest.test_cannot_start_without_enough_approvals` | `default_min_approvals > 1`, недостаток approve | подтверждено | +| `2.5` | `B3-4` | Невалидные lifecycle переходы ломают состояние | `src/backend/apps/experiments/models.py` (`ALLOWED_TRANSITIONS`) | `src/backend/tests/integration/test_negative.py` | Эксперимент и попытка запрещённого перехода | подтверждено | +| `0.2, 0.3` | `B3-5` | Неназначенный пользователь не должен влиять на review | `src/backend/apps/reviews/selectors.py`, `src/backend/apps/experiments/services.py` | `apps.reviews.tests.test_reviews_policy`, `test_negative.ReviewPolicyEnforcementTest` | Approver group / fallback настройки | подтверждено | +| `4.5` | `B4-1` | Невалидные типы событий портят аналитику | `src/backend/apps/events/services.py` (`_validate_event_payload`) | `apps.events.tests.test_services` | Batch с ошибочными типами полей | подтверждено | +| `4.5` | `B4-2` | Отсутствие обязательных полей делает события непригодными | `src/backend/apps/events/services.py`, `src/backend/apps/events/models.py` | `apps.events.tests.test_services`, `test_negative.EventValidationIntegrationTest` | События без required base/properties | подтверждено | +| `4.3` | `B4-3` | Дубли событий завышают метрики | `src/backend/apps/events/services.py` (`_is_duplicate`) | `apps.events.tests.test_services`, `tests.integration.test_events` | Два события с одним `event_id` | подтверждено | +| `4.1, 4.4` | `B4-4` | Без связи exposure с decision теряется атрибуция | `src/backend/apps/events/models.py` (`Exposure.decision_id`) | `apps.events.tests.test_services` | Валидный `decision_id` и exposure event | подтверждено | +| `4.4.1` | `B4-5` | Конверсия без exposure не должна попадать в отчёт | `src/backend/apps/events/services.py` (`requires_exposure`, `PendingEvent`) | `apps.events.tests.test_services`, `tests.integration.test_events` | Conversion до exposure и последующая промоция | подтверждено | +| `6.2` | `B5-1` | Guardrail без metric_key нефункционален | `src/backend/apps/guardrails/models.py` (`metric`) | `apps.guardrails.tests.test_guardrails` | Guardrail с привязанной метрикой | подтверждено | +| `6.2` | `B5-2` | Guardrail без threshold не может сработать | `src/backend/apps/guardrails/models.py` (`threshold`) | `apps.guardrails.tests.test_guardrails` | Guardrail с заданным порогом | подтверждено | +| `6.3` | `B5-3` | Превышение порога должно фиксироваться автоматически | `src/backend/apps/guardrails/services.py` | `apps.guardrails.tests.test_guardrails`, `tests.integration.test_guardrails` | Метрика выше threshold в окне | подтверждено | +| `6.4` | `B5-4` | После trigger должно выполняться действие безопасности | `src/backend/apps/guardrails/services.py` (`pause`, `rollback`) | `apps.guardrails.tests.test_guardrails` | Running experiment + breach | подтверждено | +| `6.5` | `B5-5` | Без аудита триггеров нельзя объяснить остановку | `src/backend/apps/guardrails/models.py` (`GuardrailTrigger`), `apps/experiments/models.py` (`ExperimentLog`) | `apps.guardrails.tests.test_guardrails` | Triggered guardrail | подтверждено | +| `3.6` | `B5-6` | Один пользователь не должен постоянно быть в экспериментах | `src/backend/apps/decision/services.py` (`MAX_CONCURRENT_EXPERIMENTS`, `COOLDOWN_DAYS`) | `apps.decision.tests.test_decide.ParticipationLimitsTest` | Несколько running/completed экспериментов на одного subject | подтверждено | +| `5.2` | `B6-1` | Отчёт без периода неуправляем и спорный | `src/backend/apps/reports/services.py` | `apps.reports.tests.test_reports.CalculateMetricValueTest.test_period_filter` | События внутри/вне окна периода | подтверждено | +| `5.3` | `B6-2` | Без разреза по вариантам нельзя сравнивать A/B | `src/backend/apps/reports/services.py` (`variant_reports`) | `apps.reports.tests.test_reports.BuildExperimentReportTest` | Эксперимент минимум с двумя вариантами | подтверждено | +| `5.4` | `B6-3` | Отчёт должен показывать именно выбранные метрики эксперимента | `src/backend/apps/metrics/models.py`, `src/backend/apps/reports/services.py` | `apps.reports.tests.test_reports` | `ExperimentMetric` привязки | подтверждено | +| `2.6` | `B6-4` | Нужна фиксация исхода (`rollout/rollback/no_effect`) | `src/backend/apps/experiments/services.py` (`experiment_complete`) | `apps.experiments.tests.test_services` | Completed experiment и выбранный outcome | подтверждено | +| `2.6` | `B6-5` | Решение без rationale теряет объяснимость | `src/backend/apps/experiments/services.py` (валидация `rationale`) | `tests.integration.test_negative.InvalidLifecycleTransitionsTest.test_cannot_complete_without_rationale` | Пустой rationale при complete | подтверждено | +| `D.5(B7)` | `B7-1` | Неясный нейминг усложняет поддержку и демо | `src/backend/apps/*`, `src/backend/api/v1/*` | Архитектурный walkthrough | Репозиторий проекта | подтверждено | +| `D.5(B7)` | `B7-2` | Без границ модулей растёт связность и регрессии | `src/backend/apps/*`, `src/backend/api/v1/*` | Проверка структуры директорий и зависимостей | Репозиторий проекта | подтверждено | +| `D.4, D.6` | `B7-3` | Без матрицы тяжело трассировать критерии | `compliance-matrix.md` | Проверка заполнения всех ID | Этот файл | подтверждено | +| `D.4` | `B7-4` | Без ADR сложно доказать осознанные trade-off | `ADR/04-decisions.md` | Сверка решений с кодом | ADR и код `src/backend` | подтверждено | +| `D.4` | `B7-5` | Нужна контекстная диаграмма границ системы | `assets/plantuml/raw/c4-context.puml` | Просмотр диаграммы | PlantUML файл | подтверждено | +| `D.4` | `B7-6` | Нужна container-диаграмма взаимодействий | `assets/plantuml/raw/c4-container.puml` | Просмотр диаграммы | PlantUML файл | подтверждено | +| `D.4` | `B7-7` | Нужна component-диаграмма по критичному пути | `assets/plantuml/raw/c4-component-critical-path.puml` | Просмотр диаграммы | PlantUML файл | подтверждено | +| `D.4` | `B7-8` | Без карты репозитория навигация медленная | `MAP.md` | Проверка ссылок на точки входа | Файл `MAP.md` | подтверждено | +| `D.7` | `B7-9` | Неявные упрощения искажают ожидания жюри | `ADR/07-simplifications.md` | Проверка явного списка ограничений | ADR пакет | подтверждено | +| `D.5(B8)` | `B8-1` | Без негативных тестов edge-cases не покрыты | `src/backend/tests/integration/test_negative.py` | `cd src/backend && just test` | Тестовая БД и фикстуры | подтверждено | +| `D.5(B8)` | `B8-2` | Критичный поток нужен в интеграционных/контрактных тестах | `src/backend/tests/integration/test_happy_path.py`, `src/backend/tests/integration/test_api_contract.py` | `cd src/backend && just test` | Тестовые фикстуры | подтверждено | +| `D.4` | `B8-4` | Нужен измеримый отчёт по покрытию | `src/backend/justfile`, `src/backend/pyproject.toml` | `cd src/backend && just test-coverage && just show-coverage` | Coverage tool из dev dependencies | подтверждено | +| `3.7` | `B9-1` | Readiness должен быть однозначным и проверяемым | `src/backend/api/urls.py` (`/ready`) | Runtime: `curl -i /ready` после старта | Поднятые cache/db/storage/celery | частично (live-demo) | +| `3.7` | `B9-2` | Нужен отдельный liveness probe | `src/backend/api/urls.py` (`/health`) | Runtime: `curl -i /health` | Поднятый backend | подтверждено | +| `D.5(B9)` | `B9-3` | Без метрик нет наблюдаемости hot-path | `src/backend/config/settings/base.py`, `src/backend/apps/decision/services.py`, `src/backend/api/v1/events/endpoints.py` | Runtime: `curl /metrics` | Поднятый backend | подтверждено | +| `D.5(B9)` | `B9-4` | Неструктурированные логи сложны для алертов и анализа | `src/backend/config/settings/base.py` (json formatter, django-guid) | Запуск в non-debug и просмотр stdout | Конфигурация `DJANGO_DEBUG=false` | подтверждено | +| `D.5(B9)` | `B9-6` | Рост трафика/данных может деградировать latency даже после оптимизаций | `src/backend/apps/decision/services.py`, `src/backend/apps/reports/services.py`, `src/backend/apps/events/tasks.py`, `ADR/04-decisions.md` (P1-P4) | Код-ревью + live-demo под нагрузкой | Сценарий увеличенного трафика и наблюдение очереди/latency | частично (live-demo) | +| `D.5(B9)` | `B9-7` | Без индексов и оптимизаций горячие запросы дорожают | `src/backend/apps/experiments/models.py`, `src/backend/apps/events/models.py`, `src/backend/apps/guardrails/models.py`, `src/backend/apps/notifications/models.py`, `src/backend/apps/learnings/models.py` | Схема моделей и миграций | БД-схема проекта | подтверждено | +| `D.5(B10)` | `B10-1` | Отсутствие автоматического линтинга снижает качество | `src/backend/justfile`, `src/backend/pyproject.toml`, `.gitlab-ci.yml` | `cd src/backend && just lint` | Dev dependencies | подтверждено | +| `D.5(B10)` | `B10-2` | Отсутствие форматирования повышает шум в diff | `src/backend/justfile`, `src/backend/pyproject.toml`, `.gitlab-ci.yml` | `cd src/backend && just format` | Dev dependencies | подтверждено | +| `7` | `FX-1` | Без рабочего сценария уведомления бесполезны | `src/backend/apps/notifications/*`, `src/backend/api/v1/notifications/*` | `cd src/backend && uv run python manage.py test apps.notifications.tests.test_notifications api.v1.notifications.tests.test_notifications_api` | Notification channels/rules/log fixtures | подтверждено | +| `7` | `FX-2` | Нужны явные ограничения по каналам и шумоподавлению | `ADR/07-simplifications.md`, `ADR/04-decisions.md`, `src/backend/apps/notifications/services.py` | Сверка docs и реализации rate-limit/dedup | Документация + код notifications | подтверждено | +| `9` | `FX-1` | Без рабочего knowledge base повторяются эксперименты | `src/backend/apps/learnings/*`, `src/backend/api/v1/learnings/*` | `cd src/backend && uv run python manage.py test apps.learnings.tests.test_learnings api.v1.learnings.tests.test_learnings_api` | Learning fixtures, experiments, tags | подтверждено | +| `9` | `FX-2` | Нужны явные ограничения алгоритма похожести | `ADR/07-simplifications.md`, `ADR/04-decisions.md`, `src/backend/apps/learnings/services.py` | Сверка docs и реализации similarity scoring | Документация + код learnings | подтверждено | +| `11` | `FX-1` | Без резолва конфликтов метрики неинтерпретируемы | `src/backend/apps/conflicts/*`, `src/backend/api/v1/conflicts/*`, `src/backend/apps/decision/services.py` | `cd src/backend && uv run python manage.py test apps.conflicts.tests.test_conflicts api.v1.conflicts.tests.test_conflicts_api` | Эксперименты в доменах с policy `mutual_exclusion/priority` | подтверждено | +| `11` | `FX-2` | Нужны явные границы конфликтных политик | `ADR/07-simplifications.md`, `ADR/04-decisions.md`, `src/backend/apps/conflicts/services.py` | Сверка docs и `resolve_domain_conflict` | Документация + код conflicts | подтверждено |