diff --git a/doc/thinking/state-extraction-and-ha-2026-10-10.md b/doc/thinking/state-extraction-and-ha-2026-10-10.md
new file mode 100644
index 0000000..fde9681
--- /dev/null
+++ b/doc/thinking/state-extraction-and-ha-2026-10-10.md
@@ -0,0 +1,290 @@
+# Шаг 3 — HA (active/passive) и путь к выносу состояния
+
+**Дата:** 2026-10-10
+**Статус:** реализовано (код в `main`, задеплоено на ВМ 213, **флаг выключен** — поведение прежнее)
+**Связанные документы:**
+- `doc/thinking/step3-state-extraction-design-2026-10-10.md` — разбор вариантов A/B/C
+- `doc/thinking/vm-public-entrypoint-2026-10-10.md` — публичная точка входа, TLS, Шаг 2
+- `HISTORY/2026-10-10-session-log.md` — хронология сессии
+
+---
+
+## 1. Какую проблему решаем
+
+Сейчас прод — **одна ВМ** (213, `5.172.178.213`) и **один процесс** `shared-sqs`. Внутри процесса
+живёт **источник правды** — in-memory `models.SyncQueues` (все очереди и сообщения). Redis —
+write-through кэш и восстановление при рестарте.
+
+Отсюда три боли:
+
+| # | Боль | Следствие |
+|---|---|---|
+| 1 | ВМ/процесс упал → сервиса нет | простой до ручного/автоматического рестарта (сейчас `Restart=always`, но это только рестарт того же инстанса) |
+| 2 | Redis на той же ВМ | падение ВМ уносит и состояние, и кэш — восстановление «с нуля по последнему AOF» |
+| 3 | Обновление версии = простой | нужно перезапустить единственный инстанс |
+
+Полноценное решение (вариант A из дизайна) — сделать **Redis/внешнее хранилище источником правды**
+и превратить инстансы в stateless. Это большой объём: атомарные операции над очередями (длинные
+скрипты/Lua, видимость, DLQ, batch, дедупликация), переписывание `models.SyncQueues` и всего, что
+на него опирается (`gosqs.PeriodicTasks`, метрики, админ-peek).
+
+Задача была: **снять боль №1 и №3 дёшево, не переписывая ядро**, и подробно задокументировать, как
+потом перейти к варианту A.
+
+---
+
+## 2. Почему выбран вариант C (active/passive), а не A
+
+| Вариант | Что даёт | Цена | Вердикт |
+|---|---|---|---|
+| **A.** Redis — источник правды | настоящий horizontal scale-out | переписать ядро очередей (Lua/транзакции), недели работы, риск регрессий в самой опасной части (доставка/видимость/DLQ) | **потом**, когда упрёмся в нагрузку |
+| **B.** Шардирование по тенантам | масштаб без переписывания ядра | сложная маршрутизация, каждый шард всё равно SPOF, эксплуатация N наборов | не берём |
+| **C.** active/passive + leader lock | failover, рестарт/деплой без простоя (после ручного чекпоинта) | не даёт scale-out; пассивный инстанс не принимает трафик | **берём сейчас** |
+
+Ключевой довод: **в 100% случаев причина простоя — недоступность одной ВМ**, а не нехватка CPU.
+Вариант C закрывает именно это, стоит ~200 строк кода и не трогает ядро очередей.
+
+Дополнительный довод: вариант C **не мешает** потом сделать A. Наоборот — он вводит понятие
+«активный инстанс», которое в варианте A просто исчезнет (все инстансы станут равными).
+
+---
+
+## 3. Как это работает
+
+### 3.1. Схема
+
+```mermaid
+flowchart LR
+ LB["nginx / LB
(проверяет /ready)"]
+ A["ВМ A: shared-sqs
лидер → /ready 200"]
+ P["ВМ B: shared-sqs
пассив → /ready 503"]
+ R[("Redis
ssq:leader, ssq:queues, ssq:tenants")]
+
+ LB -->|трафик только на ready| A
+ LB -.->|503 — трафик не идёт| P
+ A <-->|SETNX / PEXPIRE heartbeat| R
+ P <-->|пытается взять лок| R
+```
+
+### 3.2. Механика лока
+
+- Ключ: **`ssq:leader`**. Значение — **`-`** (уникальный id инстанса,
+ `app/cmd/goaws.go:180`).
+- **Взять лок:** `SET ssq:leader NX PX ` — атомарно; `app/persistence/redis.go:666`.
+- **Продлить:** Lua-скрипт — `PEXPIRE` **только если** значение всё ещё наше
+ (`GET == ARGV[1]`), `app/persistence/redis.go:676-682`, вызов `:692`.
+- **Отпустить:** Lua-скрипт — `DEL` **только если** значение наше, `app/persistence/redis.go:684-690`,
+ вызов `:706`. Вызывается при graceful shutdown (`app/cmd/goaws.go:254`).
+- **Цикл:** `app/leader/leader.go:92-124`. Каждые `interval` (по умолчанию **3 с**):
+ - если я лидер → `RefreshLock`; не подтвердился → теряю лидерство (падаю в passive);
+ - если я пассивен → `TryAcquireLock`; получил → становлюсь лидером.
+- **TTL лока:** по умолчанию **10 с**. Правило: `ttl > 3 × interval`, чтобы сетевые задержки и
+ короткие всплески не приводили к ложной потере лидерства.
+
+### 3.3. Готовность (`/ready`)
+
+- `GET /ready` (`app/router/router.go:220-229`) отдаёт **200** если `ReadyFunc` не задана **или**
+ вернула `true`, иначе **503**.
+- `ReadyFunc` — пакетная переменная (`app/router/router.go:216`), задаётся в `main` только при
+ включённом leader-election: `router.ReadyFunc = elector.IsLeader` (`app/cmd/goaws.go:182`).
+- `/ready` зарегистрирован **и** в data-plane (`router.go:48`), и в control-plane (`router.go:90`).
+- `/health` (liveness) — **всегда 200**, независимо от лидерства. Это сознательно: процесс жив,
+ его не надо перезапускать, просто он не должен получать трафик.
+
+### 3.4. Включение/выключение
+
+| Способ | Значение | Поведение |
+|---|---|---|
+| по умолчанию | — | `--leader-election=false` → `ReadyFunc == nil` → **всегда ready** (одиночный режим, как раньше) |
+| флаг | `--leader-election` | включается |
+| env | `SHARED_SQS_LEADER_ELECTION=true` | включается (удобно для systemd `EnvironmentFile`) |
+| тюнинг | `--leader-ttl` (10s), `--leader-interval` (3s) | значения лока |
+
+Код: `app/cmd/goaws.go:47-49` (флаги), `:159-187` (инициализация).
+
+### 3.5. Поведение без Redis
+
+Если `REDIS_ADDR` не задан (`persistence.Client == nil`), `TryAcquireLock`/`RefreshLock` возвращают
+`true` — инстанс **всегда лидер**. Логика: без Redis мы единственные, делить лок не с кем.
+
+⚠️ Если Redis **был, но отвалился после старта** — `TryAcquireLock` вернёт ошибку → инстанс
+станет passive → `/ready` 503. При одном инстансе это означает «нет healthy-бэкенда». Поэтому
+leader-election **нельзя включать на единственном инстансе, если Redis может исчезнуть** —
+см. §6.
+
+---
+
+## 4. Что именно проверено (замеры, не гипотезы)
+
+Проверка выполнена на ВМ 213 двумя временными инстансами нового бинарника (порты 4200 и 4210),
+Redis — локальный:
+
+```bash
+# 1) две копии с --leader-election
+./shared-sqs --port=4200 --control-port=4201 --leader-election &
+./shared-sqs --port=4210 --control-port=4211 --leader-election &
+curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4200/ready # 200 (лидер)
+curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4210/ready # 503 (пассив)
+redis-cli get ssq:leader # naeel-vm-21935
+
+# 2) имитируем падение лидера БЕЗ graceful shutdown
+kill -9 21935
+sleep 12
+curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4210/ready # 200
+redis-cli get ssq:leader # naeel-vm-21957
+```
+
+| Что мерили | Результат |
+|---|---|
+| Две копии одновременно | `200` у первой, `503` у второй — split-brain нет |
+| Значение ключа | `-` того инстанса, который отдал 200 |
+| `kill -9` лидера → перехват | пассивный стал лидером, `/ready` → `200`; перехват занял **~12 с** (теоретический максимум `ttl + interval` = 13 с) |
+| Логи | `leader: инстанс стал ЛИДЕРОМ (активный)` (16:29:12), `leader: потерян лидерский лок (пассивный)` |
+| Регрессия прод-API | `aws sqs list-queues / send-message / receive-message` через `https://sqs.sinefo.ru` — OK |
+| Прод после деплоя | `systemctl is-active shared-sqs` = active, `/health` 200, `/ready` 200 |
+
+Временные инстансы остановлены, ключ `ssq:leader` удалён, прод работает с **выключенным**
+leader-election (`/ready` 200 безусловно).
+
+---
+
+## 5. Как включить на проде (когда появится вторая ВМ)
+
+### 5.1. Предусловия
+
+1. Вторая ВМ, тот же бинарник, тот же `sqs.sinefo.ru`.
+2. **Общий Redis**, доступный обеим ВМ (сейчас на 213 — только локальный!).
+ Без общего Redis лок бессмысленен: каждый увидит свой.
+3. LB (nginx `upstream` + `proxy_next_upstream`, или внешний LB) с проверкой `GET /ready`.
+
+### 5.2. Шаги
+
+```bash
+# /etc/shared-sqs.env на ОБЕИХ ВМ
+SHARED_SQS_LEADER_ELECTION=true
+SHARED_SQS_ADMIN_TOKEN=<общий>
+REDIS_ADDR=<общий-redis>:6379
+REDIS_PASSWORD=<...>
+# systemd: EnvironmentFile=/etc/shared-sqs.env, ExecStart=... (флаги не нужны — env достаточно)
+sudo systemctl restart shared-sqs
+```
+
+В nginx upstream — health-check по `/ready`:
+
+```nginx
+upstream sqs_backend {
+ server 10.10.102.21:4100 max_fails=1 fail_timeout=5s; # ВМ A
+ server <ВМ-B-IP>:4100 max_fails=1 fail_timeout=5s; # ВМ B
+}
+# плюс активная проверка /ready (nginx OSS: passive-режим через max_fails,
+# nginx plus / внешний LB: active health check на GET /ready)
+```
+
+Проверка после включения — **обязательно** (иначе будет failover «в никуда»):
+
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" https://sqs.sinefo.ru/ready # 200 ровно у одного
+# затем жёстко убить лидера и убедиться, что трафик поехал на второй за ≤ ttl+interval
+```
+
+### 5.3. Порядок действий при деплое новой версии
+
+1. Обновить бинарник на **пассивной** ВМ, перезапустить — `/ready` остаётся 503, трафика нет.
+2. Дождаться, пока пассивная станет лидером нельзя «по кнопке» — переключение идёт только через
+ TTL. Поэтому: остановить лидера (`systemctl stop`) → лок освобождается сразу
+ (`ReleaseLock` в `elector.Stop()`) → пассивный берёт лок на следующем тике (≤ 3 с).
+3. Обновить бинарник на бывшей лидерской ВМ → она встанет пассивной.
+
+Итого простой при деплое — **единицы секунд** вместо «пока перезапускается».
+
+---
+
+## 6. Ограничения — важно понимать до включения
+
+1. 🔴 **Состояние НЕ шарится между инстансами.** `models.SyncQueues` — in-memory. Пассивный
+ инстанс загрузил состояние из Redis **на своём старте** и с тех пор его не обновляет.
+ → Пока активно ограничение: **пассивный не должен получать SQS-трафик.** Именно для этого
+ `/ready` = 503: LB обязан его уважать. Если LB игнорирует `/ready`, клиенты начнут получать
+ «пустые» очереди с пассивного. Это главный риск варианта C.
+2. 🟠 **Свежесть состояния после failover.** Новый лидер видит состояние на момент своего старта,
+ а не на момент падения предыдущего. Write-through (Redis) спасает: очереди и сообщения,
+ записанные лидером, лежат в Redis. А **изменения, не персистентные**, теряются: в первую
+ очередь `SaveQueue` (метаданные очереди) — асинхронная запись; см. открытый пункт в
+ `HISTORY/2026-10-10-session-log.md` §6. Плюс любой инстанс можно перезапустить, чтобы
+ подтянуть актуальное состояние из Redis.
+3. 🟠 **Простой = время перехвата.** `kill -9` → до `ttl + interval` (≈13 с по умолчанию).
+ Уменьшать TTL рискованно (ложные потери лидерства при сетевых задержках); лучше иметь
+ failover на уровне LB + второй инстанс, уже прогретый.
+4. 🟠 **Redis — общий SPOF.** Если Redis недоступен, лидер теряет лок → оба инстанса отдают 503.
+ То есть вариант C переносит зависимость, но не устраняет её. Для продакшена Redis должен быть
+ отдельным от SQS-ВМ и с репликацией.
+5. 🟡 **Split-brain теоретически возможен** при сетевом разделении, когда старый лидер не может
+ продлить лок (считает себя пассивным), а Redis тем же временем отдаёт лок новому — это
+ нормально (старый сам сдаётся). Обратный случай — только при «залипшем» Redis (например,
+ пауза процесса Redis > TTL). Практический риск низкий, но именно поэтому LB **должен**
+ проверять `/ready`, а не полагаться на то, что трафик не пойдёт куда не надо.
+6. 🟡 **Control-plane (`4101`) не участвует.** Он остаётся локальным на каждой ВМ; админка на
+ пассивной покажет своё (устаревшее) состояние.
+
+---
+
+## 7. Чего сознательно НЕ сделано
+
+- Нет fencing/lease-токенов для операций записи в Redis (нужно только начиная с варианта A).
+- Нет авто-переключения DNS/nginx (ручное: LB + health-check).
+- Нет эндпоинта `/leader` с деталями (кто лидер, когда взят лок) — при необходимости добавить
+ 3 строки в control-plane.
+- Нет репликации Redis в terraform/скриптах — платформенная задача (NUBES/девопсы).
+
+---
+
+## 8. Путь к варианту A (когда менять)
+
+**Триггеры, после которых вариант C перестаёт устраивать:**
+- пассивный инстанс простаивает, а нагрузка уже выше возможностей одной ВМ (CPU/память/сеть);
+- нужны **несколько активных** инстансов (scale-out, а не только failover);
+- требуется zero-downtime деплой без ручного переключения.
+
+**Куда двигаться (по шагам, каждый самостоятелен):**
+
+1. **Убрать асинхронность записи метаданных** — `SaveQueue` синхронно (как `SaveMessagesSync`).
+ Мелкая правка, снимает риск №2 из §6. *Делать первым — дешево и полезно независимо от A.*
+2. **Вынести `models.SyncQueues` за интерфейс** (например `QueueStore`), оставив текущую
+ in-memory реализацию. Поведение не меняется, но появляется точка подмены.
+3. **Атомарные операции очереди в Redis** — приёмка/видимость/удаление/дедуп через
+ Lua-скрипты (или Redis Streams). Это основная часть работы. Тесты — на существующих
+ `app/gosqs/*_test.go` + `tests/load_test.py`.
+4. **Убрать `PeriodicTasks` из процесса** — вынести в отдельный «sweeper» (тоже через лок,
+ чтобы не дублировался на всех инстансах).
+5. **Снять лидер-лок** — все инстансы становятся равными, `/ready` всегда 200, LB просто
+ round-robin. Код `app/leader` при этом удаляется (он был нужен только для C).
+
+**Что замерить перед переходом:**
+- число операций/с и p99 по SQS-API на проде (`/metrics` уже есть);
+- сколько сейчас тормозит Redis при write-through (latency `HGETALL`/`HSET`);
+- объём `ssq:msg:*` (память Redis) при текущем темпе — там сейчас весь бэклог очередей.
+
+---
+
+## 9. Откат (выключить HA)
+
+```bash
+# на каждой ВМ
+sudo sed -i '/SHARED_SQS_LEADER_ELECTION/d' /etc/shared-sqs.env
+sudo systemctl restart shared-sqs
+curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4100/ready # 200 (безусловно)
+redis-cli del ssq:leader # удалить осиротевший лок
+```
+
+Код при этом остаётся — просто `ReadyFunc == nil`. Удалять пакет `app/leader` не обязательно.
+
+---
+
+## 10. Ссылки на код
+
+| Файл | Что |
+|---|---|
+| `app/leader/leader.go` | `Elector`: `New` (39), `Start` (50), `IsLeader` (56), `Stop` (63), `loop` (92), `step` (106) |
+| `app/persistence/redis.go` | `TryAcquireLock` (666), `refreshLockScript` (676), `releaseLockScript` (684), `RefreshLock` (692), `ReleaseLock` (706) |
+| `app/router/router.go` | `/ready` в data-plane (48), `/ready` в control-plane (90), `ReadyFunc` (216), `readyHandler` (220) |
+| `app/cmd/goaws.go` | флаги (47-49), инициализация и `ReadyFunc` (159-187), `elector.Stop()` (254) |