diff --git a/HISTORY/2026-10-10-session-log.md b/HISTORY/2026-10-10-session-log.md index c787777..eaf93a7 100644 --- a/HISTORY/2026-10-10-session-log.md +++ b/HISTORY/2026-10-10-session-log.md @@ -114,6 +114,14 @@ - Примечание: пока один процесс с двумя листенерами (общее in-memory состояние); два процесса — после Шага 3. +### 2.14. Шаг 3 — начало: дизайн + тенанты read-through Redis +- Дизайн: `doc/thinking/step3-state-extraction-design-2026-10-10.md` (варианты A/B/C, рекомендация + C→A, что замерить). +- Код (коммит `779a71d`): Redis-индекс `ssq:tenant_ak` (accessKey → JSON тенанта) + бэкфилл при старте; + auth-middleware читает тенанта из Redis при промахе локальной памяти. +- Нужно для ЛЮБОГО multi-instance варианта; в одном процессе — страховка. +- Проверено: индекс заполнен (`hlen ssq:tenant_ak` = 1), регрессий нет. + ## 3. Грабли (для повторного использования) 1. **`proxy_set_header Host $host` теряет порт** → SigV4 `SignatureDoesNotMatch`. diff --git a/doc/thinking/step3-state-extraction-design-2026-10-10.md b/doc/thinking/step3-state-extraction-design-2026-10-10.md new file mode 100644 index 0000000..a340c21 --- /dev/null +++ b/doc/thinking/step3-state-extraction-design-2026-10-10.md @@ -0,0 +1,85 @@ +# Шаг 3: вынос состояния — дизайн (2026-10-10) + +> Стратегический пункт. Определяет потолок сервиса: пока очередь живёт в памяти одного +> процесса — HA/масштабирования нет. Файл — анализ вариантов и план; перед реализацией +> нужны решения/замеры (см. §5). + +## 1. Проблема (факты) + +- **Источник истины — память**: `models.SyncQueues` (map очередей + индексы tombstone) и + `tenant.TenantStore`. Redis (`app/persistence/redis.go`) — **write-through**: записи + асинхронны (`asyncWrite`), чтение — **только при старте** (`LoadAllQueues`, `LoadAllTenantsRaw`). +- Следствия: + - две реплики разъедутся (каждая со своей map; last-write-wins затирает); + - падение процесса ИЛИ ВМ = полный простой; + - сервис принципиально single-instance → нет HA, нет масштабирования. +- Замеренный ранее риск (P1.1): глобальный лок `SyncQueues` + сетевой Redis-I/O **под локом** + давал таймауты до 3–5с. При выносе в Redis локально-атомарные операции станут сетевыми — + это ключевой вопрос латентности. + +## 2. Варианты + +### A. Redis как источник истины (полный вынос) +- Все операции читают/пишут Redis; память — только кэш (или ничего). +- Атомарность — **Lua-скрипты** (dequeue, visibility, dedup, DLQ) поверх LIST/ZSET/HASH, + либо Redis Streams. +- **Плюс:** несколько инстансов data-plane, HA, потолок снят, «настоящий облачный» SQS. +- **Минус:** переписывание ядра очередей (по сути новый движок); сетевой round-trip на КАЖДЫЙ + SQS-вызов (латентность!); сложность гонок/транзакций/FIFO/dedup/DLQ на Lua. +- **Объём:** крупный. + +### B. Шардирование по тенантам +- Тенанты распределяются по инстансам (consistent hashing); каждый держит СВОИ очереди в памяти. +- **Плюс:** ядро очередей не переписываем; масштабирование по числу тенантов. +- **Минус:** HA нет (шард живёт на одном инстансе — его падение = часть тенантов недоступна); + нужен роутинг/ребаланс (LB или сам сервис); состояние всё так же в памяти. + +### C. Активный/пассивный (failover) +- Один активный инстанс; состояние непрерывно реплицируется в Redis; пассивный подхватывает + при падении (переключение по health/leader-election). +- **Плюс:** HA **без переписывания ядра** (as-is); быстрее всего по срокам. +- **Минус:** горизонтального масштабирования нет (один активный); окно даунтайма при + переключении; надо решить рассинхрон (last-write-wins, потеря «подтверждённых» сообщений + в async-окне). + +## 3. Сравнение + +| | A (Redis = истина) | B (шарды) | C (active/passive) | +|---|---|---|---| +| HA (падение инстанса) | да | частично | да (с окном) | +| Масштабирование (нагрузка) | да | да (по тенантам) | нет | +| Переписывание ядра | **да (крупно)** | нет | нет | +| Латентность на вызов | растёт (сеть) | как сейчас | как сейчас | +| Сроки | большие | средние | малые | + +## 4. Предварительная рекомендация + +Идти **поэтапно**, не прыгая в A сразу: + +1. **Прежде всего — C** (active/passive): даёт HA за малые сроки, без переписывания ядра. + Убирает главную боль «одна ВМ = SPOF». +2. **Потом A** — когда реально упрёмся в потолок нагрузки/числа тенантов (и когда есть + средства на новый движок + замеры латентности). +3. B — рассматривать только если появится требование «много независимых тенантов на разных + инстансах» без общей нагрузки. + +> Обоснование: A — самая дорогая и рискованная; делать её «на всякий случай» рано (сервис +> ещё не под нагрузкой). C снимает SPOF дешевле. + +## 5. Что замерить/решить ПЕРЕД реализацией + +1. **Нагрузка**: сколько SQS-вызовов/с ожидается? Какая латентность допустима? (определяет, + переживёт ли вариант A сетевые round-trip'ы). +2. **Объём состояния**: число очередей × сообщений сейчас и на горизонте — влезает ли в Redis + (память/лимиты), сколько стоит снапшот. +3. **Требование к HA**: окно даунтайма при переключении (для C) — допустимо ли? +4. **Порог переключения** (для C): health-check + leader-election (Redis SETNX/lock). + +## 6. Ближайшие безопасные шаги (независимо от варианта) + +- **Тенанты read-through Redis**: сейчас тенант, созданный control-plane, не виден data-plane + до рестарта. Добавить Redis-индекс `accessKey → tenant` и fallback в auth-middleware — + нужно для ЛЮБОГО multi-instance варианта и снимает текущее ограничение. +- **Метрики/health** для переключения (готово: `/health`, `/metrics`). +- **Разнести процессы data/control** (листенеры уже разделены, коммит `c1520c0`) — после + read-through тенантов возможен полноценный процесс-split.