From 2056041ee233b50a481a19350400e5f8a497b36c Mon Sep 17 00:00:00 2001 From: ITQ Date: Tue, 24 Feb 2026 19:30:19 +0300 Subject: [PATCH] fix(flags): added cache invalidation --- ADR/04-decisions.md | 5 +++-- ADR/05-critical-path.md | 2 +- compliance-matrix.md | 2 +- src/backend/apps/flags/services.py | 7 +++++++ src/backend/apps/flags/tests/test_flags.py | 15 +++++++++++++++ 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/ADR/04-decisions.md b/ADR/04-decisions.md index 59578ec..5384fbe 100644 --- a/ADR/04-decisions.md +++ b/ADR/04-decisions.md @@ -14,7 +14,7 @@ | 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-11 | Кэш результата `decide` в cache backend (Redis/Valkey в целевом окружении) | Снизить CPU/DB в hot-path | Ниже latency на повторяющихся запросах; stale-риск снижен revision-aware cache key и инвалидацией `flag:{key}` при обновлении default | | 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` решается созданием нового флага и миграцией использования | @@ -33,12 +33,13 @@ - Для `requires_exposure=True` событие без exposure не атрибутируется сразу: уходит в `PendingEvent`, промотируется после прихода exposure, затем очищается по TTL. - Одна метрика не может быть прикреплена к эксперименту дважды (`unique_experiment_metric`). - `Learning` хранится в one-to-one связи с экспериментом. +- После `FeatureFlag.default_value` update удаляется cache key `flag:{key}`; следующий `decide` читает актуальный flag из DB. ## Ключевые риски | Риск | Проявление | Смягчение | Остаток | |---|---|---|---| -| Stale ответ из кэша `decide` | Кратковременный возврат устаревшего результата в пределах TTL | Revision-aware cache key + короткий TTL | Низкий/средний | +| Stale ответ из кэша `decide` | Кратковременный возврат устаревшего результата в пределах TTL | Revision-aware cache key + инвалидация `flag:{key}` при update default + короткий 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 | Средний | diff --git a/ADR/05-critical-path.md b/ADR/05-critical-path.md index 1491d4d..477795a 100644 --- a/ADR/05-critical-path.md +++ b/ADR/05-critical-path.md @@ -2,7 +2,7 @@ ## Decide (`apps/decision/services.py`) -1. `flag` из cache/DB. +1. `flag` из cache/DB; при `FeatureFlag.default_value` update удаляется `flag:{key}`. 2. `active_experiment` из cache/DB. 3. Формируется cache key результата (`flag`, `subject`, digest атрибутов, ревизии `flag/experiment`). 4. При cache hit возвращается тот же outcome/reason/value с новым `decision_id`. diff --git a/compliance-matrix.md b/compliance-matrix.md index d286e18..daafc08 100644 --- a/compliance-matrix.md +++ b/compliance-matrix.md @@ -11,7 +11,7 @@ | `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, 3.4` | `B2-1` | Возврат не-default без активного эксперимента искажает контроль | `src/backend/apps/decision/services.py`, `src/backend/apps/flags/services.py` | `apps.decision.tests.test_decide.DecideForFlagTest.test_no_active_experiment`, `apps.flags.tests.test_flags.FeatureFlagServiceTest.test_update_default_invalidates_decide_flag_cache` | Флаг с default и без running эксперимента; update `default_value` на том же флаге | подтверждено | | `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 | подтверждено | diff --git a/src/backend/apps/flags/services.py b/src/backend/apps/flags/services.py index 4a86f2f..8498862 100644 --- a/src/backend/apps/flags/services.py +++ b/src/backend/apps/flags/services.py @@ -1,8 +1,13 @@ +from django.core.cache import cache from django.core.exceptions import ValidationError from apps.flags.models import FeatureFlag, FeatureFlagType +def _flag_cache_key(flag_key: str) -> str: + return f"flag:{flag_key}" + + def feature_flag_create( *, key: str, @@ -28,6 +33,7 @@ def feature_flag_create( default_value=default_value, ) flag.save() + cache.delete(_flag_cache_key(flag.key)) return flag @@ -38,4 +44,5 @@ def feature_flag_update_default( ) -> FeatureFlag: flag.default_value = default_value flag.save(update_fields=["default_value", "updated_at"]) + cache.delete(_flag_cache_key(flag.key)) return flag diff --git a/src/backend/apps/flags/tests/test_flags.py b/src/backend/apps/flags/tests/test_flags.py index 201c69e..5e9198f 100644 --- a/src/backend/apps/flags/tests/test_flags.py +++ b/src/backend/apps/flags/tests/test_flags.py @@ -1,6 +1,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from apps.decision.services import decide_for_flag from apps.flags.models import ( FeatureFlagType, validate_value_for_type, @@ -159,6 +160,20 @@ class FeatureFlagServiceTest(TestCase): with self.assertRaises(ValidationError): feature_flag_update_default(flag=flag, default_value="bad") + def test_update_default_invalidates_decide_flag_cache(self) -> None: + flag = feature_flag_create( + key="svc_upd_cache", + name="Update Cache", + value_type=FeatureFlagType.STRING, + default_value="old", + ) + first = decide_for_flag(flag.key, "svc_subj_1", {}) + self.assertEqual(first["value"], "old") + feature_flag_update_default(flag=flag, default_value="new") + second = decide_for_flag(flag.key, "svc_subj_2", {}) + self.assertEqual(second["reason"], "no_active_experiment") + self.assertEqual(second["value"], "new") + class FeatureFlagSelectorTest(TestCase): def test_list_all(self) -> None: