doc(sqs): подробный план доработок P0/P1/P2 для DeepSeek Flash
This commit is contained in:
@@ -0,0 +1,264 @@
|
|||||||
|
# План доработок 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 ./...`.
|
||||||
Reference in New Issue
Block a user