Files
SQS-service/doc/thinking/plan-for-flash-2026-10-05.md
T

265 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План доработок shared-sqs — для DeepSeek Flash (2026-10-05)
> Исполнитель: кодер (DeepSeek Flash). Источник правил: память + отчёт Opus
> (`opus-report-2026-10-05.md`) + мой анализ (`my-analysis-of-opus-report-2026-10-05.md`).
>
> ⛔ ВАЖНО: прод НЕ используется, никто сервис не юзает. **Обратная
> совместимость и миграции НЕ нужны.** Всё делается корректно «на будущее».
> Кодер должен работать в репозитории `/home/naeel/nubes/SQS_und_IoT/SQS-service`.
---
## Правила работы (обязательные для кодера)
1. **Коммитить почаще** — после каждой логически завершённой правки, отдельным
коммитом с понятным сообщением вида `sqs: <P0.1> ...`. Не копить правки.
2. **Один трек-пункт = один/несколько коммитов.** Не смешивать P0 и P2 в одном коммите.
3. После каждой правки — `go build ./...` и `go test ./...` (зелёные до/после).
4. Не трогать файлы вне перечисленных ниже. Не менять публичные HTTP-контракты
SQS API без явного указания (совместимость с AWS SDK должна сохраняться).
5. Правила платформы (сетевые таймауты/MSS) в этот план НЕ входят — это не код.
---
# ЧАСТЬ 1 — P0: блокеры продакшна (безопасность + durability)
## P0.1 — Убрать детерминированный SecretKey (случайные креды)
**Проблема:** `tenant_store.go` выводит AccessKey/SecretKey из email через SHA256
с захардкоженной солью. Любой, кто знает email и соль (соль в исходнике), вычисляет
чужие креды и получает доступ к очередям через SQS-эндпоинт (JWT не нужен — только SigV4).
**Файлы:** `app/tenant/tenant_store.go`.
**Что сделать:**
- Удалить `tenantIDFromEmail`, `accessKeyFromEmail`, `secretKeyFromEmail`, константу
`secretKeySalt`, и полностью переписать `CreateFromJWT`:
- `tenantID` — `generateTenantID()` (случайный, уже есть);
- `accessKey` — `generateAccessKey()` (случайный, уже есть);
- `secretKey` — `generateSecretKey()` (случайный, уже есть).
- Сохранять связь `email → tenant` в индексе `byEmail` (как сейчас), НО ключи больше
не зависят от email.
- При повторном логине с тем же email — возвращать существующего тенанта
(идемпотентность по `byEmail` сохранить), НЕ пересоздавать и НЕ пересчитывать ключи.
- Убрать логику «legacy-пересчёта» (ветку удаления старой записи): с нуля её не нужно —
если тенант с email найден, просто вернуть его как есть.
- Случайные ключи и так генерируются через `crypto/rand` (`generateAccessKey`,
`generateSecretKey`) — их уже существует, использовать их.
**Критерий готовности:** после логина новый тенант получает AccessKey/SecretKey,
не совпадающие с `SHA256(email)`; повторный логин с тем же email возвращает те же
креды (из Redis/индекса), а не пересоздаёт.
## P0.2 — Усилить доверие к JWT (не доверять неподписанному payload)
**Проблема:** `jwt.go` не проверяет подпись; `PingNubesAPI` принимает токен, если
хоть один стенд вернул не-401/403. Идентичность берётся из незаверенного claim `email`.
**Файлы:** `app/auth/jwt.go`, `app/auth/cache.go`.
**Что сделать (минимально безопасно, без внешнего JWKS):**
- `PingNubesAPI`: принимать токен ТОЛЬКО при явном HTTP 2xx от доверенного эндпоинта
(сейчас логика «любой ответ, кроме 401/403 → OK»). 401/403 → `ErrTokenRejected`;
прочие коды → тоже считаем ошибкой (не принимать).
- Порядок стендов оставить как есть (prod/test/dev), но успех — только по 2xx.
- Дополнительно: если в ответе валидирующего API доступна расшифровка токена
(email/sub), использовать её вместо доверия к payload. Если API возвращает только
статус — оставить claim `email` как есть, но после P0.1 это уже не даёт чужих кредов.
- `cache.go` — оставить TTL 5м/grace 2м (риск отозванного токена приемлем и задокументирован).
**Критерий готовности:** токен, на который все стенды отвечают 500/503/иным не-401-кодом,
больше НЕ принимается; принимается только явный 2xx.
## P0.3 — Durability SendMessage (синхронный персист до 200)
**Проблема:** `SendMessage` отвечает 200, а Redis-запись уходит асинхронно в горутину
(`redis.go` `asyncWrite`). Краш пода в этом окне = потеря подтверждённого сообщения.
**Файлы:** `app/persistence/redis.go`, `app/gosqs/send_message.go`.
**Что сделать:**
- Ввести синхронный вариант записи сообщения (по аналогии с уже существующим
синхронным `DeleteMessagePersist`): `SaveMessageSync(key, msg)` — выполняет `HSet`
с коротким таймаутом и ждёт результат.
- В `SendMessageV1`: после добавления в `queue.Messages` вызывать `SaveMessageSync`
ДО возврата 200. При ошибке Redis — вернуть клиенту ошибку (InternalError) и
откатить append сообщения (не оставлять сообщение в памяти без персиста), либо
залогировать и вернуть ошибку — выбрать стратегию и зафиксировать её в комментарии.
- Оставить `SaveMessage` (async) для некритичных случаев, но SendMessage должен
персистить синхронно.
**Критерий готовности:** краш пода сразу после 200 на SendMessage НЕ приводит к
потере сообщения — оно уже в Redis.
---
# ЧАСТЬ 2 — P1: архитектурная зрелость
## P1.1 — Per-queue мьютекс вместо глобального
**Проблема:** один `SyncQueues.RWMutex` на весь сервис — любой send/receive/tick
берёт единственный Lock; long-poll переобходит его каждые 100 мс; O(N)-скан под локом.
**Файлы:** `app/models/globals.go`, `app/models/models.go`, все gosqs-хендлеры,
`app/gosqs/gosqs.go` (`PeriodicTasks`/`processQueueTick`), `app/persistence/redis.go`.
**Что сделать:**
- Добавить в `Queue` поле `mu sync.RWMutex` (per-queue блокировка).
- `SyncQueues` оставить как `map[string]*Queue` под отдельным коротким
`sync.RWMutex` ТОЛЬКО для доступа к самой map (вставка/удаление/чтение указателя),
а состояние ОЧЕРЕДИ защищать её собственным `mu`.
- Перевести все операции на схему: короткий `SyncQueues.RLock` → взять указатель
`queue` → `SyncQueues.RUnlock` → `queue.mu.Lock()` на время работы с содержимым.
- В `processQueueTick` снять глобальный Lock с обхода: пройти по снапшоту очередей,
каждую блокировать её `mu` отдельно.
- Обновить `persistence`-вызовы: они должны вызываться под `queue.mu`, как сейчас
под глобальным (см. комментарии «ВЫЗЫВАТЬ под Lock»).
**Критерий готовности:** нет ни одной операции, блокирующей все очереди сервиса
на время O(N)-работы; тесты на конкурентный доступ проходят.
## P1.2 — Индексация сообщений (убрать O(N) по слайсу)
**Проблема:** `Messages []SqsMessage` — receive/delete/visibility — O(N); при лимите
~120k сообщений и текущих локах это основной источник latency.
**Файлы:** `app/models/models.go`, `app/gosqs/receive_message.go`,
`app/gosqs/delete_message.go`, `app/gosqs/gosqs.go`.
**Что сделать (инкрементально, без ломки схемы):**
- Добавить в `Queue` индекс `receiptIndex map[string]int` (ReceiptHandle → индекс в
`Messages`) и/или `readyIndex []int`; поддерживать его при каждом receive/delete/таймауте.
- DeleteMessage: по ReceiptHandle — O(1) вместо скана слайса.
- Visibility-tick: по `receiptIndex` определять истёкшие, а не сканировать весь слайс.
- Если полный рефакторинг со слайса на map+heap сложен — сначала версию с индексом,
сохранив слайс, чтобы не ломать Redis-сериализацию (`marshalMessage`).
**Критерий готовности:** DeleteMessage и visibility-перерасчёт не зависят от полного
размера очереди.
## P1.3 — Реализовать MessageRetentionPeriod
**Проблема:** `MessageRetentionPeriod` помечен `// TODO - not used`; сообщения не
истекают, растут до лимита, потом `OverLimit`.
**Файлы:** `app/models/models.go`, `app/gosqs/gosqs.go` (`processQueueTick`),
`app/persistence/redis.go`.
**Что сделать:**
- В `processQueueTick` добавить эвикцию сообщений старше retention: если
`SentTime + MessageRetentionPeriod < now` — удалить сообщение и его Redis-запись (`HDel`).
- Учитывать retention при `IsReadyForReceipt`/выдаче: не выдавать просроченные.
- Дефолт retention уже задан (345600 = 4 дня в `config.go` `applyEnvironmentDefaults`).
**Критерий готовности:** сообщение автоматически исчезает из очереди и Redis по
истечении retention, очереди не забиваются до `OverLimit`.
## P1.4 — Индекс тенант → его очереди
**Проблема:** `countTenantQueues`, `listTenantQueues`, `createTenantQueue` (лимит),
`deleteTenant`, gauge-updater — O(total) скан-по-префиксу по всем очередям.
**Файлы:** `app/gosqs/tenant_helpers.go`, `app/admin/admin.go`.
**Что сделать:**
- Вести индекс `map[accessKey]map[queueName]struct{}` (или в `TenantStore`) для
O(1) подсчёта/списка очередей тенанта.
- `countTenantQueues` → `len(index[accessKey])`.
- Обновлять индекс в `CreateQueue`/`DeleteQueue`/`PurgeQueue`/`DeleteTenant` и при
восстановлении из Redis на старте.
**Критерий готовности:** лимиты/списки очередей не сканируют все очереди сервиса.
## P1.5 — Убрать demo-bypass из прод-сборки
**Проблема:** хардкод demo-кредов (`seed.go`) и публичного UI-bypass-токена
(`authenticateUIDemoToken` в `admin.go`).
**Файлы:** `app/cmd/seed.go`, `app/admin/admin.go`.
**Что сделать:**
- Убрать `authenticateUIDemoToken` и фиксированный UI-токен; UI-доступ — только
через реальную JWT-валидацию.
- Демо-тенант (`SHARED_SQS_SEED_DEMO`) оставить за env-флагом, но креды для демо
генерировать через `CreateFixed` только при `SHARED_SQS_SEED_DEMO=true` (как сейчас),
НЕ хранить захардкоженный `demo-secret-key-shared-sqs-ngcloud-2026` в коде — генерировать
случайным образом и выводить в лог при старте.
**Критерий готовности:** без env-флага `SHARED_SQS_SEED_DEMO` и без явно заданного
admin UI-токена в прод-конфиге бэкдор-путей нет.
---
# ЧАСТЬ 3 — P2: надёжность, корректность, чистка
## P2.1 — BYOC Admin API
**Файлы:** `app/admin/admin.go`, `app/tenant/tenant_store.go`.
- В `createTenantRequest` добавить опц. поля `access_key`, `secret_key`.
- Если оба заданы → `CreateFixed` (валидация мин. длины: access_key ≥8, secret_key ≥16).
- Если одно без другого → 400. Экспортировать/использовать `generateTenantID`.
- Детали в `doc/byoc-credentials.md` (раздел с TODO).
## P2.2 — Чинить сиротство очередей (после отказа от legacy-пересчёта в P0.1 — уже неактуально)
После P0.1 ветка «legacy-пересчёта» удалена — сиротства больше не возникает.
**Действие:** убедиться, что после P0.1 в `CreateFromJWT` не осталось пути, где
тенант меняет AccessKey без переноса очередей. Если креды теперь неизменны на весь
жизненный цикл тенанта — пункт закрыт.
## P2.3 — Constant-time сравнение admin-токена
**Файлы:** `app/admin/admin.go`.
- Заменить `authHeader != expected` на `subtle.ConstantTimeCompare`.
## P2.4 — Единый путь создания/отправки (admin ≠ gosqs)
**Файлы:** `app/admin/admin.go`.
- `createTenantQueue`/`sendMessageToQueue` должны переиспользовать логику gosqs
(создание через общий код, FIFO-детект, проверка размера, MD5, дедуп), а не
дублировать с иными дефолтами.
## P2.5 — Биллинг: воркер/батч вместо горутины на операцию; корректный msg_count
**Файлы:** `app/billing/billing.go`, `app/router/router.go` (`actionHandler`).
- Заменить `go func(){ Insert }` на буфер+воркер (батч-инсерты).
- `RecordUsage` принимать корректный `msg_count` (для SendMessageBatch — число
сообщений в батче), а не всегда 1.
## P2.6 — Защитить/урезать `/metrics`
**Файлы:** `app/router/router.go`.
- Убрать per-tenant данные из публичных метрик ИЛИ закрыть `/metrics` аутентификацией.
- Минимально: убрать лейбл `tenant` с идентификаторами из публичного `/metrics`,
оставить агрегаты без идентификации тенантов.
## P2.7 — Чистка легаси
**Файлы:** `app/conf/goaws.yaml`, `app/conf/config.go`, `app/models/*`.
- Убрать SNS Topics/Subscriptions из `goaws.yaml`.
- Убрать мёртвый путь загрузки yaml-очередей без tenant-префикса в `config.go`.
- Убрать SNS-поля из моделей.
- Не трогать `deployments/**` (вне scope кода; при желании — отдельно).
## P2.8 — Redis reconnect/health
**Файлы:** `app/persistence/redis.go`.
- После обрыва соединения — попытка переподключения / пометка нездоровья, а не
молчаливые ошибки в лог. Мин.: пересоздание `Client` при повторном ping-фейле.
---
# Порядок выполнения (коммитить почаще, по пунктам)
1. P0.1 → коммит
2. P0.3 → коммит (durability, пока не зависит от P1.1)
3. P0.2 → коммит
4. P1.1 → коммит (самый крупный, тестировать пристально)
5. P1.2 → коммит
6. P1.3 → коммит
7. P1.4 → коммит
8. P1.5 → коммит
9. P2.1 … P2.8 → по одному коммиту каждый
После каждого коммита — `go build ./... && go test ./...`.