Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4ebb3a7a | ||
|
|
716f931544 | ||
|
|
7860c7c3f2 | ||
|
|
4e648f690e | ||
|
|
b864cab5be | ||
|
|
f99f49bd8e | ||
|
|
fa47026400 | ||
|
|
a08d58cba1 | ||
|
|
5284a56f7a | ||
|
|
120a44cb6a | ||
|
|
e8e0879914 | ||
|
|
582ad4a832 | ||
|
|
0512ea2389 | ||
|
|
dd9acb3705 | ||
|
|
e720f1ffcb | ||
|
|
b179ec6de7 | ||
|
|
9e0ad2c0a4 | ||
|
|
09fe0f7e66 | ||
|
|
24dbdaa766 | ||
|
|
84ef8f1fc9 | ||
|
|
ccd070200a | ||
|
|
9f1242aca3 | ||
|
|
22fa2b162a | ||
|
|
bb6dbfd349 | ||
|
|
4e2b462379 | ||
|
|
e21abb8f13 | ||
|
|
521e3d85d0 | ||
|
|
ea7b539d19 | ||
|
|
073977286f | ||
|
|
27fa2e6ae3 | ||
|
|
d7b3c653e0 | ||
|
|
607aebb8ef | ||
|
|
a341508b6d | ||
|
|
064ed6e5d7 | ||
|
|
8e22bc4b9b | ||
|
|
1862f058be | ||
|
|
a26e6da8b7 | ||
|
|
a66b908689 | ||
|
|
c4cc3c80ea | ||
|
|
f7fe2f26e5 | ||
|
|
cf369736a0 | ||
|
|
c011099224 | ||
|
|
f0e7cc905d | ||
|
|
ee5503f9ac | ||
|
|
6800d1abf3 | ||
|
|
7a2a7e1610 | ||
|
|
3b0815155a | ||
|
|
39b162439f | ||
|
|
b99905469d | ||
|
|
d8bdea75f3 | ||
|
|
a1d72b61d4 | ||
|
|
b947f1d467 | ||
|
|
572c617f44 | ||
|
|
33a0adc9be | ||
|
|
2e1c594892 | ||
|
|
f96649b14e | ||
|
|
529d208576 | ||
|
|
ad14fd2f7e | ||
|
|
c5b2c11f5d | ||
|
|
e735563a04 | ||
|
|
31840b9756 | ||
|
|
d53aadecfa | ||
|
|
8988293628 | ||
|
|
fd8ffc329d | ||
|
|
ed17c6cbd0 | ||
|
|
3a52960d5b | ||
|
|
fb833ef01c | ||
|
|
07eace3c6b | ||
|
|
8c39bc5346 | ||
|
|
b7f79b388f | ||
|
|
2aa9a656a7 | ||
|
|
876971eea8 | ||
|
|
424ec32a6a | ||
|
|
db41878951 | ||
|
|
5642b893a8 | ||
|
|
ac06572e25 | ||
|
|
12b25e393d | ||
|
|
c19a056de3 | ||
|
|
928a3fb85d | ||
|
|
6e96a4d007 | ||
|
|
9528441c97 | ||
|
|
e8ccd8bae1 | ||
|
|
60b15fdb1c | ||
|
|
7897fda9e7 | ||
|
|
96f6d64184 | ||
|
|
6d451c33d1 | ||
|
|
5c2180d40e | ||
|
|
cd7f5a7d5a | ||
|
|
5f6c0a5bf7 | ||
|
|
b91f21a50d | ||
|
|
bd63c04ea3 | ||
|
|
7460f59432 | ||
|
|
9950271259 | ||
|
|
904d34904c | ||
|
|
2d47e86425 | ||
|
|
cc20282ab0 | ||
|
|
28f8527f8b | ||
|
|
9f69262b8c | ||
|
|
c66ac28766 | ||
|
|
33735b0deb | ||
|
|
dcafb1ad71 |
@@ -0,0 +1,96 @@
|
||||
# HANDOFF для нового чата — shared-sqs (состояние на 2026-08-16)
|
||||
|
||||
> Этот файл — резюме длинной сессии 13–16.08.2026. Новый чат: прочитать
|
||||
> этот файл + HISTORY/2026-08-14-session-log.md + doc/thinking/.
|
||||
|
||||
## 1. Что за проект
|
||||
|
||||
`shared-sqs` — SQS-совместимая очередь (AWS API), Go 1.23, multi-tenant,
|
||||
форк GoAWS. Живёт на платформе Nubes (managed Kubernetes, realm iot-naeel).
|
||||
Локально: `/home/naeel/nubes/SQS-service`, зеркало на ВМ:
|
||||
`naeel@5.172.178.213:~/terra/SQS-service` (ssh-ключ `~/.ssh/naeel_vm_id_ed25519`).
|
||||
|
||||
**⛔ ГРАНИЦЫ ПРОЕКТА (записано в память):** работать ТОЛЬКО с локальной папкой
|
||||
и её зеркалом на ВМ. Никуда больше без прямого указания пути.
|
||||
|
||||
## 2. Статус сервиса
|
||||
|
||||
- Прод-версия **v0.1.35** задеплоена по digest (Docker Hub `naeel/shared-sqs`).
|
||||
- Все тесты зелёные: api_test 22/22, sdk_test 15/15, fifo_dlq_probe PASS.
|
||||
- План Соннета выполнен: P0.1, P0.2, P1.4, P2.5, P2.6, P3.7, P3.8 — все PASS.
|
||||
- Нагрузка 168 591 операция — 0 сервисных ошибок.
|
||||
- Память стабильна 2ч (VmRSS 16588→15648 kB, Threads 13).
|
||||
|
||||
## 3. Главная открытая проблема — ПЛАТФОРМА, не код
|
||||
|
||||
1. **Таймауты каждые 31–33с** на внешнем пути (интернет→шлюз Nubes): ~5.5%
|
||||
запросов не получают ответ 30с. Доказано серверным tcpdump (8 «дыр» по
|
||||
29.5с: запросы не доходят до пода), nginx чист (max 68мс, 499=0), cilium
|
||||
0 дропов, port-forward 24977 раундов 0 сбоев. Таймер — на ВНЕШНЕМ шлюзе,
|
||||
вне кластера.
|
||||
2. **MSS=1448** анонсируется шлюзом при underlay MTU 1450 → большие тела
|
||||
(>~1400 байт) виснут ~51с (PMTUD сломан).
|
||||
3. Готов тикет: `doc/thinking/nubes-ticket.md` — ОТПРАВИТЬ в поддержку Nubes.
|
||||
|
||||
## 4. Что мы починили в кластере (16.08, по команде пользователя)
|
||||
|
||||
- kube-vip: `rollout restart ds shturval-vip` — ушли ошибки leaderelection в
|
||||
удалённый namespace `0a42bef3-...` (хвост июльского сервиса пользователя).
|
||||
- provider LB: добавлен `cidr-global: 185.247.187.151/32` в ConfigMap
|
||||
`kubevip` в **kube-system** (provider читает именно оттуда,
|
||||
env KUBEVIP_NAMESPACE=kube-system). «Ensured load balancer» — OK.
|
||||
- После починки таймауты НЕ ушли (тест: 19 сбоев, период 31.3–33.5с) —
|
||||
подтверждение, что таймер на внешнем шлюзе.
|
||||
|
||||
## 5. Сравнение с Yandex YMQ (очередь `newsqs`)
|
||||
|
||||
- Наш p50: 6–8мс (с ВМ) / 89–90мс (с локали). YMQ: 60–62мс / 138–150мс.
|
||||
- YMQ стабилен (1 ошибка на 959), у нас — таймауты платформы (см. п.3).
|
||||
- YMQ-доступ восстановлен: SA `newsqs` (роль ymq.admin), креды в
|
||||
`secrets/yandex_ymq_newsqs.txt` (имена переменных AWS_ACCESS_KEY_ID/
|
||||
AWS_SECRET_ACCESS_KEY). Очередь: URL в HISTORY.
|
||||
- Повторный бенчмарк — ПОСЛЕ фикса шлюза Nubes.
|
||||
|
||||
## 6. Ключевые доступы
|
||||
|
||||
- Креды shared-SQS (тенант t-fec713a719ad0b33):
|
||||
AK `SSAK-fec713a719ad0b33c91fa54a`,
|
||||
SK `e898ed410a20ff166f51a52ba39a2954fb87e2da200b1648c3975772bfe9e2b4`.
|
||||
- Внешний endpoint: `https://sqs.containerk8s.dev.nubes.ru` (регион us-east-1).
|
||||
- Внутренний (из подов кластера): сервис `containerk8s` в ns
|
||||
`f1ffb134-7d16-45bd-8bef-69f6ec8ab33c`, порт 4100.
|
||||
- Gitea: `gitea.services.ngcloud.ru/Nail/SQS-service` (токен — в secrets/gitea.txt).
|
||||
- kubeconfig на ВМ протухает ~раз в сутки (~26ч) — обновлять через консоль
|
||||
Nubes (пользователь копирует в буфер, на ВМ: `xclip -selection clipboard -o
|
||||
> ~/.kube/config`).
|
||||
- ⚠️ boto3/aws CLI: ВСЕГДА передавать креды явно (env терминала копит чужие
|
||||
AWS_ACCESS_KEY_ID — был ложный 403 с кредами Яндекса).
|
||||
|
||||
## 7. Хвосты / TODO
|
||||
|
||||
1. ~~Отправить тикет Nubes (файл готов).~~ **ОТМЕНЕНО 2026-10-08:** это болезнь
|
||||
платформы; в соседнем проекте drhider ту же проблему не «починили», а обошли
|
||||
(внешний буфер + исходящий pull). Разбор: `doc/thinking/nubes-large-body-workaround-findings-2026-10-08.md`,
|
||||
применимость к SQS: `doc/thinking/nubes-workaround-applicability-to-sqs-2026-10-08.md`.
|
||||
Тикет оставлен только как история.
|
||||
2. Удалить дубликат `secrets/yandex_ymq_fork8s.txt` (одинаков с newsqs.txt) —
|
||||
ждёт команды пользователя.
|
||||
3. Повторный 30-мин бенчмарк vs YMQ и обновление BENCHMARK-COMPARISON — только
|
||||
после того, как внешний путь перестанет рваться (обходной прокси, см. п.1),
|
||||
либо как есть, с оговоркой про таймауты шлюза.
|
||||
4. Эхо-сервис (Фаза 3 планов) не понадобился — доказательств хватило.
|
||||
|
||||
## 8. Тестовая инфраструктура (tests/)
|
||||
|
||||
- `run_full_regression.sh` — полная регрессия (api_test + sdk_test + fifo_dlq).
|
||||
- `long_compare_local.py` — 30-мин сравнение с YMQ с таймстемпами.
|
||||
- `compare_ymq_vs_shared.py` — короткое сравнение.
|
||||
- `tcp_mtu_probe.py` — бинарный поиск MTU-порога (порт 443! 32391 снаружи закрыт).
|
||||
- Планы 4 агентов: doc/thinking/nubes-network-bug-plan*.md.
|
||||
|
||||
## 9. Правила пользователя (обязательные)
|
||||
|
||||
- Без «делай» — ничего не менять, только отвечать/смотреть.
|
||||
- Каждый шаг и результат — в HISTORY сразу; коммит после правок.
|
||||
- Команды только с таймаутами. Никаких оценок — считать/мерить.
|
||||
- Часовой пояс GMT+03. sed запрещён (только редакторы файлов).
|
||||
@@ -0,0 +1,151 @@
|
||||
# История правок shared-sqs — 2026-10-07 (P1.2 + фикс утечки блокировки)
|
||||
|
||||
> Запись по требованию «документировать всё, включая собственные ошибки».
|
||||
> Предыдущие записи: `doc/thinking/*-2026-10-0[56].md`, `HISTORY/2026-08-1*-session-log.md`.
|
||||
|
||||
## 1. Задача
|
||||
|
||||
Пользователь: «доделать ПОЛНОСТЬЮ и протестировать SQS». Незакрытым по плану
|
||||
`plan-for-flash-2026-10-05.md` оставался единственный пункт — **P1.2
|
||||
(индексация сообщений)**, отложенный 2026-10-06 с обоснованием.
|
||||
|
||||
## 2. Что сделано
|
||||
|
||||
### 2.1. Дизайн (коммит `b58e958`)
|
||||
|
||||
`doc/thinking/p1.2-message-index-design-2026-10-07.md`.
|
||||
|
||||
Ключевое решение: **tombstone + индексы + амортизированная компакция**.
|
||||
Удаление не сдвигает срез (O(1)), поэтому индексы `receipt→index` и `uuid→index`
|
||||
остаются валидными. Физическая чистка — `Compact`/`CompactIfNeeded` в `processQueueTick`.
|
||||
Redis-схема не меняется (сообщения и так лежат в per-message HASH).
|
||||
|
||||
### 2.2. Код (коммит `22e9bc8`)
|
||||
|
||||
- `models.Queue`: `deadIdx`, `receiptIdx`, `uuidIdx`, `liveCount`, `deadCount` (все `json:"-"`)
|
||||
+ примитивы: `AppendMessage`, `RemoveIndex`, `Compact`, `CompactIfNeeded`, `Reset`,
|
||||
`EnsureIndexes`/`RebuildIndexes`, `IndexOfReceipt`, `IndexOfUUID`, `MarkInFlight`,
|
||||
`ClearInFlight`, `InFlightIndexes`, `IsDead`, `LiveCount`.
|
||||
- `DeleteMessage` / `ChangeMessageVisibility(±Batch)` — поиск по индексу вместо скана среза;
|
||||
удаление без сдвига остатка.
|
||||
- `DeleteMessageBatch` — tombstone вместо полной пересборки среза.
|
||||
- `processQueueTick` — visibility/DLQ по индексу in-flight (O(in-flight)), retention — через
|
||||
tombstone, в конце — `CompactIfNeeded`.
|
||||
- send-пути — `AppendMessage`/`LiveCount`; admin/metrics/persistence — учёт живых сообщений;
|
||||
`LoadAllQueues` — `RebuildIndexes` после загрузки.
|
||||
|
||||
### 2.3. Тесты (коммиты `e05af04`, `5b3fb51`)
|
||||
|
||||
- `app/models/message_index_test.go` — 6 тестов примитивов (tombstone, компакция, индексы, Reset, порог).
|
||||
- `app/gosqs/delete_middle_test.go` — удаление СЕРЕДИНЫ очереди через API: остальные не теряются,
|
||||
удалённое не возвращается.
|
||||
- `app/gosqs/tenant_flow_test.go` — существующие тесты переведены на `LiveCount()`; добавлен
|
||||
`TestReceiveEmptyQueue_DoesNotLeakLock`.
|
||||
|
||||
## 3. НАЙДЕННЫЙ ДЕФЕКТ (моя регрессия) — утечка per-queue блокировки
|
||||
|
||||
### 3.1. Проявление
|
||||
|
||||
Стресс (2000 сообщений × циклы): `ReadTimeout`, всплеск 1.9 с на `delete_batch`,
|
||||
дальше — зависание сервиса (health при этом отвечает).
|
||||
|
||||
### 3.2. Диагностика (факты, не догадки)
|
||||
|
||||
- Снят стек горутин по `SIGQUIT`: десятки горутин в `sync.RWMutex.RLock`
|
||||
(`models.go:101 Queue.RLockMu`), включая `metrics.updateGauges` (ждала 15 минут)
|
||||
и HTTP-хендлеры `GetQueueAttributesV1`.
|
||||
- Аудит всех `GetLocked`/`GetRLocked`: дефект ровно один.
|
||||
- `receive_message.go`:
|
||||
```go
|
||||
queue := models.SyncQueues.GetLocked(key) // Lock взят
|
||||
if queue == nil || queue.LiveCount() == 0 {
|
||||
return nil, false // ← возврат БЕЗ Unlock
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
```
|
||||
|
||||
### 3.3. Причина и почему проявилось именно сейчас
|
||||
|
||||
Возврат до `defer` оставлял `queue.mu` захваченным навсегда (в Go мьютекс не
|
||||
освобождается при выходе из функции). Прежнее условие `len(queue.Messages) == 0`
|
||||
срабатывало только на полностью пустой очереди; после перехода на tombstone
|
||||
появился новый случай `LiveCount()==0` при `len(Messages)>0` (все сообщения удалены,
|
||||
компакция ещё не прошла) — типовой для последовательных циклов «залить → выгрести».
|
||||
|
||||
### 3.4. Фикс
|
||||
|
||||
`defer queue.UnlockMu()` — до любой проверки/возврата. Регрессионный тест
|
||||
`TestReceiveEmptyQueue_DoesNotLeakLock` проверен на дефекте (падает за 2 с)
|
||||
и на фиксе (зелёный).
|
||||
|
||||
## 4. Результаты проверок (после фикса)
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| `go build ./...`, `go vet ./...` | OK |
|
||||
| `go test -race ./app/...` | зелёные |
|
||||
| `tests/api_test.sh` (aws CLI, локальный сервис) | PASS=22 FAIL=0 |
|
||||
| `tests/sdk_test.py` (boto3 1.43.108) | PASS=15 FAIL=0 |
|
||||
| `tests/fifo_dlq_probe.py` | fifo_order / fifo_dedup / dlq = PASS |
|
||||
| Стресс 3×2000 (send-batch → receive → delete-batch) | send ≈0.6 с, drain ≈1.2 с на цикл, `left=0/0`, вызовов >0.5 с нет |
|
||||
|
||||
## 5. Инфраструктурные заметки (для повторного прогона)
|
||||
|
||||
- Локальный сервис: `SHARED_SQS_SEED_DEMO=true /tmp/shared-sqs --admin-token=... --port=4100`
|
||||
(memory-only без `REDIS_ADDR`; креды демо-тенанта печатаются в лог при старте).
|
||||
- `boto3` в системе НЕ установлен (PEP 668) и `python3.12-venv` отсутствует →
|
||||
wheels скачаны через `pip download` и распакованы в `/tmp/sqlibs_unpacked`,
|
||||
запуск с `PYTHONPATH=/tmp/sqlibs_unpacked`.
|
||||
- Лимит очередей тенанта — 10: падавшие скрипты оставляют очереди, перед прогоном
|
||||
чистить (`aws sqs delete-queue`).
|
||||
- `ENDPOINT` (api_test.sh) и `ENDPOINT_URL` (python-тесты) — разные имена переменных.
|
||||
|
||||
## 6. Уроки
|
||||
|
||||
1. Стресс-проверка обязательна: api/sdk/fifo проходили и с дефектом, он проявлялся
|
||||
только на циклах полного опустошения очереди.
|
||||
2. `SIGQUIT` — быстрый и точный способ найти зависание (кто держит мьютекс), дешевле догадок.
|
||||
3. При замене условия на новый критерий (`len` → `LiveCount`) надо перечитывать
|
||||
весь блок, включая порядок `defer` — старый код содержал латентную утечку лока,
|
||||
которую новый критерий сделал массовой.
|
||||
4. Не запускать сервис с `| head` — SIGPIPE убивает процесс на середине теста.
|
||||
|
||||
## 7. Код-ревью (роль ПРО) и фиксы — коммит `48c38c4`
|
||||
|
||||
Проведён архитектурный разбор всех затронутых файлов. Найдено и исправлено:
|
||||
|
||||
1. **🔥 Батч-видимость не заводила в DLQ** (`change_message_visibility_batch.go`).
|
||||
При `VisibilityTimeout=0` батч лишь инкрементировал `Retry` и НЕ переносил
|
||||
сообщение в DLQ — логика была только в одиночной версии, счётчик и перенос
|
||||
расходились. Вынесен единый метод `models.Queue.ResetVisibilityAndTryDLQ`
|
||||
(сброс видимости + DLQ-перенос), обе версии приведены к одному пути.
|
||||
Тест `TestChangeVisibilityBatch_MovesToDLQ` (падает на старой логике, зелёный на новой).
|
||||
2. **TOCTOU лимит в `SendMessageBatch`**: проверка OOM-лимита была только по
|
||||
устаревшему снимку (RLock → Lock). Добавлена повторная проверка под per-queue Lock.
|
||||
3. Инварианты `InFlightIndexes` (снимок может содержать устаревшие индексы —
|
||||
потребитель обязан перепроверять) и `ensureIndexes` (проверяет согласованность
|
||||
чисел, а не валидность map-индексов → `Messages` нельзя мутировать вне методов)
|
||||
зафиксированы в док-комментариях.
|
||||
4. `Compact` упрощён: один `RebuildIndexes` на выходе + `ensureIndexes` на входе
|
||||
(убран дублирующий блок), `deadCount` пересчитывается внутри.
|
||||
|
||||
Осталось осознанно не тронуто: `DeleteMessageBatch` итерация с `RemoveIndex` внутри
|
||||
безопасна (tombstone не меняет длину) — покрыто тестами.
|
||||
|
||||
## 8. Полный код-ревью сервиса (2026-10-08) — коммит `5b0e89a`
|
||||
|
||||
Разобран весь сервис: auth (SigV4 + JWT), router, billing, tenant, admin/UI API,
|
||||
создание очередей. Критичных проблем не найдено. Исправлено три замечания:
|
||||
|
||||
1. **TOCTOU лимита тенантов** — `TenantStore.Create`: проверка `MaxTenantsGlobal`
|
||||
была только под RLock, вставка — позже под Lock без повторной проверки. Добавлена
|
||||
проверка под Lock (как уже было в `CreateFromJWT`).
|
||||
2. **Потеря `NubesSub` при рестарте** — `CreateFromJWT`: обновлённый `NubesSub`
|
||||
сохранялся в Redis только внутри ветки смены `MaxQueues`; теперь персист при
|
||||
любом реальном изменении, запись только если что-то изменилось.
|
||||
3. **`http.Client` на каждый вызов** — `auth.PingNubesAPI` переведён на общий
|
||||
клиент (keep-alive).
|
||||
|
||||
UI/безопасность проверены отдельно: `listTenants`/`getTenant`/IDOR для UI-тенанта —
|
||||
видит только себя, чужие `{id}` отсекаются, удаление через UI запрещено,
|
||||
`SecretKey` отдаётся только по явному `/ui/api/credentials`.
|
||||
@@ -0,0 +1,290 @@
|
||||
# История правок shared-sqs — 2026-10-10 (публичная точка на ВМ, TLS, замеры)
|
||||
|
||||
> Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24).
|
||||
> Предыдущие: `HISTORY/2026-10-07-session-log.md`, `doc/thinking/*-2026-10-08.md`.
|
||||
> Сессия велась двумя моделями: сначала DeepSeek Flash, затем DeepSeek Pro (архитектор) —
|
||||
> Pro перепроверял выводы Flash, часть из них оказалась неверной (см. раздел 4).
|
||||
|
||||
## 1. Задача
|
||||
|
||||
Вынести публичную точку SQS на ВМ `5.172.178.213` (обход больного managed-шлюза) и
|
||||
**проверить гипотезу** из `MANUAL-NEXT-2026-10-08.md` (Шаг 1): нет ли на пути
|
||||
«интернет → ВМ» той же сетевой болезни (зависания ~51 с, таймауты 31–33 с).
|
||||
Требования владельца по пути: домен — только свой (`sinefo.ru`); `kube5s` из проекта убрать.
|
||||
|
||||
## 2. Хронология (что делалось и что получалось)
|
||||
|
||||
### 2.1. Сборка и запуск на ВМ
|
||||
- `make build` → `shared-sqs` v0.1.35 (статический, x86_64, 17 МБ).
|
||||
- `scp` → `naeel@5.172.178.213:~/sqs-test/shared-sqs`.
|
||||
- Запуск memory-only: `SHARED_SQS_SEED_DEMO=true ./shared-sqs --admin-token=dev-token-123 --port=4100`.
|
||||
- Локально на ВМ: `/health` → `200 {"status":"ok","version":"v0.1.35"}`;
|
||||
`ListQueues` без подписи → `403 MissingAuthenticationToken` (корректно).
|
||||
- ⚠️ Предупреждение `Failure to find default config file` — `goaws.yaml` на ВМ нет,
|
||||
приложение работает на встроенных дефолтах (см. 5.1).
|
||||
|
||||
### 2.2. Разведка ВМ
|
||||
- Публичный адрес — `5.172.178.213` (NAT); внутренний `10.10.102.21` (`ens192`), hostname `naeel-vm`.
|
||||
- Снаружи прилетают только 80/443. Проверка портов:
|
||||
- `8443` — занят DNAT: `iptables -t nat`: `--dport 8443 -j DNAT --to-destination 95.179.252.111:443`
|
||||
(сервис `vpn-transit-dnat`, `enabled/active`) → весь трафик на 8443 уходит на Vultr **до** ВМ.
|
||||
- `8888`/`9443` — `/dev/tcp` показывает «OPEN», но локально **никто не слушает** и HTTP не отвечает
|
||||
(curl timeout) → это SYN-proxy провайдерского firewall, порты непригодны.
|
||||
- `/etc/hosts` на ВМ содержит внутренние имена: `gitea.mgmt.nubes.ru`, `sqs.containerk8s.dev.nubes.ru`
|
||||
→ `185.247.187.151`, `tf-registry.containerk8s.services.ngcloud.ru`, `s3.msk-1.ngcloud.ru`.
|
||||
Резолвер ВМ — публичный (`8.8.8.8`, `1.1.1.1`), поэтому имена `*.services.ngcloud.ru` не резолвятся.
|
||||
|
||||
### 2.3. Блокер nginx (возник 10-08, снят 10-10)
|
||||
- 10-08: `sudo nginx -t` **падал**: `host not found in upstream "contractor.pythonk8s.services.ngcloud.ru"
|
||||
in sites-enabled/check.kube5s.ru:44` — внутреннее имя не резолвится.
|
||||
- 10-10 (после ребута ВМ): конфиг `check.kube5s.ru` оказался **изменён** (catch-all `location /`
|
||||
на contractor убран, добавлен `location /rf`) → `nginx -t` **проходит**, nginx перезагружаем.
|
||||
- Следствие: переход к рабочему пути упростился (сначала была построена схема со вторым nginx, см. 2.4).
|
||||
|
||||
### 2.4. Второй nginx на 10443 (сделан, затем убран)
|
||||
- Владелец пробросил порт: firewall `[22, 80, 443, 4443, 10443]`.
|
||||
- Проверено: `10443` реально доходит до ВМ (`/health` 200), `4443` — нет.
|
||||
- Поднят **отдельный** nginx: `/etc/nginx-sqs/nginx.conf`, pid `/run/nginx-sqs.pid`,
|
||||
`listen 10443` → `proxy_pass http://127.0.0.1:4100`. Запуск `sudo nginx -c /etc/nginx-sqs/nginx.conf`.
|
||||
|
||||
### 2.5. Замеры (гипотеза ПОДТВЕРЖДЕНА)
|
||||
|
||||
| Тест | Инструмент | Результат |
|
||||
|---|---|---|
|
||||
| HTTP, 30 запросов 1КБ/10КБ/100КБ/256КБ | `curl --aws-sigv4` | 30/30 **200**, max **0.99 с** |
|
||||
| HTTPS, 10 запросов 26КБ…260КБ | `curl --aws-sigv4` | 10/10 **200**, 0.7–3.2 с |
|
||||
| Long-poll `ReceiveMessage` `WaitTimeSeconds=20` | `curl` | **20.3 с, 200** (корректно) |
|
||||
|
||||
Ни зависаний ~51 с, ни таймаутов 31–33 с. **На пути «интернет → ВМ 213» болезни нет.**
|
||||
|
||||
### 2.6. DNS
|
||||
- `sinefo.ru` делегирован в реестре .ru на `ns3.ngcloud.ru` / `ns4.ngcloud.ru` (проверено через `a.dns.ripn.net`).
|
||||
- `sqs.sinefo.ru A 5.172.178.213` — публично резолвится. Ранее запись была `185.247.187.83`,
|
||||
что = **IP самого DNS-сервера** `ns3.ngcloud.ru`, а не ВМ (исправлено владельцем).
|
||||
|
||||
### 2.7. TLS (Let's Encrypt)
|
||||
- `sudo certbot certonly --webroot -w /var/www/acme -d sqs.sinefo.ru --non-interactive --agree-tos`
|
||||
(аккаунт LE на ВМ уже существовал → без нового e-mail) → `/etc/letsencrypt/live/sqs.sinefo.ru/`.
|
||||
- Срок: до 2027-01-08, автопродление настроено.
|
||||
- ACME-челлендж обслуживается главным nginx (vhost 80 с `location /.well-known/acme-challenge/`).
|
||||
|
||||
### 2.8. Публичная точка (итог)
|
||||
- Главный nginx, `/etc/nginx/conf.d/sqs-sinefo.conf`:
|
||||
- **80**: ACME-челлендж + редирект на https;
|
||||
- **443 ssl**: сертификат `sqs.sinefo.ru` + `proxy_pass http://127.0.0.1:4100`, `Host $http_host`.
|
||||
- Проверено снаружи: `https://sqs.sinefo.ru/health` → 200 (cert valid), SigV4, 256 КБ за 1.05 с.
|
||||
|
||||
### 2.9. Уборка
|
||||
- Второй nginx (10443) остановлен, `/etc/nginx-sqs` удалён; порт 10443 свободен.
|
||||
- `conf.d/sqs-sinefo.conf` — заменён на нормальный vhost (было: HTTP-only левак от 10-08).
|
||||
- Из **актуального** дока `doc/2026-08-13-migration-to-managed.md` убран `kube5s.ru`
|
||||
(квинтет: `iot.kube5s.ru`→`iot.nubes.ru`, `qu.kube5s.ru`×3→`sqs.containerk8s.dev.nubes.ru`),
|
||||
коммит `a47743f`. В `doc/legacy/` (история) не трогал.
|
||||
|
||||
### 2.10. Фикс QueueUrl (кодинг)
|
||||
- Проблема: `QueueUrl` = `http://region.host:port/...` (плейсхолдеры) — клиенты без `endpoint_url` сломаны.
|
||||
- Реализовано: `Environment.ExternalURLBase` + env `SHARED_SQS_EXTERNAL_URL_BASE`.
|
||||
- **Ошибка по пути №1:** env-override сначала поместил после `models.CurrentEnvironment = envs[env]`
|
||||
— не сработал: `LoadYamlConfig` делает ранний `return` без `goaws.yaml`. Перенёс в
|
||||
`applyEnvironmentDefaults` (вызывается через defer → всегда).
|
||||
- **Ошибка по пути №2:** после переноса всё равно не работало — демо-seed (`app/cmd/seed.go`) собирал
|
||||
URL **своей копией** логики, а не через `tenantQueueURL`. Вынес `gosqs.TenantQueueURL`, seed переведён на него.
|
||||
- Коммиты `469132b`, `ca0372b`; тест `app/gosqs/tenant_helpers_test.go`. Проверено снаружи:
|
||||
`list-queues` → `https://sqs.sinefo.ru/t-.../orders`.
|
||||
|
||||
### 2.11. Устойчивость: systemd + локальный Redis
|
||||
- Установлен `redis-server` (7.0.15) на 213, включён AOF (`appendonly yes`).
|
||||
- systemd-юнит `shared-sqs.service` (`Restart=always`, `enable`) + `/etc/shared-sqs.env`.
|
||||
- Проверено: сообщение пережило рестарт; `systemctl is-enabled/active` → enabled/active.
|
||||
- **Дефекты seed (обнаружены и исправлены, коммит `c0aa618`):** демо-очереди не персистились;
|
||||
каждый старт плодил нового демо-тенанта (утечка при `Restart=always`). Фикс: `SaveQueue`+
|
||||
`SaveMessages` + идемпотентность через `store.List()`.
|
||||
- Управление: `sudo systemctl {status,restart,stop} shared-sqs`.
|
||||
|
||||
### 2.12. Прод-хардненинг
|
||||
- `SHARED_SQS_SEED_DEMO=false`; admin-token сменён на случайный 64-hex (в `/etc/shared-sqs.env`);
|
||||
logrotate для `sqs.log`.
|
||||
- Billing/JWT не настроены. Проверено: сервис работает на существующем тенанте из Redis.
|
||||
|
||||
### 2.13. Шаг 2 — разбиение data-plane / control-plane
|
||||
- `router.NewDataPlane` (публичный SQS API) + `router.NewControlPlane` (админка/метрики/UI).
|
||||
- `main`: два листенера — `0.0.0.0:4100` (публично) и `127.0.0.1:4101` (control-plane, только локально).
|
||||
- Проверено: публично `/admin`/`/metrics` → 403 (наружу не доступны), SQS API работает; локально
|
||||
`4101/admin` → 200. Коммит `c1520c0`.
|
||||
- Примечание: пока один процесс с двумя листенерами (общее 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), регрессий нет.
|
||||
|
||||
### 2.15. Durability batch + billing
|
||||
- `SendMessageBatch` переведён на `SaveMessagesSync` (было async) + не персистит дубли (коммит `3559414`).
|
||||
- Проверено: batch в пустую очередь пережил рестарт.
|
||||
- Billing — предусмотрен (модуль есть), но выключен (`BILLING_PG_HOST` не задан). Решение: включить позже.
|
||||
|
||||
### 2.16. Шаг 3 — HA active/passive (leader-election)
|
||||
- Выбран **вариант C** (active/passive через Redis-лок) вместо полного выноса состояния (A) —
|
||||
обоснование: причина простоя — недоступность ВМ, а не CPU; C стоит ~200 строк и не трогает ядро
|
||||
очередей. Подробно: `doc/thinking/state-extraction-and-ha-2026-10-10.md`.
|
||||
- Код (коммит `bc00643`):
|
||||
- `app/persistence/redis.go`: `TryAcquireLock` (SETNX+PX), `RefreshLock`/`ReleaseLock`
|
||||
(Lua: только если значение наше). При `Client == nil` — всегда `true` (мы одни).
|
||||
- `app/leader/leader.go`: `Elector` — цикл «продлить/взять лок» каждые 3 с, TTL 10 с, `IsLeader()`.
|
||||
- `app/router/router.go`: `/ready` (в обоих плоскостях) — 200 если готов, 503 если пассивный;
|
||||
поведение задаётся пакетной `ReadyFunc`.
|
||||
- `app/cmd/goaws.go`: `--leader-election` (или env `SHARED_SQS_LEADER_ELECTION=true`),
|
||||
`--leader-ttl`, `--leader-interval`; `ReadyFunc` ставится только при включении;
|
||||
лок отпускается при graceful shutdown. **По умолчанию выключено — поведение не изменилось.**
|
||||
- **Замеры (не гипотезы), ВМ 213, два временных инстанса 4200/4210:**
|
||||
- одна копия → `/ready` `200`, вторая → `503` (split-brain нет); `redis-cli get ssq:leader`
|
||||
= `<hostname>-<pid>` лидера;
|
||||
- `kill -9` лидера → пассивный стал лидером, `/ready` → `200`, перехват занял **~12 с**
|
||||
(максимум по формуле `ttl+interval` = 13 с);
|
||||
- прод-API после деплоя: `list-queues` / `send-message` / `receive-message` через
|
||||
`https://sqs.sinefo.ru` — OK; временные инстансы остановлены, ключ `ssq:leader` удалён.
|
||||
- **В проде выключено**: для включения нужны общий Redis + вторая ВМ + LB с проверкой `/ready`
|
||||
(иначе пассивный получит трафик и вернёт «пустые» очереди — главный риск варианта C, описан в доке).
|
||||
- Док (коммит `865db49`): почему C, механика, замеры, ограничения, пошаговое включение, откат,
|
||||
путь к варианту A (5 шагов) и триггеры перехода.
|
||||
|
||||
### 2.17. Код-ревью Opus + критический разбор
|
||||
- Дал промпт Opus: полное код-ревью + советы по архитектуре. Дословный ответ:
|
||||
`doc/thinking/opus-report-2026-10-10.md`; критический разбор + что подтверждено:
|
||||
`doc/thinking/opus-report-analysis-2026-10-10.md`.
|
||||
- Opus указал ~50 пунктов (🔴/🟡/🟢). Моя оценка: силён по конкуренции/durability, но смешивает
|
||||
баги / осознанный дизайн / отклонения от семантики AWS; часть 🔴 надо понизить.
|
||||
- **Подтверждено по коду (чинится без владельца):**
|
||||
- 🔴1 фатал `concurrent map iteration and map write`: `create_queue.go:84` зовёт `SaveQueue`
|
||||
под `SyncQueues.Lock` без `queue.mu`; `SaveQueue`→`json.Marshal` итерирует `Duplicates`/`FIFO*`/`Tags`,
|
||||
а мутаторы держат только `queue.mu`. Условие: идемпотентный CreateQueue существующей FIFO-очереди + SendMessage.
|
||||
- 🔴5 неполный откат dedup при сбое `SaveMessageSync` (`send_message.go:151-160`) → ретрай 200 без сохранения.
|
||||
- 🔴8 пустой `ReceiptHandle` в DeleteMessageBatch удаляет все невыданные сообщения — **только
|
||||
через JSON-протокол** (`x-amz-json-1.0`); Query-форма защищена (`requests.go:568` не на этом пути).
|
||||
- Порядок локов + I/O под глобальным локом; fail-fast на старте без Redis отсутствует.
|
||||
- **Нужны решения владельца:**
|
||||
- JWT: проверяет ли шлюз подпись (trusted-perimeter из `jwt.go:7-11`)? Независимо чинятся:
|
||||
три шлюза prod/test/dev → prod; потолок grace в `cache.go:68-75`; идентичность по `sub`, не email.
|
||||
- Лимит по байтам (OOM, 🔴14): нужны RAM ВМ + число тенантов → cap на тенанта/очередь + глобальный предохранитель по RSS.
|
||||
- Порядок исправления — §5 дока анализа.
|
||||
|
||||
### 2.18. Правки по ревью (применено, 3 коммита)
|
||||
- `654e315` — 🔴1 (SaveQueue под queue.mu), 🔴8 (пустой ReceiptHandle), 🔴5 (dedup только после
|
||||
успешной записи, single+batch), глобальный предохранитель HeapAlloc (`memory_guard.go`,
|
||||
`SHARED_SQS_MAX_HEAP_MB`, 0=выключен).
|
||||
- `1230149` — JWT: только prod-шлюз (убран test/dev); grace в `cache.go` ограничен `graceDeadline`
|
||||
от последней успешной проверки (не продлевается бесконечно).
|
||||
- `2187027` — fail-fast: при заданном `REDIS_ADDR` и недоступном Redis → `log.Fatalf`.
|
||||
- build + vet + test — зелёные. Не задеплоено на ВМ (жду команды).
|
||||
- НЕ сделано (нужны решения владельца): цифры лимита по байтам (RAM/тенанты); идентичность
|
||||
JWT по `sub` вместо email (зависит от того, проверяет ли шлюз подпись). Порядок локов и I/O
|
||||
под глобальным локом — осталось (более крупный рефакторинг).
|
||||
|
||||
### 2.19. Деплой правок на ВМ 213 + проверка
|
||||
- Обновлён `/home/naeel/sqs-test/shared-sqs` (prev-бинарник сохранён, лог бэкапнут
|
||||
`sqs.log.before-deploy-*`), сервис `active`, `/health` 200, `/ready` 200.
|
||||
- В логе подтверждено: `leader-election выключен`, два листенера (4100 public / 4101 localhost),
|
||||
fail-fast не срабатывал (Redis доступен).
|
||||
- **Функциональная проверка 🔴8 (изолированная очередь `probe-empty-handle`):**
|
||||
- отправлено 2 сообщения (не выдавались → `ReceiptHandle == ""`), `ApproximateNumberOfMessages` = 2;
|
||||
- `delete-message-batch` с `ReceiptHandle: ""` → ошибка
|
||||
`AWS.SimpleQueueService.InvalidParameterValue`, **сообщения остались (2)** → фикс работает;
|
||||
- CLI v2 подтверждённо шлёт JSON-протокол (ошибку отдал наш новый валидатор, а не парсер формы);
|
||||
- легитимный `delete-message-batch` по реальному handle → `Successful`, счётчик 2→1;
|
||||
- probe-очередь удалена.
|
||||
- Регрессий нет: `list-queues`, `send-message`, `receive-message` через `https://sqs.sinefo.ru` — OK.
|
||||
|
||||
### 2.20. AWS-совместимость для Terraform-провайдера (цель дня уточнена владельцем)
|
||||
Контекст: это **не глобальный прод с высокой нагрузкой**; цель — рабочие SQS+IoT и **Terraform-провайдер**,
|
||||
чтобы юзеры Nubes подключали сервисы в своих конфигурациях. Приоритет — **надёжность и соответствие
|
||||
AWS**, а не масштабирование. Поэтому HA/перф-оптимизации отложены, в фокус — совместимость.
|
||||
- `95bce88` — атрибуты очереди для провайдера: `GetQueueAttributes` теперь отдаёт `FifoQueue`,
|
||||
`ContentBasedDeduplication`, `Policy` и **реальные** `CreatedTimestamp`/`LastModifiedTimestamp`
|
||||
(были отсутствующие/заглушка `0000000000`); в `Queue` добавлены `CreatedAt`/`LastModifiedAt`/`Policy`/
|
||||
`ContentBasedDeduplication`; разбор FIFO-атрибутов в обоих протоколах (`StringToBool`);
|
||||
`Policy`/`RedriveAllowPolicy` нормализуются из строки в объект (JSON-протокол присылает строку →
|
||||
раньше `SetQueueAttributes` падал с `InvalidParameterValue`); бэкфилл пустых timestamps при загрузке.
|
||||
- `988a6a6` — FIFO-дедупликация по AWS: дедуп — свойство FIFO (гейт по `IsFIFO`, а не по env-флагу
|
||||
`EnableDuplicates=false`, из-за которого дедуп не работал вообще); `ContentBasedDeduplication`
|
||||
считает dedupId из тела (SHA-256); dedup-окно **не сбрасывается при удалении** сообщения (5 мин).
|
||||
- **Проверено локально и на проде** (`aws` CLI v2, JSON-протокол):
|
||||
- `GetQueueAttributes All` отдаёт полный набор (см. лог);
|
||||
- FIFO: два send с одним dedupId → 1 сообщение; `ContentBasedDeduplication` (два одинаковых тела) → 1;
|
||||
- receive+delete, затем повторный send с тем же dedupId → 0 (окно живо) — раньше доставлялся повторно;
|
||||
- `set-queue-attributes` с `Policy` → OK, `get-queue-attributes Policy` возвращает канонический JSON.
|
||||
- На ВМ задеплоено (prev-бинарник + бэкап лога сохранены), `active`, `/health` 200.
|
||||
|
||||
## 3. Грабли (для повторного использования)
|
||||
|
||||
1. **`proxy_set_header Host $host` теряет порт** → SigV4 `SignatureDoesNotMatch`.
|
||||
Нужен `$http_host`. (nginx `$host` — имя без порта; `$http_host` — заголовок целиком.)
|
||||
2. **`~/.aws/config [default]` содержит `endpoint_url = https://s3.msk-1.ngcloud.ru`** →
|
||||
`aws sqs ...` без явного `--endpoint-url` уходит в S3 и получает
|
||||
`405 MethodNotAllowed` (Ceph RGW, `HostId=...-dc1-msk-1`). **Всегда задавать `--endpoint-url`.**
|
||||
3. **Креды демо-тенанта рандомны при каждом старте** — брать из `~/sqs-test/sqs.log`
|
||||
(`seed: создан тенант ... AccessKey=...`). При «address already in use» в логе креды **мёртвого** инстанса.
|
||||
4. `pkill -f "http.server 10443"` по SSH **самоматчится** на строку ssh-команды и убивает сессию —
|
||||
использовать `pkill -x <имя>`.
|
||||
5. Порт `8443` на 213 **нельзя занимать** — там DNAT транзита на Vultr.
|
||||
6. **Managed Redis недоступен с 213**: его endpoint — внутренний k8s DNS
|
||||
(`redisk8s.<uid>.svc.cluster.local:6379`), резолвится только изнутри кластера. С ВМ — только
|
||||
локальный Redis или внешний endpoint от NUBES.
|
||||
7. Локальный Redis на 213 **не спасает от падения ВМ** (SQS и Redis — на одной машине, SPOF).
|
||||
|
||||
## 4. ОШИБКИ (сводка, включая ошибки Flash)
|
||||
|
||||
| # | Что утверждалось/сделано | Что на самом деле | Исправление |
|
||||
|---|---|---|---|
|
||||
| 1 | «При ребуте ВМ nginx не поднимется» (из-за битого upstream) | После ребута nginx `active`, 80/443 слушают | Неверный вывод; снят при перепроверке |
|
||||
| 2 | «`.83` — мёртвый IP» | `185.247.187.83` = живой DNS `ns3.ngcloud.ru` (порт 53); проверял только 80/443 | Признано при перепроверке |
|
||||
| 3 | Запуск второго инстанса при живом первом | `fatal: bind: address already in use`; лог перезаписан кредами мёртвого → `InvalidClientTokenId` | `pkill -x shared-sqs`, один инстанс, креды из лога |
|
||||
| 4 | Второй nginx: `Host $host` | SigV4 `SignatureDoesNotMatch` | `Host $http_host` |
|
||||
| 5 | Прямые DNS-выводы по регексу `location / ` | Первый `grep` не нашёл (паттерн с пробелом) — чуть не сделал ложный вывод | Перечитал файл целиком (`cat`) |
|
||||
| 6 | «Только 80/443 открыты» | 8888/9443 «принимают» TCP (SYN-proxy), но пусты | Уточнено: рабочие — только 80/443 |
|
||||
|
||||
## 5. Состояние и воспроизведение
|
||||
|
||||
### 5.1. Итоговое состояние
|
||||
- **Работает:** `https://sqs.sinefo.ru` (443, TLS) → shared-sqs на ВМ 213 (memory-only, `127.0.0.1:4100`).
|
||||
- SQS: `~/sqs-test/shared-sqs`, лог `~/sqs-test/sqs.log`, pid меняется. Демо-тенант + 5 очередей.
|
||||
- **QueueUrl — ИСПРАВЛЕНО** (см. 2.10): `https://sqs.sinefo.ru/<tenantID>/<queue>`.
|
||||
|
||||
### 5.2. Как поднять заново
|
||||
```bash
|
||||
# на ВМ 213
|
||||
cd ~/sqs-test && setsid nohup env SHARED_SQS_SEED_DEMO=true \
|
||||
./shared-sqs --admin-token=dev-token-123 --port=4100 --loglevel=info > sqs.log 2>&1 </dev/null &
|
||||
# креды демо-тенанта — в sqs.log
|
||||
grep "seed: создан тенант" ~/sqs-test/sqs.log | tail -1
|
||||
# проверка снаружи
|
||||
curl -sS https://sqs.sinefo.ru/health
|
||||
```
|
||||
|
||||
### 5.3. Откат
|
||||
```bash
|
||||
sudo rm /etc/nginx/conf.d/sqs-sinefo.conf && sudo nginx -t && sudo systemctl reload nginx
|
||||
pkill -x shared-sqs
|
||||
# бэкапы nginx на ВМ: /etc/nginx/conf.d.bak-*
|
||||
```
|
||||
|
||||
## 6. Открытые вопросы
|
||||
|
||||
1. Клиенты без явного `endpoint_url` — есть ли такие (вопрос №5 мануала). `QueueUrl` уже корректный.
|
||||
2. Egress ВМ: `curl https://api.ipify.org` с ВМ вернул пусто — влияет на billing (PG) и JWT-API.
|
||||
3. `SaveQueue` (метаданные очереди) всё ещё пишется **асинхронно** — при failover новый лидер
|
||||
может не увидеть очередь, созданную незадолго до падения. Первый шаг к варианту A
|
||||
(см. §8 дока по HA) — сделать синхронно, как `SaveMessagesSync`.
|
||||
4. Для включения HA нужны: **общий Redis** (сейчас только локальный на 213 — SPOF),
|
||||
вторая ВМ, LB с проверкой `/ready`. Пока всё это не готово — флаг выключен.
|
||||
5. Дальше по мануалу: обновить устаревший `MANUAL-NEXT-2026-10-08.md` (ещё говорит «Шаг 1 — проверить
|
||||
гипотезу» и «вопрос по домену открыт»); решить вопрос push в origin (18 коммитов впереди).
|
||||
6. **JWT (решение владельца):** проверяет ли шлюз подпись и отдаёт 200 только для валидных токенов?
|
||||
От этого — 🔴 или «доверяем шлюзу». См. `opus-report-analysis-2026-10-10.md` §3.
|
||||
7. **Лимит по байтам (решение владельца):** нужны RAM ВМ 213 и целевое число тенантов для цифр cap.
|
||||
См. `opus-report-analysis-2026-10-10.md` §4.
|
||||
8. 🔴1/🔴5/🔴8 + fail-fast + JWT + heap-guard — **применены** (коммиты 654e315/1230149/2187027),
|
||||
не задеплоено. Осталось: порядок локов + I/O под глобальным локом (рефакторинг).
|
||||
@@ -0,0 +1,124 @@
|
||||
# История правок shared-sqs — 2026-10-11 (внешнее код-ревью и фиксы)
|
||||
|
||||
> Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24).
|
||||
> Предыдущие: `HISTORY/2026-10-10-session-log.md`, `doc/thinking/*-2026-10-08.md`.
|
||||
|
||||
## 1. Задача
|
||||
|
||||
Провести **внешнее код-ревью** двух сервисов (SQS-service + IoT) моделью Claude Sonnet
|
||||
(в режиме Plan — правки не вносит, только находит), затем своими силами исправить
|
||||
найденное. Промпт для ревью — `CLAUDE-REVIEW-PROMPT.md` в корне монорепо
|
||||
(со списком файлов, фокус-точками и строгим форматом ответа; явно запрещены
|
||||
правки, оценка тестов/UI/legacy/стиля).
|
||||
|
||||
Ревью дало **40 пунктов** (10 🔴 / 29 🟡 / 1 🟢). Разбор вопросов ревьюера и
|
||||
уточнение некорректных выводов — в п.3.
|
||||
|
||||
## 2. Что исправлено (SQS-service)
|
||||
|
||||
### 🔴 Критичные (10/10)
|
||||
|
||||
| Пункт | Файл | Суть | Коммит |
|
||||
|-------|------|------|--------|
|
||||
| 🔴1 | `app/admin/admin.go` | `jwtMiddleware` пропускал любой путь с суффиксом `/auth` (обход JWT + IDOR на `DELETE .../queues/auth`). Теперь — точное сравнение `/ui/api/auth` | `1241093` |
|
||||
| 🔴2 | `app/gosqs/queue_attributes.go` | Запрет self-/циклического `RedrivePolicy`: перенос в DLQ берёт второй `mu` под первым → deadlock | `9b9bbe1` |
|
||||
| 🔴3 | `app/gosqs/gosqs.go`, `models.go`, `change_message_visibility*.go` | Перенос в DLQ не персистился (сообщение «воскрешало» в исходной и пропадало из DLQ) | `1e334c2` |
|
||||
| 🔴4 | `app/persistence/redis.go`, `app/tenant/tenant_store.go` | `DeleteTenant` не чистил индекс `ssq:tenant_ak` — отозванный тенант возвращался через read-through | `f68b0a4` |
|
||||
| 🔴5 | `app/persistence/redis.go`, `app/cmd/goaws.go` | Глобальный `Client` перезаписывался в `reconnect()` без синхронизации (data race). → `atomic.Pointer` | `d03ce36` |
|
||||
| 🔴6 | `app/conf/config.go` | `return ports` без `SyncQueues.Unlock()` при ошибке `setQueueRedrivePolicy` → вечный deadlock | `1520753` |
|
||||
| 🔴7 | `app/billing/billing.go` | `Close()` писал `db=nil` параллельно с `usageWorker` (nil-паника). → мьютекс + остановка воркера до `db.Close` | `4e36ba0` |
|
||||
| 🔴10 | `app/auth/auth_middleware.go` | `X-Amz-Content-Sha256` не сверялся с телом — подмена тела под подписанным заголовком | `2918a41` |
|
||||
|
||||
(🔴8/🔴9 — на стороне IoT, см. `IoT/HISTORY/2026-10-11-session-log.md`.)
|
||||
|
||||
### 🟡 Важные
|
||||
|
||||
| Пункт | Файл | Суть | Коммит |
|
||||
|-------|------|------|--------|
|
||||
| 🟡20 | `app/admin/admin.go` | `createTenantQueue` не валидировал имя очереди (`:`,`/` ломали ключ) | `efd8c8e` |
|
||||
| 🟡23 | `app/auth/jwt.go` | Токен без `exp` принимался бессрочно → требуем `exp` | `efd8c8e` |
|
||||
| 🟡24 | `app/leader/leader.go` | При сбое `RefreshLock` инстанс оставался лидером (split-brain) → сброс лидерства после TTL | `efd8c8e` |
|
||||
| 🟡21 | `app/tenant/tenant_store.go` | Запрет `:` в BYOC `access_key` (коллизии ключа `{ak}:{name}`) | `dc4815d` |
|
||||
| 🟡22 | `app/admin/admin.go` | `sendMessageToQueue` без лимитов + async persist → лимит `MaxMessagesForQueue` + `SaveMessageSync` | `dc4815d` |
|
||||
| 🟡33 | `app/models/models.go` | `InitDuplicatation` падал на nil-map `Duplicates` → ленивая инициализация | `dc4815d` |
|
||||
| 🟡35 | `app/models/models.go` | `Reset()` не чистил `FIFOMessages` (после purge группы не выдавались) | `dc4815d` |
|
||||
| 🟡36 | `app/gosqs/receive_message.go` | `ApproximateReceiveCount` был завышен на 1 | `dc4815d` |
|
||||
| 🟡38 | `app/cmd/goaws.go` | `billing.Close()` вызывался ДО `Shutdown` серверов → перенесён после | `dc4815d` |
|
||||
| 🟡26 | `app/gosqs/delete_message_batch.go` | Ключ map по `ReceiptHandle` (два entry с одним handle теряли Id) → по `Id` | `e0b5f24` |
|
||||
| 🟡27 | `app/gosqs/send_message.go` | `DelaySeconds` не валидировался → 0–900 | `e0b5f24` |
|
||||
| 🟡25 | `app/gosqs/send_message_batch.go` | Dedup регистрировался после цикла (два entry с одним id оба добавлялись); `NextSequenceNumber` до проверки дубликата; per-entry `DelaySeconds` не применялся | `6f69755` |
|
||||
| 🟡29 | `app/gosqs/delete_message*.go`, `persistence/redis.go` | Ошибка `HDel` только логировалась (клиент получал 200, после рестарта сообщение воскрешало) → `DeleteMessage(s)Persist` возвращает ошибку, 500 клиенту | `5d38118` |
|
||||
| 🟡31 | `app/persistence/redis.go`, `app/cmd/goaws.go` | Ошибка загрузки сообщений очереди давала `continue` (очередь стартовала пустой) → fail-fast (`log.Fatalf`) | `5d38118` |
|
||||
| 🟡30 | `app/persistence/redis.go` | Async `SaveMessage` без порядковой нумерации (старое состояние могло перезаписать новое) → seq по `{queueKey}|{uuid}` + очистка при удалении | `7a2340d` |
|
||||
| 🟡32 | `app/gosqs/gosqs.go` | `PeriodicTasks` без `recover` (паника убивала процесс) | `dc4815d` |
|
||||
| 🟢 | `app/utils/utils.go` | `SqsErrors[errKey]` без проверки → nil-deref на неизвестном ключе | `5d38118` |
|
||||
| (🟡 new) | `app/gosqs/create_queue.go` | Очередь из API получала `VisibilityTimeout=0` и пр. (дефолты не подставлялись) | `5d38118` |
|
||||
|
||||
### Осознанно НЕ менялось — РЕШЕНИЯ (2026-10-11)
|
||||
|
||||
- 🟡34 (атрибуты «0 = не передано»): **решение — не менять** совместимость.
|
||||
Различать «не передано» и явный 0 потребовало бы перевести `QueueAttributes`
|
||||
на указатели — ломает JSON-протокол и Terraform-drift. Явный 0 — редкий сценарий.
|
||||
- 🟡37 (дефолт `SHARED_SQS_MAX_HEAP_MB`): **решение — оставить выключенным (0)**.
|
||||
Безопасное число зависит от RAM конкретной ВМ и числа тенантов — выбор владельца,
|
||||
не хардкод в коде.
|
||||
- 🟡28 (сеть под `queue.mu`): **решение — оставить как есть (вариант A)**. См. п.4.
|
||||
|
||||
## 3. Уточнение выводов ревьюера
|
||||
|
||||
Ревьюер задал вопросы по коду, ответы найдены в коде (файл:строка):
|
||||
|
||||
- **ACL EMQX — единственный барьер для топиков** `{ns}/telemetry/{deviceId}`? Да:
|
||||
`bridge/handler.go` берёт `ns`/`deviceID` из топика без сверки принадлежности,
|
||||
барьер — EMQX authz (`emqx/emqx.conf`). Добавлена defense-in-depth проверка
|
||||
формата (IoT).
|
||||
- **Self-/циклический `RedrivePolicy` реально задаётся через API?** Да
|
||||
(`setQueueAttributesV1` не проверял) — отсюда фикс 🔴2.
|
||||
- **Пустой `adminToken`?** Недопустим — `goaws.go:~74` `log.Fatal` при отсутствии.
|
||||
- **Дефолты очереди расходятся** (`create_queue.go` vs `conf/config.go:100-109`)?
|
||||
Да — отсюда фикс (🟡 new).
|
||||
- **`utils.go` паника на неизвестном ключе?** Текущей паники нет (все ключи есть
|
||||
в `models.SqsErrors`), но добавлена защита.
|
||||
|
||||
Также ревьюер отметил некорректный пункт (удалён из списка по лимиту 40): nil-map
|
||||
`Duplicates` (🟡33) фактически не воспроизводился в текущем коде — но защита
|
||||
добавлена как дешёвая страховка.
|
||||
|
||||
## 4. 🟡28 — РЕШЕНИЕ (2026-10-11)
|
||||
|
||||
- **Проблема**: синхронный `SaveMessageSync` (до 3 с) выполняется под `queue.mu`
|
||||
(`send_message.go:150`, `delete_message.go`, `admin.go`). Медленный Redis блокирует
|
||||
всю очередь.
|
||||
- **Варианты**: A) оставить; B) укоротить таймаут Redis-записи; C) полный рефакторинг
|
||||
с per-message синхронизацией.
|
||||
- **Выбрано A — оставить.** Обоснование:
|
||||
1. in-memory ядро очередей — одиночка, блокировка `queue.mu` локальна (одна очередь).
|
||||
2. Синхронный персист под `mu` = гарантия «нет потери подтверждённого сообщения
|
||||
при крахе пода» — приоритет проекта (надёжность, а не масштабирование).
|
||||
3. Вынос сети за `mu` вносит окно между `Unlock`/`Lock`: два конкурентных
|
||||
`SendMessage` с одним `deduplicationId` оба пройдут `IsDuplicate` (дубль),
|
||||
либо при откате `dedup` теряется сообщение — хуже кратковременной блокировки.
|
||||
4. Redis-авария — глобальная: деградирует всё, 2–3 с на очередь не критично.
|
||||
- 🟡34/🟡37 — см. п.2.
|
||||
|
||||
## 5. Верификация
|
||||
|
||||
- `go build ./...`, `go vet ./...`, `go test -race ./...` — зелёные на всех коммитах.
|
||||
- Push: `2c6d2d7..f80e1b4` в `origin/master`.
|
||||
|
||||
## 6. Деплой на прод (ВМ 213) и smoke
|
||||
|
||||
- `make build` → `scp shared-sqs → /home/naeel/sqs-test/shared-sqs.new` →
|
||||
`systemctl stop shared-sqs; mv .new → shared-sqs; start`. Бэкап — `shared-sqs.prev`.
|
||||
- Проверка: `systemctl is-active` = active; `/health` → 200 `v0.1.35`;
|
||||
в логе «загружено 5 очередей из Redis» (fail-fast-загрузка сработала, не упала).
|
||||
- Smoke по `https://sqs.sinefo.ru` (`tests/api_test.sh`, aws cli, тенант `demo-service`):
|
||||
**PASS=22 FAIL=0** (17 базовых операций + FIFO CreateQueue/Send/Receive/Delete/DeleteQueue).
|
||||
Вывод: 18 фиксов ревью не сломали AWS-совместимость на проде.
|
||||
|
||||
## 7. Образы → Gitea registry
|
||||
|
||||
- Реестр — **Gitea** `gitea.services.ngcloud.ru` (НЕ Docker Hub). Makefile/Dockerfile/доки исправлены.
|
||||
- Залито: `nail/shared-sqs:v0.1.35` (+latest; было только v0.1.25), `nail/iot-service:v0.1.13`,
|
||||
`nail/iot-emqx:v0.2.4`.
|
||||
- Push: `e30bf74..f80e1b4`.
|
||||
@@ -0,0 +1,169 @@
|
||||
# МАНУАЛ: что делать дальше (состояние на 2026-10-08)
|
||||
|
||||
> Файл для НОВОГО чата. Прочитай его первым, затем документы из раздела 2.
|
||||
> Репозиторий: `/home/naeel/nubes/SQS_und_IoT/SQS-service` (сервис `shared-sqs`).
|
||||
> Всё, что описано как «сделано», уже в ветке `main` и на сервере (`origin/main`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Где мы сейчас
|
||||
|
||||
**Код SQS готов и запушен.** Ветка `main` = `origin/main`, рабочая копия чистая.
|
||||
|
||||
Что сделано за 2026-10-05…08:
|
||||
- закрыт весь план доработок P0/P1/P2 (`doc/thinking/plan-for-flash-2026-10-05.md`);
|
||||
- P1.2 — индексация сообщений (tombstone + индексы + амортизированная компакция);
|
||||
- полный код-ревью сервиса + исправления (утечка блокировки очереди, DLQ в батче,
|
||||
гонка лимита тенантов, потеря `NubesSub`, общий HTTP-клиент);
|
||||
- интеграционные прогоны: `api_test.sh` 22/22, `sdk_test.py` 15/15, `fifo_dlq_probe.py` — PASS,
|
||||
стресс 3×2000 сообщений — без зависаний.
|
||||
|
||||
**Прод-сервис никто не использует** (podтверждено владельцем), IoT переделывается с нуля.
|
||||
Поэтому мы свободны менять архитектуру без обратной совместимости.
|
||||
|
||||
## 2. Документы, которые надо прочитать в этом порядке
|
||||
|
||||
| Документ | Что внутри |
|
||||
|---|---|
|
||||
| `doc/thinking/architecture-analysis-2026-10-08.md` | **ГЛАВНЫЙ**: решения по размещению и разбиению (публичный SQS, обход через ВМ, модульный монолит data-plane/control-plane) |
|
||||
| `doc/thinking/nubes-large-body-workaround-findings-2026-10-08.md` | как та же болезнь платформы обойдена в `~/nubes/drhider` (буфер на ВМ, pull вместо push) — со ссылками «файл:строка» |
|
||||
| `doc/thinking/nubes-workaround-applicability-to-sqs-2026-10-08.md` | применимость к SQS: 4 варианта, обязательные замеры, техусловия (Host для подписи, внешние `QueueUrl`, TLS) |
|
||||
| `HISTORY/2026-10-07-session-log.md` | журнал правок: что и почему менялось, найденные дефекты, уроки |
|
||||
| `doc/thinking/nubes-ticket.md` | исходная диагностика сетевой болезни (MSS/MTU + таймер шлюза). **Тикет отправлять не нужно** — см. `HANDOFF-NEW-CHAT.md`, раздел 7 |
|
||||
| `doc/thinking/plan-for-flash-2026-10-05.md` | исходный план доработок (выполнен) |
|
||||
| `HANDOFF-NEW-CHAT.md` | доступы, креды, конфигурация кластера, история проекта |
|
||||
|
||||
## 3. Принятые решения (не пересматривать без причины)
|
||||
|
||||
1. **SQS — публичный сервис**, как AWS SQS / YMQ. Внешний доступ обязателен,
|
||||
совместимость с AWS SDK — обязательна.
|
||||
2. **Сетевую болезнь платформы не «лечим» тикетом** — обходим: публичная точка входа
|
||||
на ВМ, минуя managed-шлюз.
|
||||
3. **Пока — ВМ (сейчас `5.172.178.213`), потом отдельная прод-ВМ.** Девопсов не дёргаем,
|
||||
пока не отлажено; затем — уже с доказательствами (tcpdump, период 31–33 с, MSS 1448).
|
||||
4. **«Без монолитов» = модульный монолит**: в отдельные процессы выносим только то,
|
||||
что живёт по-разному. Для SQS граница одна: **data-plane** (очереди/сообщения/SigV4)
|
||||
и **control-plane** (тенанты, креды, админ). Биллинг/метрики/UI — модули, не сервисы.
|
||||
5. **Ограничение, которое важнее разбиения**: ядро очередей держит состояние в памяти
|
||||
одного пода → одиночка. Пока хранилище не вынесено, «не монолит» ≠ «масштабируется».
|
||||
|
||||
## 4. План работ (порядок обязателен)
|
||||
|
||||
### Шаг 1. Проверить гипотезу на ВМ — БЕЗ кода (критерий входа: домен и сертификат)
|
||||
Цель: убедиться, что на ВМ болезни нет, **до** вложений в разбиение.
|
||||
|
||||
1. Собрать бинарник: `make build` (или `CGO_ENABLED=0 go build -o shared-sqs ./app/cmd/`).
|
||||
2. Поднять на ВМ (memory-only достаточно): см. команды в разделе 5.
|
||||
3. Поставить nginx с TLS на выбранный домен, проксировать на `127.0.0.1:4100`.
|
||||
**Обязательно** `proxy_set_header Host $host;` — иначе AWS-подпись не сойдётся
|
||||
(сервис берёт хост из запроса: `app/auth/auth_middleware.go:315-317`).
|
||||
4. Замерить **с внешней машины** (не с ВМ): малые тела, 1 КБ, 10 КБ, 100 КБ, 256 КБ,
|
||||
плюс длинный прогон на таймауты. Инструменты: `tests/tcp_mtu_probe.py`,
|
||||
`tests/long_compare_local.py`, `tests/compare_ymq_vs_shared.py`.
|
||||
5. **Критерий успеха:** 0 зависаний ~51 с, 0 таймаутов 31–33 с.
|
||||
|
||||
Если гипотеза не подтвердится — СТОП, вернуться к разбору (раздел 2, документ о применимости).
|
||||
|
||||
### Шаг 2. Код: разбиение на два процесса
|
||||
- `data-plane` — SQS API и очереди (`app/gosqs/*`, `app/router/router.go`, `app/auth/auth_middleware.go`).
|
||||
- `control-plane` — админка (`app/admin/admin.go`) и управление тенантами (`app/tenant/*`).
|
||||
- Что уже учитывать при разбиении:
|
||||
- адреса очередей формируются жёстко: `app/gosqs/tenant_helpers.go:29-37` →
|
||||
`http://{region}.{host}:{port}/{tenantID}/{queue}`; хост/порт только из `app/conf/goaws.yaml`
|
||||
(`app/conf/config.go:87-90`), env-переменных для Host нет → **внешние `QueueUrl` потребуют правки**;
|
||||
- подпись SigV4 считается по заголовку `Host` (см. выше);
|
||||
- ключ очереди — `{accessKey}:{queueName}` (`app/gosqs/tenant_helpers.go:16`).
|
||||
|
||||
### Шаг 3. Код: вынести состояние (стратегический пункт)
|
||||
- Сейчас: память — источник истины, Redis — write-through (`app/persistence/redis.go`,
|
||||
`LoadAllQueues`), восстановление при старте.
|
||||
- На ВМ поставить Redis и решить, как читать: остаться write-through или перейти к Redis
|
||||
как источнику истины (тогда появится возможность нескольких экземпляров).
|
||||
- Это решение определяет потолок сервиса (сейчас — один под).
|
||||
|
||||
### Шаг 4. IoT — отдельно, после SQS
|
||||
- Внешний MQTT-канал неизбежен (устройства), но там только мелкая телеметрия;
|
||||
критичен разрыв wss ~150 с (лечится переподключением).
|
||||
- Крупные тела устройств через managed-шлюз не пускать (лимит/дробление/приём на ВМ).
|
||||
- IoT переделывается с нуля не монолитом: bridge (MQTT), consumer (→storage), API.
|
||||
|
||||
## 5. Команды (проверены)
|
||||
|
||||
**Локальный запуск (memory-only, без Redis):**
|
||||
```bash
|
||||
cd /home/naeel/nubes/SQS_und_IoT/SQS-service
|
||||
go build -o /tmp/shared-sqs ./app/cmd/
|
||||
SHARED_SQS_SEED_DEMO=true /tmp/shared-sqs --admin-token=dev-token-123 --port=4100
|
||||
# креды демо-тенанта печатаются в лог при старте
|
||||
```
|
||||
⚠️ Не запускать через `| head` — SIGPIPE убивает процесс на середине теста.
|
||||
|
||||
**Проверки кода:**
|
||||
```bash
|
||||
go build ./... && go vet ./... && go test -race ./app/...
|
||||
```
|
||||
|
||||
**Деплой на ВМ:**
|
||||
- зеркало репозитория: `naeel@5.172.178.213:~/terra/SQS-service`;
|
||||
- SSH: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`;
|
||||
- ⚠️ порт **443 на 213 занят** (nginx/xray/hysteria), поэтому публичная точка SQS —
|
||||
отдельный домен на существующем nginx (виртуальный хост/SNI) или отдельный порт;
|
||||
- ⚠️ в образе `Dockerfile` зашит `REDIS_ADDR` внутреннего кластера — на ВМ переопределить
|
||||
(или не задавать, чтобы работать memory-only).
|
||||
|
||||
**Сборка образа (если через Docker):** `make docker-build` (`Makefile`, образ `naeel/shared-sqs`).
|
||||
|
||||
## 6. Грабли и правила (не наступать повторно)
|
||||
|
||||
1. `app/cmd` в `.gitignore` → при добавлении файлов `git add -f app/cmd/...`.
|
||||
2. `deployments/` в этом репозитории **нет** — не искать.
|
||||
3. `Queue.Messages` — tombstone-хранилище: менять только через `AppendMessage`/`RemoveIndex`/
|
||||
`Compact`/`Reset`; число живых = `queue.LiveCount()`, а НЕ `len(queue.Messages)`
|
||||
(`app/models/models.go`).
|
||||
4. После `GetLocked`/`GetRLocked` — `defer Unlock` **до** любых `return` (иначе очередь
|
||||
зависает навсегда; было в `receive_message.go`, исправлено).
|
||||
5. Интеграционные тесты: `tests/api_test.sh` читает `ENDPOINT`, python-тесты — `ENDPOINT_URL`.
|
||||
`boto3` в системе нет (PEP 668) → скачать колеса `pip download boto3`, распаковать в `/tmp`
|
||||
и запускать с `PYTHONPATH`.
|
||||
6. Лимит очередей тенанта = 10 → после упавших прогонов чистить очереди.
|
||||
7. Диагностика зависаний сервиса: стек горутин по `kill -QUIT` (SIGQUIT).
|
||||
8. Правки ingress на платформе — только через `ShturvalServiceConfig` (kubectl откатывает ArgoCD).
|
||||
|
||||
## 7. Правила работы с владельцем (обязательные)
|
||||
|
||||
- Коммитить после каждой логически завершённой правки; **пуш — только по прямой команде**.
|
||||
- Документировать всё, включая собственные ошибки (HISTORY + `doc/thinking/`).
|
||||
- Не выполнять `terraform apply`/`destroy`, не менять платформу, не удалять инстансы.
|
||||
- Не расширять область работ; не лезть в соседние репозитории без прямой команды.
|
||||
- Рекомендация без замера — не рекомендация, а догадка: сначала мерить, потом предлагать.
|
||||
- Вопрос от владельца = только ответ (ничего не делать, пока не последует команда).
|
||||
|
||||
## 8. Открытые вопросы (нужны решения владельца)
|
||||
|
||||
1. **Домен и сертификат** для публичной точки SQS на ВМ (порт 443 занят → виртуальный хост?).
|
||||
2. **Отдельная прод-ВМ** — когда создавать и с какими параметрами (пока хватит 213 для тестов;
|
||||
хватит ли 213 — проверить замером: ядра, память, диск, загрузка).
|
||||
3. **Redis на ВМ** — ставить сейчас или пока memory-only.
|
||||
4. **Вынос состояния** (Шаг 3) — когда начинать; это определяет масштабируемость.
|
||||
5. **Клиенты без явного `endpoint_url`** — есть ли такие (влияет на внешние `QueueUrl`).
|
||||
|
||||
## 9. Карта кода (что где)
|
||||
|
||||
| Путь | Ответственность |
|
||||
|---|---|
|
||||
| `app/cmd/goaws.go` | точка входа, флаги, загрузка конфига, periodic tasks, HTTP-сервер |
|
||||
| `app/router/router.go` | HTTP-роутинг, лимит тела 3 МБ, биллинг/метрики по операциям |
|
||||
| `app/auth/auth_middleware.go` | SigV4 (header + presigned), извлечение тенанта |
|
||||
| `app/auth/jwt.go`, `app/auth/cache.go` | валидация JWT через внешний API + кэш |
|
||||
| `app/gosqs/*` | SQS-операции (send/receive/delete/visibility/queue/attributes/tags) |
|
||||
| `app/gosqs/receive_message.go` | long-polling, выдача сообщений (в т.ч. per-queue лок) |
|
||||
| `app/gosqs/gosqs.go` | фоновый тик: retention, дедупликация, видимость, DLQ |
|
||||
| `app/models/models.go` | `Queue`/`SqsMessage`, индексация (tombstone), FIFO-состояние |
|
||||
| `app/models/globals.go` | `SyncQueues`: map очередей + tenant-индекс + блокировки |
|
||||
| `app/persistence/redis.go` | write-through в Redis, загрузка при старте, переподключение |
|
||||
| `app/admin/admin.go` | админка (тенанты/очереди/сообщения), UI-API по JWT |
|
||||
| `app/tenant/tenant_store.go` | хранилище тенантов, генерация кредов, авто-провижининг по JWT |
|
||||
| `app/billing/billing.go` | учёт операций в PostgreSQL (воркер + буфер) |
|
||||
| `app/metrics/*` | Prometheus-метрики и gauge-обновление |
|
||||
| `app/conf/goaws.yaml` | Host/Port/Region, дефолты очередей (используется для URL) |
|
||||
| `tests/*` | интеграционные проверки (aws CLI, boto3, пробы сети/MTU) |
|
||||
@@ -1,8 +1,10 @@
|
||||
# Makefile — shared-sqs
|
||||
# Created: 2026-04-09
|
||||
# Registry: Docker Hub (naeel/shared-sqs)
|
||||
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
|
||||
# Проверено 2026-10-11: gitea.services.ngcloud.ru/nail/shared-sqs существует (login naeel).
|
||||
|
||||
IMAGE_REPO=naeel/shared-sqs
|
||||
REGISTRY ?= gitea.services.ngcloud.ru/nail
|
||||
IMAGE_REPO=$(REGISTRY)/shared-sqs
|
||||
VERSION=v0.1.35
|
||||
LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION)
|
||||
BINARY=shared-sqs
|
||||
|
||||
+125
-147
@@ -8,18 +8,17 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/auth"
|
||||
"shared-sqs/app/gosqs"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/persistence"
|
||||
"shared-sqs/app/tenant"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -30,17 +29,6 @@ const (
|
||||
uiTenantContextKey uiContextKey = "ui-tenant"
|
||||
)
|
||||
|
||||
// ВАЖНО: значения ниже относятся только к временному demo/showcase режиму.
|
||||
// Перед production rollout без публичного demo-режима этот блок должен быть удалён
|
||||
// вместе с веткой authenticateUIDemoToken и связанными UI-подсказками.
|
||||
const (
|
||||
defaultUIDemoToken = "demo-ui-shared-sqs-ngcloud-2026"
|
||||
defaultUIDemoTenantID = "t-demo-shared-sqs-ngcloud"
|
||||
defaultUIDemoEmail = "demo@shared-sqs.ngcloud"
|
||||
)
|
||||
|
||||
var errUIDemoTokenMismatch = errors.New("demo token mismatch")
|
||||
|
||||
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
||||
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
||||
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||
@@ -69,48 +57,6 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
||||
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
||||
}
|
||||
|
||||
// uiDemoToken — возвращает публичный demo token для UI.
|
||||
// Временный showcase-only путь: в production без demo user этот метод нужно удалить.
|
||||
func (h *Handler) uiDemoToken() string {
|
||||
if token := os.Getenv("SHARED_SQS_UI_DEMO_TOKEN"); token != "" {
|
||||
return token
|
||||
}
|
||||
return defaultUIDemoToken
|
||||
}
|
||||
|
||||
// uiDemoTenantID — возвращает tenant ID, к которому привязан demo token.
|
||||
// Существует только для showcase-режима, не для постоянной production auth-модели.
|
||||
func (h *Handler) uiDemoTenantID() string {
|
||||
if tenantID := os.Getenv("SHARED_SQS_UI_DEMO_TENANT_ID"); tenantID != "" {
|
||||
return tenantID
|
||||
}
|
||||
return defaultUIDemoTenantID
|
||||
}
|
||||
|
||||
// uiDemoEmail — возвращает отображаемый email для demo UI session.
|
||||
// Нужен только для публичного демо-логина и должен уйти вместе с demo path.
|
||||
func (h *Handler) uiDemoEmail() string {
|
||||
if email := os.Getenv("SHARED_SQS_UI_DEMO_EMAIL"); email != "" {
|
||||
return email
|
||||
}
|
||||
return defaultUIDemoEmail
|
||||
}
|
||||
|
||||
// authenticateUIDemoToken — маппит публичный demo token на заранее сидированный demo tenant.
|
||||
// Почему так: это быстрый showcase-вход для заказчика. Для production без demo user
|
||||
// функция должна быть удалена, чтобы в коде не осталось публичного bypass-пути.
|
||||
func (h *Handler) authenticateUIDemoToken(token string) (*tenant.Tenant, string, error) {
|
||||
demoToken := h.uiDemoToken()
|
||||
if demoToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(demoToken)) != 1 {
|
||||
return nil, "", errUIDemoTokenMismatch
|
||||
}
|
||||
demoTenant, ok := h.store.GetByID(h.uiDemoTenantID())
|
||||
if !ok {
|
||||
return nil, "", errors.New("demo tenant unavailable — enable SHARED_SQS_SEED_DEMO=true")
|
||||
}
|
||||
return demoTenant, h.uiDemoEmail(), nil
|
||||
}
|
||||
|
||||
// currentUITenant — возвращает tenant, авторизованный через UI middleware.
|
||||
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
|
||||
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
|
||||
@@ -174,8 +120,12 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
// Timing-safe сравнение: длина префикса и сам токен проверяются отдельно,
|
||||
// само сравнение значения — через subtle.ConstantTimeCompare, чтобы не
|
||||
// давать timing side-channel при подборе admin-токена.
|
||||
expected := "Bearer " + h.adminToken
|
||||
if authHeader != expected {
|
||||
if len(authHeader) != len(expected) ||
|
||||
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
@@ -197,24 +147,6 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if demoTenant, demoEmail, err := h.authenticateUIDemoToken(req.Token); err == nil {
|
||||
log.Infof("ui auth: authenticated demo tenant=%s", demoTenant.ID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"email": demoEmail,
|
||||
"tenant_id": demoTenant.ID,
|
||||
"access_key": demoTenant.AccessKey,
|
||||
"secret_key": demoTenant.SecretKey,
|
||||
"max_queues": demoTenant.MaxQueues,
|
||||
"token": req.Token,
|
||||
})
|
||||
return
|
||||
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
||||
log.Warnf("ui auth: demo login unavailable: %v", err)
|
||||
jsonErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Парсим JWT claims
|
||||
claims, err := auth.ParseJWTClaims(req.Token)
|
||||
if err != nil {
|
||||
@@ -287,8 +219,10 @@ func (h *Handler) uiCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
|
||||
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT
|
||||
if strings.HasSuffix(r.URL.Path, "/auth") {
|
||||
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT.
|
||||
// Сравниваем ТОЧНЫЙ путь, а не суффикс "/auth": иначе, например,
|
||||
// DELETE /ui/api/tenants/{id}/queues/auth пропускался бы без JWT и IDOR-проверки.
|
||||
if r.URL.Path == "/ui/api/auth" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -305,19 +239,6 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
token := parts[1]
|
||||
|
||||
if demoTenant, _, err := h.authenticateUIDemoToken(token); err == nil {
|
||||
if pathTenantID, exists := mux.Vars(r)["id"]; exists && pathTenantID != "" && pathTenantID != demoTenant.ID {
|
||||
jsonErr(w, http.StatusForbidden, "forbidden tenant access")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), uiTenantContextKey, demoTenant)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
||||
jsonErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := auth.ParseJWTClaims(token)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||
@@ -356,6 +277,10 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||
type createTenantRequest struct {
|
||||
Name string `json:"name"`
|
||||
MaxQueues int `json:"max_queues"`
|
||||
// BYOC: необязательные произвольные креды. Если задано одно поле —
|
||||
// оба обязательны.
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
SecretKey string `json:"secret_key,omitempty"`
|
||||
}
|
||||
|
||||
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
|
||||
@@ -398,7 +323,23 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
t, err := h.store.Create(req.Name, req.MaxQueues)
|
||||
|
||||
// BYOC: либо оба поля кредов заданы, либо ни одного.
|
||||
var t *tenant.Tenant
|
||||
var err error
|
||||
if req.AccessKey != "" || req.SecretKey != "" {
|
||||
if req.AccessKey == "" || req.SecretKey == "" {
|
||||
jsonErr(w, http.StatusBadRequest, "both access_key and secret_key are required when specifying custom credentials")
|
||||
return
|
||||
}
|
||||
if len(req.AccessKey) < 8 || len(req.SecretKey) < 16 {
|
||||
jsonErr(w, http.StatusBadRequest, "access_key min 8 chars, secret_key min 16 chars")
|
||||
return
|
||||
}
|
||||
t, err = h.store.CreateFixedAutoID(req.Name, req.MaxQueues, req.AccessKey, req.SecretKey)
|
||||
} else {
|
||||
t, err = h.store.Create(req.Name, req.MaxQueues)
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorf("admin: failed to create tenant: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -466,15 +407,14 @@ func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
// Удаляем все очереди тенанта из SyncQueues
|
||||
prefix := t.AccessKey + ":"
|
||||
deletedQueueKeys := make([]string, 0)
|
||||
// Удаляем все очереди тенанта из SyncQueues (через tenant-индекс, без скана всех очередей)
|
||||
models.SyncQueues.Lock()
|
||||
for key := range models.SyncQueues.Queues {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
tenantQueueNames := models.SyncQueues.TenantQueueNames(t.AccessKey)
|
||||
deletedQueueKeys := make([]string, 0, len(tenantQueueNames))
|
||||
for _, name := range tenantQueueNames {
|
||||
key := t.AccessKey + ":" + name
|
||||
deletedQueueKeys = append(deletedQueueKeys, key)
|
||||
delete(models.SyncQueues.Queues, key)
|
||||
}
|
||||
models.SyncQueues.RemoveQueue(key)
|
||||
}
|
||||
models.SyncQueues.Unlock()
|
||||
for _, queueKey := range deletedQueueKeys {
|
||||
@@ -507,30 +447,42 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
|
||||
RetentionPeriod int `json:"retention_period"`
|
||||
}
|
||||
queues := make([]queueInfo, 0)
|
||||
type nameQueue struct {
|
||||
name string
|
||||
q *models.Queue
|
||||
}
|
||||
models.SyncQueues.RLock()
|
||||
for key, q := range models.SyncQueues.Queues {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
name := strings.TrimPrefix(key, prefix)
|
||||
pairs := make([]nameQueue, 0)
|
||||
for _, name := range models.SyncQueues.TenantQueueNames(t.AccessKey) {
|
||||
if q := models.SyncQueues.Queues[prefix+name]; q != nil {
|
||||
pairs = append(pairs, nameQueue{name, q})
|
||||
}
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
for _, p := range pairs {
|
||||
p.q.RLockMu()
|
||||
visible := 0
|
||||
notVisible := 0
|
||||
for _, msg := range q.Messages {
|
||||
if msg.ReceiptHandle != "" {
|
||||
for i := range p.q.Messages {
|
||||
if p.q.IsDead(i) {
|
||||
continue
|
||||
}
|
||||
if p.q.Messages[i].ReceiptHandle != "" {
|
||||
notVisible++
|
||||
} else {
|
||||
visible++
|
||||
}
|
||||
}
|
||||
queues = append(queues, queueInfo{
|
||||
Name: name,
|
||||
Name: p.name,
|
||||
Messages: visible,
|
||||
NotVisible: notVisible,
|
||||
VisibilityTimeout: q.VisibilityTimeout,
|
||||
MaxMessageSize: q.MaximumMessageSize,
|
||||
RetentionPeriod: q.MessageRetentionPeriod,
|
||||
VisibilityTimeout: p.q.VisibilityTimeout,
|
||||
MaxMessageSize: p.q.MaximumMessageSize,
|
||||
RetentionPeriod: p.q.MessageRetentionPeriod,
|
||||
})
|
||||
p.q.RUnlockMu()
|
||||
}
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(queues)
|
||||
}
|
||||
@@ -558,6 +510,12 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
// Та же валидация имени, что и в SQS CreateQueue (иначе имена с ':'/'/'
|
||||
// ломали бы ключ "{accessKey}:{name}").
|
||||
if err := gosqs.ValidateQueueName(req.Name); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, "invalid queue name")
|
||||
return
|
||||
}
|
||||
key := t.AccessKey + ":" + req.Name
|
||||
models.SyncQueues.Lock()
|
||||
if _, exists := models.SyncQueues.Queues[key]; exists {
|
||||
@@ -565,27 +523,18 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, http.StatusConflict, "queue already exists")
|
||||
return
|
||||
}
|
||||
// Проверяем лимит очередей тенанта
|
||||
count := 0
|
||||
for k := range models.SyncQueues.Queues {
|
||||
if strings.HasPrefix(k, t.AccessKey+":") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
// Проверяем лимит очередей тенанта (через tenant-индекс, O(1))
|
||||
count := models.SyncQueues.CountTenantQueues(t.AccessKey)
|
||||
if t.MaxQueues > 0 && count >= t.MaxQueues {
|
||||
models.SyncQueues.Unlock()
|
||||
jsonErr(w, http.StatusForbidden, "queue limit exceeded")
|
||||
return
|
||||
}
|
||||
models.SyncQueues.Queues[key] = &models.Queue{
|
||||
Name: req.Name,
|
||||
VisibilityTimeout: 30,
|
||||
MaximumMessageSize: 262144,
|
||||
MessageRetentionPeriod: 345600,
|
||||
Messages: []models.SqsMessage{},
|
||||
Duplicates: make(map[string]time.Time),
|
||||
}
|
||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
||||
// Очередь создаётся через общий билдер — те же URL/ARN/дефолты и FIFO-детект,
|
||||
// что и в SQS CreateQueue (единый путь, без расхождения поведения UI и API).
|
||||
newQueue := gosqs.BuildQueueForTenant(t, req.Name)
|
||||
models.SyncQueues.AddQueue(key, newQueue)
|
||||
persistence.SaveQueue(key, newQueue)
|
||||
models.SyncQueues.Unlock()
|
||||
log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -610,7 +559,7 @@ func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||
return
|
||||
}
|
||||
delete(models.SyncQueues.Queues, key)
|
||||
models.SyncQueues.RemoveQueue(key)
|
||||
models.SyncQueues.Unlock()
|
||||
persistence.DeleteQueue(key)
|
||||
log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID)
|
||||
@@ -659,12 +608,16 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
models.SyncQueues.RLock()
|
||||
result := make([]peekMessageItem, 0, len(q.Messages))
|
||||
for i, msg := range q.Messages {
|
||||
if i >= limit {
|
||||
q.RLockMu()
|
||||
result := make([]peekMessageItem, 0, limit)
|
||||
for i := range q.Messages {
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
if q.IsDead(i) {
|
||||
continue
|
||||
}
|
||||
msg := &q.Messages[i]
|
||||
result = append(result, peekMessageItem{
|
||||
ID: msg.Uuid,
|
||||
Body: msg.MessageBody,
|
||||
@@ -674,7 +627,7 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
|
||||
InFlight: msg.ReceiptHandle != "",
|
||||
})
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
q.RUnlockMu()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
@@ -705,17 +658,30 @@ func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, http.StatusBadRequest, "body is required")
|
||||
return
|
||||
}
|
||||
msg := models.SqsMessage{
|
||||
MessageBody: req.Body,
|
||||
Uuid: uuid.NewString(),
|
||||
SentTime: time.Now(),
|
||||
// Сообщение строится через общий билдер (UUID + MD5), + проверка размера и
|
||||
// лимита — как в SQS SendMessage (иначе UI обходил бы OOM-защиту).
|
||||
q.LockMu()
|
||||
if q.MaximumMessageSize > 0 && len(req.Body) > q.MaximumMessageSize {
|
||||
q.UnlockMu()
|
||||
jsonErr(w, http.StatusBadRequest, "message too big")
|
||||
return
|
||||
}
|
||||
models.SyncQueues.Lock()
|
||||
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
|
||||
// Персистим одно сообщение отдельно
|
||||
persistence.SaveMessage(key, &msg)
|
||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
||||
models.SyncQueues.Unlock()
|
||||
if q.LiveCount() >= gosqs.MaxMessagesForQueue(q.IsFIFO) {
|
||||
q.UnlockMu()
|
||||
jsonErr(w, http.StatusTooManyRequests, "queue is full")
|
||||
return
|
||||
}
|
||||
msg := gosqs.BuildSqsMessage(req.Body)
|
||||
idx := q.AppendMessage(msg)
|
||||
// Персистим СИНХРОННО (как SendMessage) — иначе 201 без гарантии записи.
|
||||
if err := persistence.SaveMessageSync(key, &msg); err != nil {
|
||||
q.RemoveIndex(idx)
|
||||
q.UnlockMu()
|
||||
jsonErr(w, http.StatusInternalServerError, "failed to persist message")
|
||||
return
|
||||
}
|
||||
persistence.SaveQueue(key, q)
|
||||
q.UnlockMu()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"})
|
||||
@@ -736,12 +702,12 @@ func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||
return
|
||||
}
|
||||
models.SyncQueues.Lock()
|
||||
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0]
|
||||
q.LockMu()
|
||||
q.Reset()
|
||||
// Удаляем все сообщения из Redis одной командой
|
||||
persistence.PurgeMessagesPersist(key)
|
||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
||||
models.SyncQueues.Unlock()
|
||||
persistence.SaveQueue(key, q)
|
||||
q.UnlockMu()
|
||||
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -769,13 +735,19 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
||||
queueCount := 0
|
||||
msgCount := 0
|
||||
models.SyncQueues.RLock()
|
||||
uiQueues := make([]*models.Queue, 0)
|
||||
for key, q := range models.SyncQueues.Queues {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
queueCount++
|
||||
msgCount += len(q.Messages)
|
||||
uiQueues = append(uiQueues, q)
|
||||
}
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
for _, q := range uiQueues {
|
||||
q.RLockMu()
|
||||
msgCount += q.LiveCount()
|
||||
q.RUnlockMu()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(adminHealthDetail{
|
||||
@@ -790,11 +762,17 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
||||
tenants := h.store.List()
|
||||
models.SyncQueues.RLock()
|
||||
queueCount := len(models.SyncQueues.Queues)
|
||||
msgCount := 0
|
||||
allQueues := make([]*models.Queue, 0, queueCount)
|
||||
for _, q := range models.SyncQueues.Queues {
|
||||
msgCount += len(q.Messages)
|
||||
allQueues = append(allQueues, q)
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
msgCount := 0
|
||||
for _, q := range allQueues {
|
||||
q.RLockMu()
|
||||
msgCount += q.LiveCount()
|
||||
q.RUnlockMu()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(adminHealthDetail{
|
||||
|
||||
+5
-26
@@ -1,7 +1,5 @@
|
||||
// app/admin/admin_test.go
|
||||
// Focused tests for UI auth and scoping
|
||||
// Created: 2026-04-12 09:28 MSK
|
||||
// Updated: 2026-04-12 09:28 MSK — demo UI token and tenant-scoped UI responses
|
||||
package admin
|
||||
|
||||
import (
|
||||
@@ -15,37 +13,18 @@ import (
|
||||
"shared-sqs/app/tenant"
|
||||
)
|
||||
|
||||
// TestJWTAuth_AllowsDemoToken verifies that the public demo token authenticates into the seeded demo tenant.
|
||||
func TestJWTAuth_AllowsDemoToken(t *testing.T) {
|
||||
t.Setenv("SHARED_SQS_UI_DEMO_TOKEN", "demo-token-for-test")
|
||||
t.Setenv("SHARED_SQS_UI_DEMO_TENANT_ID", "t-demo-test")
|
||||
|
||||
// TestJWTAuth_RejectsMissingToken verifies jwtAuth rejects a request without a token.
|
||||
func TestJWTAuth_RejectsMissingToken(t *testing.T) {
|
||||
store := tenant.NewTenantStore()
|
||||
demoTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFixed(): %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(store, "admin-token")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{"token":"demo-token-for-test"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.jwtAuth(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("jwtAuth() status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got := resp["tenant_id"]; got != demoTenant.ID {
|
||||
t.Fatalf("tenant_id = %v, want %s", got, demoTenant.ID)
|
||||
}
|
||||
if got := resp["access_key"]; got != demoTenant.AccessKey {
|
||||
t.Fatalf("access_key = %v, want %s", got, demoTenant.AccessKey)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("jwtAuth() status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/persistence"
|
||||
"shared-sqs/app/tenant"
|
||||
)
|
||||
|
||||
@@ -59,6 +61,17 @@ func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
t, ok := store.GetByAccessKey(accessKeyID)
|
||||
if !ok {
|
||||
// Read-through из Redis: тенант мог быть создан control-plane / другим
|
||||
// инстансом и ещё не попасть в локальную память.
|
||||
if raw, found := persistence.LoadTenantByAccessKey(accessKeyID); found {
|
||||
var loaded tenant.Tenant
|
||||
if err := json.Unmarshal(raw, &loaded); err == nil {
|
||||
store.LoadTenant(&loaded)
|
||||
t, ok = store.GetByAccessKey(accessKeyID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok || !t.Active {
|
||||
writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid")
|
||||
return
|
||||
@@ -150,9 +163,14 @@ func verifyHeaderSigV4(r *http.Request, t *tenant.Tenant, authHeader string) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualHash := sha256Hex(body)
|
||||
payloadHash := r.Header.Get("X-Amz-Content-Sha256")
|
||||
if payloadHash == "" {
|
||||
payloadHash = sha256Hex(body)
|
||||
payloadHash = actualHash
|
||||
} else if !strings.EqualFold(payloadHash, actualHash) && !strings.EqualFold(payloadHash, "UNSIGNED-PAYLOAD") {
|
||||
// Заголовок заявлен, но не совпадает с фактическим хэшем тела: клиент
|
||||
// не должен иметь возможность подменить тело под подписанным заголовком.
|
||||
return errors.New("X-Amz-Content-Sha256 does not match body hash")
|
||||
}
|
||||
|
||||
canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, true)
|
||||
|
||||
+14
-7
@@ -28,6 +28,7 @@ const cacheCleanupInterval = 5 * time.Minute
|
||||
|
||||
type tokenCacheEntry struct {
|
||||
validUntil time.Time
|
||||
graceDeadline time.Time // потолок grace от последней УСПЕШНОЙ проверки (не продлевается)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -66,18 +67,20 @@ func PingNubesAPICached(ctx context.Context, endpoint, token string) error {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
// Сетевая ошибка шлюза: если токен ранее был валиден — даём grace.
|
||||
if ok {
|
||||
tokenCacheMu.Lock()
|
||||
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheGraceTTL)}
|
||||
tokenCacheMu.Unlock()
|
||||
// Сетевая ошибка шлюза: если токен ранее был валиден — даём grace, но
|
||||
// НЕ дальше graceDeadline. Раньше validUntil сдвигали вперёд на каждый
|
||||
// сетевой сбой — отозванный токен жил бы вечно при повторных ошибках.
|
||||
if ok && now.Before(entry.graceDeadline) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
tokenCacheMu.Lock()
|
||||
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)}
|
||||
tokenCache[key] = tokenCacheEntry{
|
||||
validUntil: now.Add(cacheTTL),
|
||||
graceDeadline: now.Add(cacheGraceTTL),
|
||||
}
|
||||
tokenCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -85,8 +88,12 @@ func PingNubesAPICached(ctx context.Context, endpoint, token string) error {
|
||||
// WarmTokenCache — записывает токен в кэш как валидный без вызова шлюза.
|
||||
// Используется после успешной свежей проверки при логине (jwtAuth).
|
||||
func WarmTokenCache(token string) {
|
||||
now := time.Now()
|
||||
tokenCacheMu.Lock()
|
||||
tokenCache[tokenCacheKey(token)] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)}
|
||||
tokenCache[tokenCacheKey(token)] = tokenCacheEntry{
|
||||
validUntil: now.Add(cacheTTL),
|
||||
graceDeadline: now.Add(cacheGraceTTL),
|
||||
}
|
||||
tokenCacheMu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
+21
-9
@@ -59,7 +59,10 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
|
||||
if claims.Sub == "" {
|
||||
return nil, fmt.Errorf("JWT missing 'sub' claim")
|
||||
}
|
||||
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
|
||||
if claims.Exp == 0 {
|
||||
return nil, fmt.Errorf("JWT missing 'exp' claim")
|
||||
}
|
||||
if claims.Exp < time.Now().Unix() {
|
||||
return nil, fmt.Errorf("JWT token expired")
|
||||
}
|
||||
return &claims, nil
|
||||
@@ -71,17 +74,22 @@ var ErrTokenRejected = errors.New("token rejected by nubes API")
|
||||
|
||||
// nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен.
|
||||
// Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял.
|
||||
// Юзер может вводить токен от любого стенда — главное, что токен действительный.
|
||||
// Безопасность: только prod-шлюз. Тестовый/dev-шлюзы принимают токены своих
|
||||
// стендов, и при их наличии токен тестового стенда считался бы валидным на проде.
|
||||
// Специфичный endpoint можно переопределить через NUBES_ENDPOINT.
|
||||
var nubesAPIEndpoints = []string{
|
||||
"https://lk-api-gateway.ngcloud.ru/api/v1/svc",
|
||||
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
||||
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
||||
}
|
||||
|
||||
// nubesHTTPClient — общий клиент для валидации токенов: переиспользует
|
||||
// соединения (keep-alive) вместо создания нового клиента на каждый запрос.
|
||||
var nubesHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// PingNubesAPI — проверяет валидность токена перебором стендов Nubes API.
|
||||
// endpoint (если задан через NUBES_ENDPOINT) пробуется первым.
|
||||
// Логика: 401/403 → токен отклонён ЭТИМ стендом, пробуем следующий.
|
||||
// Любой другой HTTP статус → токен принят.
|
||||
// Логика: токен принимается ТОЛЬКО при явном HTTP 2xx от стенда.
|
||||
// 401/403 → токен отклонён ЭТИМ стендом (ErrTokenRejected), пробуем следующий.
|
||||
// Любой другой статус (4xx/5xx) → стенд НЕ подтвердил токен, пробуем следующий.
|
||||
func PingNubesAPI(ctx context.Context, endpoint, token string) error {
|
||||
candidates := make([]string, 0, len(nubesAPIEndpoints)+1)
|
||||
if endpoint != "" {
|
||||
@@ -103,19 +111,23 @@ func PingNubesAPI(ctx context.Context, endpoint, token string) error {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := nubesHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Принимаем токен ТОЛЬКО при явном 2xx: никакой другой статус (5xx,
|
||||
// 429, редирект и т.п.) не должен считаться подтверждением валидности.
|
||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
lastErr = fmt.Errorf("nubes API %s returned unexpected status HTTP %d", ep, resp.StatusCode)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("nubes API rejected token")
|
||||
|
||||
+114
-25
@@ -9,6 +9,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
// PostgreSQL драйвер — регистрируется в database/sql через init()
|
||||
_ "github.com/lib/pq"
|
||||
@@ -16,8 +17,26 @@ import (
|
||||
)
|
||||
|
||||
// db — подключение к PostgreSQL для записи usage-данных.
|
||||
// nil если billing отключён.
|
||||
var db *sql.DB
|
||||
// nil если billing отключён. Доступ — только через getDB/setDB (мьютекс):
|
||||
// usageWorker читает db параллельно с Close/Init — иначе data race и nil-паника.
|
||||
var (
|
||||
db *sql.DB
|
||||
dbMu sync.Mutex
|
||||
)
|
||||
|
||||
// getDB — потокобезопасное чтение db.
|
||||
func getDB() *sql.DB {
|
||||
dbMu.Lock()
|
||||
defer dbMu.Unlock()
|
||||
return db
|
||||
}
|
||||
|
||||
// setDB — потокобезопасная установка db.
|
||||
func setDB(d *sql.DB) {
|
||||
dbMu.Lock()
|
||||
db = d
|
||||
dbMu.Unlock()
|
||||
}
|
||||
|
||||
// Init — подключается к PostgreSQL и создаёт таблицу sqs_usage_records если не существует.
|
||||
// Env переменные: BILLING_PG_HOST, BILLING_PG_PORT, BILLING_PG_DATABASE, BILLING_PG_USER,
|
||||
@@ -46,28 +65,28 @@ func Init() {
|
||||
host, port, dbname, user, password, sslmode)
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("postgres", dsn)
|
||||
d, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
log.Errorf("billing: failed to open PostgreSQL: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем реальное подключение (Open не подключается)
|
||||
if err = db.Ping(); err != nil {
|
||||
if err = d.Ping(); err != nil {
|
||||
log.Errorf("billing: failed to connect to PostgreSQL: %v", err)
|
||||
db.Close()
|
||||
db = nil
|
||||
d.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Ограничиваем пул — billing не должен отжирать коннекты у основной БД
|
||||
db.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(2)
|
||||
d.SetMaxOpenConns(5)
|
||||
d.SetMaxIdleConns(2)
|
||||
|
||||
setDB(d)
|
||||
if err = autoMigrate(); err != nil {
|
||||
log.Errorf("billing: failed to create table: %v", err)
|
||||
db.Close()
|
||||
db = nil
|
||||
setDB(nil)
|
||||
d.Close()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,7 +96,7 @@ func Init() {
|
||||
// autoMigrate — создаёт таблицу и индекс если не существуют.
|
||||
// Идемпотентно — безопасно вызывать при каждом старте.
|
||||
func autoMigrate() error {
|
||||
_, err := db.Exec(`
|
||||
_, err := getDB().Exec(`
|
||||
CREATE TABLE IF NOT EXISTS sqs_usage_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
@@ -98,35 +117,105 @@ func autoMigrate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordUsage — записывает одну SQS-операцию в таблицу биллинга.
|
||||
// Вызывается асинхронно (горутина) чтобы не добавлять latency к SQS-ответу.
|
||||
// Если billing отключён — no-op. Ошибки логируются, SQS-операция не ломается.
|
||||
func RecordUsage(tenantID, operation, queueName string, msgCount int, msgBytes int64) {
|
||||
if db == nil {
|
||||
return
|
||||
// usageRecord — одна запись биллинга, накапливается в буфере воркера.
|
||||
type usageRecord struct {
|
||||
tenantID string
|
||||
operation string
|
||||
queueName string
|
||||
msgCount int
|
||||
msgBytes int64
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := db.Exec(
|
||||
// usageChan — канал-буфер для асинхронных записей биллинга.
|
||||
// Вместо горутины на каждую операцию (навал горутин под нагрузкой) используется
|
||||
// один воркер, читающий из буферизованного канала.
|
||||
var usageChan = make(chan usageRecord, 1024)
|
||||
|
||||
// done — сигнал остановки воркера при graceful shutdown.
|
||||
var done = make(chan struct{})
|
||||
|
||||
// closeOnce гарантирует однократный close(done).
|
||||
var closeOnce sync.Once
|
||||
|
||||
// workerWg — ожидание завершения воркера перед закрытием db (иначе Close обнулил
|
||||
// бы db, пока воркер пишет — nil-паника + потеря остатка буфера usageChan).
|
||||
var workerWg sync.WaitGroup
|
||||
|
||||
// init запускает воркер-горутину биллинга. Воркер стартует всегда; если db == nil
|
||||
// (billing отключён) — он просто дропает записи.
|
||||
func init() {
|
||||
workerWg.Add(1)
|
||||
go usageWorker()
|
||||
}
|
||||
|
||||
// usageWorker — единственная горутина, пишущая usage-записи в PostgreSQL.
|
||||
func usageWorker() {
|
||||
defer workerWg.Done()
|
||||
for {
|
||||
select {
|
||||
case rec := <-usageChan:
|
||||
processUsage(rec)
|
||||
case <-done:
|
||||
// Дренируем остаток буфера перед выходом (не теряем usage).
|
||||
for {
|
||||
select {
|
||||
case rec := <-usageChan:
|
||||
processUsage(rec)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processUsage — одна запись usage в PostgreSQL (db == nil → дроп).
|
||||
func processUsage(rec usageRecord) {
|
||||
d := getDB()
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO sqs_usage_records (tenant_id, operation, queue_name, msg_count, msg_bytes) VALUES ($1, $2, $3, $4, $5)`,
|
||||
tenantID, operation, queueName, msgCount, msgBytes,
|
||||
rec.tenantID, rec.operation, rec.queueName, rec.msgCount, rec.msgBytes,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("billing: failed to record usage [%s/%s]: %v", tenantID, operation, err)
|
||||
log.Errorf("billing: failed to record usage [%s/%s]: %v", rec.tenantID, rec.operation, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordUsage — ставит запись в очередь биллинга (асинхронно, без блокировки SQS).
|
||||
// Если billing отключён — no-op. Если буфер переполнен — запись дропается (биллинг
|
||||
// жертвуем ради стабильности основного пути), ошибка логируется.
|
||||
func RecordUsage(tenantID, operation, queueName string, msgCount int, msgBytes int64) {
|
||||
if getDB() == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case usageChan <- usageRecord{tenantID, operation, queueName, msgCount, msgBytes}:
|
||||
case <-done:
|
||||
// Воркер остановлен (shutdown) — запись не примется.
|
||||
default:
|
||||
log.Warnf("billing: буфер полон, запись [%s/%s] дропнута", tenantID, operation)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Enabled — возвращает true если billing подключён к PostgreSQL
|
||||
func Enabled() bool {
|
||||
return db != nil
|
||||
return getDB() != nil
|
||||
}
|
||||
|
||||
// Close — закрывает подключение к PostgreSQL. Вызывается при graceful shutdown.
|
||||
// Close — останавливает воркер, дожидается его завершения и закрывает db.
|
||||
// Вызывается при graceful shutdown. Порядок обязателен: сначала воркер,
|
||||
// потом db.Close — иначе гонка db=nil с Exec воркера (nil-паника).
|
||||
func Close() {
|
||||
closeOnce.Do(func() { close(done) })
|
||||
workerWg.Wait()
|
||||
dbMu.Lock()
|
||||
if db != nil {
|
||||
db.Close()
|
||||
db = nil
|
||||
}
|
||||
dbMu.Unlock()
|
||||
log.Info("billing: PostgreSQL connection closed")
|
||||
}
|
||||
}
|
||||
|
||||
+110
-21
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"shared-sqs/app/billing"
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/gosqs"
|
||||
"shared-sqs/app/leader"
|
||||
"shared-sqs/app/metrics"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/persistence"
|
||||
@@ -29,14 +31,24 @@ func main() {
|
||||
var configFile string
|
||||
var adminToken string
|
||||
var port string
|
||||
var controlPort string
|
||||
var debug bool
|
||||
var loglevel string
|
||||
var leaderElection bool
|
||||
var leaderTTL time.Duration
|
||||
var leaderInterval time.Duration
|
||||
|
||||
flag.StringVar(&configFile, "config", "", "config file location")
|
||||
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
||||
flag.StringVar(&port, "port", "4100", "listen port")
|
||||
flag.StringVar(&port, "port", "4100", "data-plane listen port")
|
||||
flag.StringVar(&controlPort, "control-port", "4101", "control-plane listen port (localhost only: admin/metrics/ui)")
|
||||
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
||||
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
||||
// Leader-election (active/passive HA): выключено по умолчанию — поведение
|
||||
// одиночного инстанса не меняется. Подробно — doc/thinking/state-extraction-and-ha-2026-10-10.md
|
||||
flag.BoolVar(&leaderElection, "leader-election", false, "enable leader election via Redis lock (active/passive HA); off = always ready")
|
||||
flag.DurationVar(&leaderTTL, "leader-ttl", 10*time.Second, "leader lock TTL (should be > 3x leader-interval)")
|
||||
flag.DurationVar(&leaderInterval, "leader-interval", 3*time.Second, "leader lock refresh/acquire interval")
|
||||
flag.Parse()
|
||||
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
@@ -89,12 +101,15 @@ func main() {
|
||||
redisPass := os.Getenv("REDIS_PASSWORD")
|
||||
if redisAddr != "" {
|
||||
if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil {
|
||||
log.Warnf("Не удалось подключиться к Redis: %v — работаем в memory-only режиме", err)
|
||||
// Fail-fast: при явно заданном REDIS_ADDR, но недоступном Redis,
|
||||
// продолжать опасно — SaveQueue перезапишет Redis пустотой, read-through
|
||||
// и failover сломаются. Падаем, чтобы systemd перезапустил.
|
||||
log.Fatalf("Не удалось подключиться к Redis (%s): %v", redisAddr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Восстановление состояния из Redis (тенанты + очереди)
|
||||
if persistence.Client != nil {
|
||||
if persistence.Client.Load() != nil {
|
||||
// Загружаем тенантов
|
||||
tenantsRaw, err := persistence.LoadAllTenantsRaw()
|
||||
if err != nil {
|
||||
@@ -112,16 +127,26 @@ func main() {
|
||||
// Загружаем очереди
|
||||
queues, err := persistence.LoadAllQueues()
|
||||
if err != nil {
|
||||
log.Warnf("Ошибка загрузки очередей из Redis: %v", err)
|
||||
log.Fatalf("Ошибка загрузки очередей из Redis: %v", err)
|
||||
} else {
|
||||
models.SyncQueues.Lock()
|
||||
for k, q := range queues {
|
||||
models.SyncQueues.Queues[k] = q
|
||||
models.SyncQueues.AddQueue(k, q)
|
||||
}
|
||||
models.SyncQueues.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Бэкфилл индекса accessKey→tenant для read-through (идемпотентно; нужен для
|
||||
// тенантов, созданных до появления индекса, и для multi-instance сценария).
|
||||
if persistence.Client.Load() != nil {
|
||||
for _, t := range tenantStore.List() {
|
||||
if data, err := json.Marshal(t); err == nil {
|
||||
persistence.SaveTenantRaw(t.ID, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
|
||||
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
|
||||
seedDemoData(tenantStore)
|
||||
@@ -130,8 +155,39 @@ func main() {
|
||||
// Billing: подключение к PostgreSQL для учёта использования.
|
||||
// Если BILLING_PG_HOST не задан — billing отключён, SQS работает без него.
|
||||
billing.Init()
|
||||
// Роутер с tenant auth и admin API
|
||||
r := router.New(tenantStore, adminToken)
|
||||
// Разбиение data-plane/control-plane (Шаг 2):
|
||||
// data-plane — публичный SQS API; control-plane — админка/метрики/UI (только localhost).
|
||||
dataRouter := router.NewDataPlane(tenantStore)
|
||||
controlRouter := router.NewControlPlane(tenantStore, adminToken)
|
||||
|
||||
// Leader-election (active/passive, Шаг 3 — вариант C).
|
||||
// Включается флагом --leader-election или SHARED_SQS_LEADER_ELECTION=true.
|
||||
// Только лидер отдаёт /ready → 200; пассивный — 503 → LB/failover держит
|
||||
// трафик на активном. Без флага ReadyFunc = nil (всегда готов) — одиночный
|
||||
// инстанс работает ровно как раньше.
|
||||
//
|
||||
// Ограничение (осознанное, см. док): in-memory состояние НЕ шарится. Пока
|
||||
// второй инстанс запущен «горячим», очереди живут в Redis, но изменения
|
||||
// второго (пассивного) инстанса не увидит активный до рестарта. Поэтому
|
||||
// на фазе C пассивный НЕ должен принимать SQS-трафик: nginx/LB обязан
|
||||
// проверять /ready и не отправлять API-запросы на 503.
|
||||
if !leaderElection && os.Getenv("SHARED_SQS_LEADER_ELECTION") == "true" {
|
||||
leaderElection = true
|
||||
}
|
||||
var elector *leader.Elector
|
||||
if leaderElection {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
instanceID := fmt.Sprintf("%s-%d", hostname, os.Getpid())
|
||||
elector = leader.New("ssq:leader", instanceID, leaderTTL, leaderInterval)
|
||||
elector.Start()
|
||||
router.ReadyFunc = elector.IsLeader
|
||||
log.Infof("leader-election включён (id=%s, ttl=%s, interval=%s)", instanceID, leaderTTL, leaderInterval)
|
||||
} else {
|
||||
log.Info("leader-election выключен — инстанс всегда ready (одиночный режим)")
|
||||
}
|
||||
|
||||
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
||||
quit := make(chan bool)
|
||||
@@ -140,20 +196,43 @@ func main() {
|
||||
// Metrics: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд
|
||||
metrics.StartGaugeUpdater(15*time.Second, quit)
|
||||
|
||||
// HTTP сервер с таймаутами
|
||||
srv := &http.Server{
|
||||
// HTTP сервер с таймаутами (рекомендации ревью Соннета, план v0.1.35):
|
||||
// - ReadHeaderTimeout 10с — лимит ТОЛЬКО на чтение заголовков (slowloris).
|
||||
// ReadTimeout НЕ ставим: он обрывал бы запросы с медленной/большой
|
||||
// загрузкой тела (в т.ч. большие SendMessage) и ломал бы long-poll.
|
||||
// - WriteTimeout 35с — ответ; чуть больше max WaitTimeSeconds (20с long-poll).
|
||||
// - IdleTimeout 90с — keep-alive; больше интервала ретраев клиентских SDK.
|
||||
// - MaxHeaderBytes 8192 — защита от гигантских заголовков
|
||||
// (платформа режет >16KB, наш лимит строже).
|
||||
dataSrv := &http.Server{
|
||||
Addr: "0.0.0.0:" + port,
|
||||
Handler: r,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
|
||||
IdleTimeout: 60 * time.Second,
|
||||
Handler: dataRouter,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 35 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 8192,
|
||||
}
|
||||
// control-plane слушает ТОЛЬКО localhost — админка/метрики/UI не должны быть публичными.
|
||||
controlSrv := &http.Server{
|
||||
Addr: "127.0.0.1:" + controlPort,
|
||||
Handler: controlRouter,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 35 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 8192,
|
||||
}
|
||||
|
||||
// Запуск в горутине для graceful shutdown
|
||||
serverErr := make(chan error, 1)
|
||||
// Запуск в горутинах для graceful shutdown
|
||||
serverErr := make(chan error, 2)
|
||||
go func() {
|
||||
log.Infof("shared-sqs listening on 0.0.0.0:%s", port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Infof("shared-sqs data-plane listening on 0.0.0.0:%s", port)
|
||||
if err := dataSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
serverErr <- err
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
log.Infof("shared-sqs control-plane listening on 127.0.0.1:%s", controlPort)
|
||||
if err := controlSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
serverErr <- err
|
||||
}
|
||||
}()
|
||||
@@ -172,14 +251,24 @@ func main() {
|
||||
// Остановить PeriodicTasks
|
||||
close(quit)
|
||||
|
||||
// Закрыть billing (если был подключён)
|
||||
billing.Close()
|
||||
// Отпустить лидерский лок (если были лидером) до закрытия слушателей,
|
||||
// чтобы пассивный взял его быстрее, чем через TTL.
|
||||
if elector != nil {
|
||||
elector.Stop()
|
||||
}
|
||||
|
||||
// Дать 10 секунд на завершение текущих HTTP запросов
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Errorf("Server shutdown error: %v", err)
|
||||
if err := dataSrv.Shutdown(ctx); err != nil {
|
||||
log.Errorf("Data-plane shutdown error: %v", err)
|
||||
}
|
||||
if err := controlSrv.Shutdown(ctx); err != nil {
|
||||
log.Errorf("Control-plane shutdown error: %v", err)
|
||||
}
|
||||
|
||||
// Закрыть billing ПОСЛЕ Shutdown — иначе in-flight запросы писали бы usage
|
||||
// в уже закрытую БД (ревью 2026-10-11).
|
||||
billing.Close()
|
||||
log.Info("shared-sqs stopped")
|
||||
}
|
||||
|
||||
+28
-23
@@ -1,12 +1,11 @@
|
||||
// app/cmd/seed.go
|
||||
// Created: 2026-04-09
|
||||
// Updated: 2026-04-09 — фиксированные credentials для demo-tenant (BYOC)
|
||||
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
|
||||
// Создаёт тенанта demo-service с 5 очередями и демо-сообщениями.
|
||||
// Создаёт тенанта demo-service со случайными кредами (генерируются через
|
||||
// tenant.Create → crypto/rand) с 5 очередями и демо-сообщениями.
|
||||
//
|
||||
// DEMO CREDENTIALS — только для тестового стенда.
|
||||
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
|
||||
// admin API и к очередям других тенантов. Credentials открыты намеренно — стенд публичный.
|
||||
// admin API и к очередям других тенантов. Креды генерируются случайно и печатаются
|
||||
// в лог при старте (только при явном включении SHARED_SQS_SEED_DEMO).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -17,30 +16,36 @@ import (
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"shared-sqs/app/gosqs"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/persistence"
|
||||
"shared-sqs/app/tenant"
|
||||
)
|
||||
|
||||
// Фиксированные credentials демо-тенанта.
|
||||
// Открыты намеренно — тестовый стенд.
|
||||
// Тенант ограничен 10 очередями и не имеет прав admin API.
|
||||
const (
|
||||
demoTenantID = "t-demo-shared-sqs-ngcloud"
|
||||
demoAccessKey = "SSAK-demo-shared-sqs"
|
||||
demoSecretKey = "demo-secret-key-shared-sqs-ngcloud-2026"
|
||||
demoTenantName = "demo-service"
|
||||
demoMaxQueues = 10
|
||||
)
|
||||
|
||||
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
|
||||
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
|
||||
// Креды генерируются случайно (tenant.Create) — никаких захардкоженных
|
||||
// AccessKey/SecretKey в исходнике.
|
||||
func seedDemoData(store *tenant.TenantStore) {
|
||||
t, err := store.CreateFixed(demoTenantName, demoMaxQueues, demoTenantID, demoAccessKey, demoSecretKey)
|
||||
// Идемпотентность: если демо-тенант уже есть (восстановлен из Redis) — не плодим копии.
|
||||
for _, existing := range store.List() {
|
||||
if existing.Name == demoTenantName {
|
||||
log.Infof("seed: тенант %s уже существует (%s) — сид пропущен", demoTenantName, existing.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t, err := store.Create(demoTenantName, demoMaxQueues)
|
||||
if err != nil {
|
||||
log.Warnf("seed: не удалось создать demo-tenant: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("seed: создан тенант %s (AccessKey=%s)", t.ID, t.AccessKey)
|
||||
log.Infof("seed: создан тенант %s (AccessKey=%s, SecretKey=%s)", t.ID, t.AccessKey, t.SecretKey)
|
||||
|
||||
// Демо-очереди с набором сообщений
|
||||
queues := []struct {
|
||||
@@ -87,14 +92,9 @@ func seedDemoData(store *tenant.TenantStore) {
|
||||
|
||||
// URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN
|
||||
// Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью.
|
||||
env := models.CurrentEnvironment
|
||||
var queueURL string
|
||||
if env.Region != "" {
|
||||
queueURL = "http://" + env.Region + "." + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
|
||||
} else {
|
||||
queueURL = "http://" + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
|
||||
}
|
||||
queueARN := "arn:aws:sqs:" + env.Region + ":" + t.ID + ":" + q.name
|
||||
// URL — единый источник (gosqs.TenantQueueURL), учитывает ExternalURLBase.
|
||||
queueURL := gosqs.TenantQueueURL(t, q.name)
|
||||
queueARN := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + q.name
|
||||
|
||||
msgs := make([]models.SqsMessage, 0, len(q.msgs))
|
||||
for _, body := range q.msgs {
|
||||
@@ -108,8 +108,7 @@ func seedDemoData(store *tenant.TenantStore) {
|
||||
})
|
||||
}
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
models.SyncQueues.Queues[key] = &models.Queue{
|
||||
queue := &models.Queue{
|
||||
Name: q.name,
|
||||
URL: queueURL,
|
||||
Arn: queueARN,
|
||||
@@ -119,8 +118,14 @@ func seedDemoData(store *tenant.TenantStore) {
|
||||
Messages: msgs,
|
||||
Duplicates: make(map[string]time.Time),
|
||||
}
|
||||
models.SyncQueues.Lock()
|
||||
models.SyncQueues.AddQueue(key, queue)
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
// Персистим очередь и сообщения в Redis (иначе после рестарта демо-очереди исчезают).
|
||||
persistence.SaveQueue(key, queue)
|
||||
persistence.SaveMessages(key, msgs)
|
||||
|
||||
log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs))
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -90,6 +90,9 @@ func LoadYamlConfig(filename string, env string) []string {
|
||||
queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" +
|
||||
models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
|
||||
}
|
||||
if base := strings.TrimRight(models.CurrentEnvironment.ExternalURLBase, "/"); base != "" {
|
||||
queueUrl = base + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
|
||||
}
|
||||
queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name
|
||||
|
||||
if queue.ReceiveMessageWaitTimeSeconds == 0 {
|
||||
@@ -105,7 +108,7 @@ func LoadYamlConfig(filename string, env string) []string {
|
||||
queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod
|
||||
}
|
||||
|
||||
models.SyncQueues.Queues[queue.Name] = &models.Queue{
|
||||
models.SyncQueues.AddQueue(queue.Name, &models.Queue{
|
||||
Name: queue.Name,
|
||||
VisibilityTimeout: queue.VisibilityTimeout,
|
||||
Arn: queueArn,
|
||||
@@ -116,7 +119,7 @@ func LoadYamlConfig(filename string, env string) []string {
|
||||
IsFIFO: utils.HasFIFOQueueName(queue.Name),
|
||||
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
||||
Duplicates: make(map[string]time.Time),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка
|
||||
@@ -126,6 +129,9 @@ func LoadYamlConfig(filename string, env string) []string {
|
||||
err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy)
|
||||
if err != nil {
|
||||
log.Errorf("err: %s", err)
|
||||
// Разблокировать глобальный мьютекс перед выходом — иначе все
|
||||
// операции с очередями зависнут навсегда (deadlock).
|
||||
models.SyncQueues.Unlock()
|
||||
return ports
|
||||
}
|
||||
}
|
||||
@@ -140,6 +146,10 @@ func LoadYamlConfig(filename string, env string) []string {
|
||||
// AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты
|
||||
// устанавливались при любом раннем return (например, если конфиг не найден).
|
||||
func applyEnvironmentDefaults() {
|
||||
// Публичный адрес очередей — override через env (работает и когда goaws.yaml не найден).
|
||||
if v := os.Getenv("SHARED_SQS_EXTERNAL_URL_BASE"); v != "" {
|
||||
models.CurrentEnvironment.ExternalURLBase = v
|
||||
}
|
||||
if models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout <= 0 {
|
||||
models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30
|
||||
}
|
||||
|
||||
+1
-15
@@ -1,13 +1,10 @@
|
||||
Local: # Environment name that can be passed on the command line
|
||||
# (i.e.: ./goaws [Local | Dev] -- defaults to 'Local')
|
||||
Host: goaws.com # hostname of the goaws system (for docker-compose this is the tag name of the container)
|
||||
# you can now use either 1 port for both sns and sqs or alternatively you can comment out Port and use SqsPort + SnsPort for compatibilyt with
|
||||
# yopa and (fage-sns + face-sqs). If both ways are in the config file on the one "Port" will be used by GoAws
|
||||
Port: 4100 # port to listen on.
|
||||
# SqsPort: 9324 # alterante Sqs Port
|
||||
# SnsPort: 9292 # alternate Sns Port
|
||||
Region: us-east-1
|
||||
AccountId: "100010001000"
|
||||
ExternalURLBase: "" # публичный адрес очередей (напр. https://sqs.sinefo.ru); пусто → http://{region}.{host}:{port}
|
||||
LogToFile: false # Log messages (true/false)
|
||||
LogFile: .st/goaws_messages.log # Log filename (for message logging
|
||||
EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId
|
||||
@@ -17,7 +14,6 @@ Local: # Environment name that can be passed on the
|
||||
MaximumMessageSize: 262144 # maximum message size (bytes)
|
||||
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
|
||||
Queues: [] # No default queues — created via Admin API / AWS CLI by tenants
|
||||
Topics: [] # No default topics
|
||||
RandomLatency: # Parameters for introducing random latency into message queuing
|
||||
Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied.
|
||||
Max: 0 # Desired latency in milliseconds
|
||||
@@ -25,20 +21,10 @@ Local: # Environment name that can be passed on the
|
||||
Dev: # Another environment
|
||||
Host: localhost
|
||||
Port: 4100
|
||||
# SqsPort: 9324
|
||||
# SnsPort: 9292
|
||||
AccountId: "794373491471"
|
||||
LogToFile: false
|
||||
LogFile: ./goaws_messages.log
|
||||
Queues:
|
||||
- Name: dev-queue1
|
||||
- Name: dev-queue2
|
||||
Topics:
|
||||
- Name: dev-topic1
|
||||
Subscriptions:
|
||||
- QueueName: dev-queue3
|
||||
Raw: false
|
||||
- QueueName: dev-queue4
|
||||
Raw: true
|
||||
- Name: dev-topic2
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// app/gosqs/build.go
|
||||
// Общие билдеры очереди и сообщения — чтобы admin/UI-путь создавал объекты
|
||||
// ровно так же, как SQS API (единый путь, без расхождения дефолтов).
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/tenant"
|
||||
"shared-sqs/app/utils"
|
||||
)
|
||||
|
||||
// BuildQueueForTenant — создаёт очередь тенанта с теми же URL/ARN/дефолтами и
|
||||
// FIFO-детектом, что и CreateQueueV1. Используется admin-хендлерами, чтобы
|
||||
// поведение UI совпадало с SQS API (URL/ARN заполнены, .fifo → IsFIFO).
|
||||
func BuildQueueForTenant(t *tenant.Tenant, queueName string) *models.Queue {
|
||||
return &models.Queue{
|
||||
Name: queueName,
|
||||
URL: tenantQueueURL(t, queueName),
|
||||
Arn: tenantQueueARN(t, queueName),
|
||||
VisibilityTimeout: 30,
|
||||
MaximumMessageSize: MaxMessageSizeDefault,
|
||||
MessageRetentionPeriod: 345600, // 4 дня
|
||||
IsFIFO: utils.HasFIFOQueueName(queueName),
|
||||
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
||||
Messages: []models.SqsMessage{},
|
||||
Duplicates: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSqsMessage — создаёт сообщение с UUID, MD5 тела и временем отправки —
|
||||
// так же, как это делает SendMessageV1 (для admin/UI-пути).
|
||||
func BuildSqsMessage(body string) models.SqsMessage {
|
||||
return models.SqsMessage{
|
||||
MessageBody: body,
|
||||
Uuid: uuid.NewString(),
|
||||
MD5OfMessageBody: utils.GetMD5Hash(body),
|
||||
SentTime: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -47,62 +47,40 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
|
||||
// ВСЯ работа с очередью — под одним Lock.
|
||||
// Раньше проверка существования шла БЕЗ блокировки (чтение map) —
|
||||
// concurrent map read and map write при параллельном DeleteQueue
|
||||
// (fatal error, под падал под нагрузкой).
|
||||
// defer Unlock: при любой панике в секции мьютекс не останется захваченным.
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
messageFound := false
|
||||
var changedMsgUuid string
|
||||
var msgRemoved bool
|
||||
for i := 0; i < len(queue.Messages); i++ {
|
||||
msgs := queue.Messages
|
||||
if msgs[i].ReceiptHandle == receiptHandle {
|
||||
timeout := queue.VisibilityTimeout
|
||||
var dlqMsg *models.SqsMessage
|
||||
// O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза.
|
||||
if i, ok := queue.IndexOfReceipt(receiptHandle); ok {
|
||||
if visibilityTimeout == 0 {
|
||||
msgs[i].ReceiptTime = time.Now().UTC()
|
||||
msgs[i].ReceiptHandle = ""
|
||||
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(timeout) * time.Second)
|
||||
msgs[i].Retry++
|
||||
if queue.MaxReceiveCount > 0 &&
|
||||
queue.DeadLetterQueue != nil &&
|
||||
msgs[i].Retry >= queue.MaxReceiveCount {
|
||||
changedMsgUuid = msgs[i].Uuid
|
||||
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i])
|
||||
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
|
||||
msgRemoved = true
|
||||
// Возврат видимости (+ DLQ-перенос при исчерпании MaxReceiveCount) —
|
||||
// единый путь с батчевой версией.
|
||||
changedMsgUuid, msgRemoved, dlqMsg = queue.ResetVisibilityAndTryDLQ(i, receiptHandle)
|
||||
} else {
|
||||
changedMsgUuid = msgs[i].Uuid
|
||||
}
|
||||
} else {
|
||||
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
|
||||
changedMsgUuid = msgs[i].Uuid
|
||||
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
|
||||
changedMsgUuid = queue.Messages[i].Uuid
|
||||
}
|
||||
messageFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Персистим изменения в Redis под Lock (сетевые записи асинхронны).
|
||||
if messageFound {
|
||||
if msgRemoved {
|
||||
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis
|
||||
persistence.DeleteMessagePersist(key, changedMsgUuid)
|
||||
} else {
|
||||
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis исходной
|
||||
// очереди и сохраняем в DLQ, иначе после рестарта оно пропадёт из DLQ.
|
||||
_ = persistence.DeleteMessagePersist(key, changedMsgUuid)
|
||||
if dlqMsg != nil {
|
||||
persistence.SaveMessage(dlqKeyFor(key, queue.DeadLetterQueue.Name), dlqMsg)
|
||||
}
|
||||
} else if i, ok := queue.IndexOfUUID(changedMsgUuid); ok {
|
||||
// Сообщение обновлено — перезаписываем в Redis
|
||||
for i := range queue.Messages {
|
||||
if queue.Messages[i].Uuid == changedMsgUuid {
|
||||
persistence.SaveMessage(key, &queue.Messages[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
persistence.SaveQueue(key, queue)
|
||||
}
|
||||
|
||||
@@ -59,15 +59,11 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
|
||||
ids[entry.Id] = true
|
||||
}
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
|
||||
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0)
|
||||
failedEntries := make([]models.BatchResultErrorEntry, 0)
|
||||
@@ -84,22 +80,29 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
|
||||
}
|
||||
|
||||
messageFound := false
|
||||
for i := 0; i < len(queue.Messages); i++ {
|
||||
if queue.Messages[i].ReceiptHandle == entry.ReceiptHandle {
|
||||
// O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза.
|
||||
if i, ok := queue.IndexOfReceipt(entry.ReceiptHandle); ok {
|
||||
if entry.VisibilityTimeout == 0 {
|
||||
// Сброс: сообщение снова видимо, счётчик retry++
|
||||
queue.Messages[i].ReceiptTime = time.Now().UTC()
|
||||
queue.Messages[i].ReceiptHandle = ""
|
||||
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second)
|
||||
queue.Messages[i].Retry++
|
||||
// Сброс + DLQ-перенос при исчерпании MaxReceiveCount — единый путь
|
||||
// с одиночной версией (раньше батч лишь инкрементировал Retry и не
|
||||
// переносил в DLQ — счётчик и перенос могли расходиться).
|
||||
uuid, moved, dlqMsg := queue.ResetVisibilityAndTryDLQ(i, entry.ReceiptHandle)
|
||||
if moved {
|
||||
// Сообщение перенесено в DLQ и удалено из очереди — чистим Redis
|
||||
// исходной и сохраняем в DLQ (иначе пропадёт после рестарта).
|
||||
_ = persistence.DeleteMessagePersist(key, uuid)
|
||||
if dlqMsg != nil {
|
||||
persistence.SaveMessage(dlqKeyFor(key, queue.DeadLetterQueue.Name), dlqMsg)
|
||||
}
|
||||
} else if j, ok := queue.IndexOfUUID(uuid); ok {
|
||||
persistence.SaveMessage(key, &queue.Messages[j])
|
||||
}
|
||||
} else {
|
||||
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second)
|
||||
}
|
||||
// Персистим изменённое сообщение отдельно
|
||||
persistence.SaveMessage(key, &queue.Messages[i])
|
||||
messageFound = true
|
||||
break
|
||||
}
|
||||
messageFound = true
|
||||
}
|
||||
|
||||
if messageFound {
|
||||
|
||||
+36
-14
@@ -49,14 +49,9 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
queueUrl := tenantQueueURL(t, queueName)
|
||||
queueArn := tenantQueueARN(t, queueName)
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
if _, exists := models.SyncQueues.Queues[key]; !exists {
|
||||
// Проверка лимита очередей тенанта
|
||||
if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues {
|
||||
models.SyncQueues.Unlock()
|
||||
return utils.CreateErrorResponseV1("LimitExceeded", true)
|
||||
}
|
||||
log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID)
|
||||
// Готовим очередь и применяем атрибуты ВНЕ глобального лока: setQueueAttributesV1
|
||||
// берёт SyncQueues.RLock (поиск DLQ), а RWMutex не реентрантен — вызов под
|
||||
// уже захваченным SyncQueues.Lock дал бы дедлок.
|
||||
queue := &models.Queue{
|
||||
Name: queueName,
|
||||
URL: queueUrl,
|
||||
@@ -64,6 +59,16 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
IsFIFO: utils.HasFIFOQueueName(queueName),
|
||||
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
||||
Duplicates: make(map[string]time.Time),
|
||||
// Дефолты (как в InitQueues): без них очередь из API получала бы
|
||||
// VisibilityTimeout=0 и т.д. — сообщение сразу видимо, retention 0 = нет
|
||||
// эвикции. Переданные атрибуты перезапишут дефолты в setQueueAttributesV1.
|
||||
VisibilityTimeout: models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout,
|
||||
MaximumMessageSize: models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize,
|
||||
MessageRetentionPeriod: models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod,
|
||||
ReceiveMessageWaitTimeSeconds: models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds,
|
||||
// CreatedTimestamp/LastModifiedTimestamp в GetQueueAttributes (AWS их отдаёт).
|
||||
CreatedAt: time.Now().UTC(),
|
||||
LastModifiedAt: time.Now().UTC(),
|
||||
}
|
||||
// Список имён переданных атрибутов — для whitelist-валидации
|
||||
// (Query-протокол: Attribute.N.Name; JSON: nil — проверка пропускается).
|
||||
@@ -71,15 +76,32 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
if req.Header.Get("Content-Type") != "application/x-amz-json-1.0" {
|
||||
provided = utils.ExtractQueueAttributes(req.PostForm)
|
||||
}
|
||||
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided); err != nil {
|
||||
models.SyncQueues.Unlock()
|
||||
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
|
||||
return utils.CreateErrorResponseV1(err.Error(), true)
|
||||
}
|
||||
models.SyncQueues.Queues[key] = queue
|
||||
}
|
||||
// Сохраняем очередь в Redis пока держим Lock — консистентный снапшот
|
||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
existing, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
// Проверка лимита очередей тенанта
|
||||
if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues {
|
||||
models.SyncQueues.Unlock()
|
||||
return utils.CreateErrorResponseV1("LimitExceeded", true)
|
||||
}
|
||||
log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID)
|
||||
models.SyncQueues.AddQueue(key, queue)
|
||||
existing = queue
|
||||
}
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
// SaveQueue маршалит map'ы очереди (Duplicates/FIFO*/Tags) в JSON. Это нужно
|
||||
// делать под per-queue блокировкой, а НЕ под глобальным SyncQueues.Lock:
|
||||
// мутаторы (send/tag/CMV) пишут в эти map под queue.mu, и идемпотентный
|
||||
// CreateQueue существующей очереди без queue.mu даёт fatal
|
||||
// "concurrent map iteration and map write" (краш всего процесса).
|
||||
existing.LockMu()
|
||||
persistence.SaveQueue(key, existing)
|
||||
existing.UnlockMu()
|
||||
respStruct := models.CreateQueueResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Result: models.CreateQueueResult{QueueUrl: queueUrl},
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/mitchellh/copystructure"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateQueueV1_success(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.CreateQueueRequest)
|
||||
*v = fixtures.CreateQueueRequest
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := CreateQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
||||
|
||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
||||
assert.Equal(t, fixtures.FullyPopulatedQueue, actualQueue)
|
||||
}
|
||||
|
||||
func TestCreateQueueV1_success_with_redrive_policy(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
||||
c, _ := dupe.(models.CreateQueueRequest)
|
||||
c.Attributes.RedrivePolicy = models.RedrivePolicy{
|
||||
MaxReceiveCount: 100,
|
||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", fixtures.DeadLetterQueueName),
|
||||
}
|
||||
|
||||
v := resultingStruct.(*models.CreateQueueRequest)
|
||||
*v = c
|
||||
return true
|
||||
}
|
||||
|
||||
dlq := &models.Queue{
|
||||
Name: fixtures.DeadLetterQueueName,
|
||||
}
|
||||
models.SyncQueues.Queues[fixtures.DeadLetterQueueName] = dlq
|
||||
|
||||
expectedQueue := &models.Queue{
|
||||
Name: fixtures.QueueName,
|
||||
URL: fmt.Sprintf("http://%s.%s:%s/%s/%s",
|
||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
||||
fixtures.LOCAL_ENVIRONMENT.Host,
|
||||
fixtures.LOCAL_ENVIRONMENT.Port,
|
||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
||||
fixtures.QueueName,
|
||||
),
|
||||
Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s",
|
||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
||||
fixtures.QueueName,
|
||||
),
|
||||
VisibilityTimeout: 5,
|
||||
ReceiveMessageWaitTimeSeconds: 4,
|
||||
DelaySeconds: 1,
|
||||
MaximumMessageSize: 2,
|
||||
MessageRetentionPeriod: 3,
|
||||
DeadLetterQueue: dlq,
|
||||
MaxReceiveCount: 100,
|
||||
Duplicates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := CreateQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
||||
|
||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
||||
assert.Equal(t, expectedQueue, actualQueue)
|
||||
}
|
||||
|
||||
func TestCreateQueueV1_success_with_existing_queue(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.CreateQueueRequest)
|
||||
*v = fixtures.CreateQueueRequest
|
||||
return true
|
||||
}
|
||||
|
||||
q := &models.Queue{
|
||||
Name: fixtures.QueueName,
|
||||
}
|
||||
models.SyncQueues.Queues[fixtures.QueueName] = q
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := CreateQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
||||
|
||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
||||
assert.Equal(t, q, actualQueue)
|
||||
}
|
||||
|
||||
func TestCreateQueueV1_success_with_no_request_attributes_falls_back_to_default(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
||||
c, _ := dupe.(models.CreateQueueRequest)
|
||||
c.Attributes = models.QueueAttributes{}
|
||||
|
||||
v := resultingStruct.(*models.CreateQueueRequest)
|
||||
*v = c
|
||||
return true
|
||||
}
|
||||
|
||||
expectedQueue := &models.Queue{
|
||||
Name: fixtures.QueueName,
|
||||
URL: fmt.Sprintf("http://%s.%s:%s/%s/%s",
|
||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
||||
fixtures.LOCAL_ENVIRONMENT.Host,
|
||||
fixtures.LOCAL_ENVIRONMENT.Port,
|
||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
||||
fixtures.QueueName,
|
||||
),
|
||||
Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s",
|
||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
||||
fixtures.QueueName,
|
||||
),
|
||||
VisibilityTimeout: 0,
|
||||
ReceiveMessageWaitTimeSeconds: 0,
|
||||
DelaySeconds: 0,
|
||||
MaximumMessageSize: 0,
|
||||
MessageRetentionPeriod: 0,
|
||||
Duplicates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := CreateQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
||||
|
||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
||||
assert.Equal(t, expectedQueue, actualQueue)
|
||||
}
|
||||
|
||||
func TestCreateQueueV1_success_no_configured_region_for_queue_url(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
models.CurrentEnvironment.Region = ""
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
||||
c, _ := dupe.(models.CreateQueueRequest)
|
||||
c.Attributes = models.QueueAttributes{}
|
||||
|
||||
v := resultingStruct.(*models.CreateQueueRequest)
|
||||
*v = c
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := CreateQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
|
||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
||||
assert.Equal(t,
|
||||
fmt.Sprintf("http://%s:%s/%s/%s",
|
||||
fixtures.LOCAL_ENVIRONMENT.Host,
|
||||
fixtures.LOCAL_ENVIRONMENT.Port,
|
||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
||||
fixtures.QueueName,
|
||||
),
|
||||
actualQueue.URL,
|
||||
)
|
||||
}
|
||||
|
||||
func TestCreateQueueV1_request_transformer_error(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := CreateQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestCreateQueueV1_invalid_dead_letter_queue_error(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
||||
c, _ := dupe.(models.CreateQueueRequest)
|
||||
c.Attributes.RedrivePolicy = models.RedrivePolicy{
|
||||
MaxReceiveCount: 100,
|
||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"),
|
||||
}
|
||||
|
||||
v := resultingStruct.(*models.CreateQueueRequest)
|
||||
*v = c
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := CreateQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
+24
-16
@@ -42,30 +42,38 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle)
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
if _, ok := models.SyncQueues.Queues[key]; ok {
|
||||
for i, msg := range models.SyncQueues.Queues[key].Messages {
|
||||
if msg.ReceiptHandle == receiptHandle {
|
||||
msgUuid := msg.Uuid
|
||||
models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID)
|
||||
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...)
|
||||
delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID)
|
||||
// Удаляем одно сообщение из Redis — O(1)
|
||||
persistence.DeleteMessagePersist(key, msgUuid)
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
log.Warning("Queue not found")
|
||||
return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
// O(1)-поиск сообщения по индексу ReceiptHandle → индекс в Messages (P1.2).
|
||||
// Раньше — линейный скан всего среза + сдвиг остатка (O(N) на большое тело).
|
||||
if i, ok := queue.IndexOfReceipt(receiptHandle); ok {
|
||||
msg := queue.Messages[i] // копия ДО мутации: RemoveIndex может сдвинуть срез
|
||||
queue.ClearInFlight(i, receiptHandle)
|
||||
queue.UnlockGroup(msg.GroupID)
|
||||
queue.RemoveIndex(i) // tombstone за O(1) + подрезка хвоста/амортизированная компакция
|
||||
// ВАЖНО: запись в Duplicates НЕ удаляем — AWS держит dedup-окно 5 минут
|
||||
// независимо от удаления сообщения (иначе повторный send с тем же
|
||||
// dedupId доставлялся бы повторно). Чистит только истечение окна (PeriodicTasks).
|
||||
// Удаляем одно сообщение из Redis — O(1). Ошибка → 500: иначе клиент
|
||||
// получил бы 200, а после рестарта сообщение «воскресло» бы.
|
||||
if err := persistence.DeleteMessagePersist(key, msg.Uuid); err != nil {
|
||||
log.Errorf("DeleteMessage: не удалось удалить сообщение из Redis: %v", err)
|
||||
return utils.CreateErrorResponseV1("InternalError", true)
|
||||
}
|
||||
// Метаданные очереди (duplicates, FIFO state)
|
||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
||||
persistence.SaveQueue(key, queue)
|
||||
respStruct := models.DeleteMessageResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
return 200, &respStruct
|
||||
}
|
||||
}
|
||||
log.Warning("Receipt Handle not found")
|
||||
} else {
|
||||
log.Warning("Queue not found")
|
||||
}
|
||||
|
||||
return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
|
||||
}
|
||||
|
||||
@@ -54,52 +54,72 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
||||
return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true)
|
||||
}
|
||||
ids[v.Id] = true
|
||||
// Пустой ReceiptHandle опасен: в JSON-протоколе (x-amz-json-1.0) он не
|
||||
// отсекается парсером и совпадает с ReceiptHandle НЕвыданных сообщений (""),
|
||||
// что удаляло бы все такие сообщения очереди одним запросом.
|
||||
if v.ReceiptHandle == "" {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
}
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
|
||||
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
deleteMessageMap := make(map[string]*deleteEntry)
|
||||
// Ключ — Id, а НЕ ReceiptHandle: два entry с одинаковым ReceiptHandle и разными
|
||||
// Id не должны перезаписывать друг друга (первый исчезал бы и не попадал ни в
|
||||
// Successful, ни в Failed — ревью 2026-10-11). entriesByHandle — обратный индекс.
|
||||
entriesByID := make(map[string]*deleteEntry)
|
||||
entriesByHandle := make(map[string][]*deleteEntry)
|
||||
for _, entry := range requestBody.Entries {
|
||||
deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{
|
||||
de := &deleteEntry{
|
||||
Id: entry.Id,
|
||||
ReceiptHandle: entry.ReceiptHandle,
|
||||
Deleted: false,
|
||||
}
|
||||
entriesByID[entry.Id] = de
|
||||
entriesByHandle[entry.ReceiptHandle] = append(entriesByHandle[entry.ReceiptHandle], de)
|
||||
}
|
||||
|
||||
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
|
||||
deletedUuids := make([]string, 0)
|
||||
remainingMessages := make([]models.SqsMessage, 0, len(queue.Messages))
|
||||
|
||||
for _, message := range queue.Messages {
|
||||
if de, found := deleteMessageMap[message.ReceiptHandle]; found {
|
||||
// Идём по индексам, не пересобирая срез (P1.2): удаление = tombstone за O(1),
|
||||
// индексы не сдвигаются, поэтому итерация остаётся корректной.
|
||||
for i := range queue.Messages {
|
||||
if queue.IsDead(i) {
|
||||
continue
|
||||
}
|
||||
message := &queue.Messages[i]
|
||||
des, found := entriesByHandle[message.ReceiptHandle]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID)
|
||||
queue.UnlockGroup(message.GroupID)
|
||||
delete(queue.Duplicates, message.DeduplicationID)
|
||||
// Читаем поля ДО RemoveIndex: компакция может перезаписать слот.
|
||||
groupID, uuid := message.GroupID, message.Uuid
|
||||
queue.ClearInFlight(i, message.ReceiptHandle)
|
||||
queue.UnlockGroup(groupID)
|
||||
// Duplicates НЕ чистим — dedup-окно 5 минут живёт независимо от удаления (AWS).
|
||||
for _, de := range des {
|
||||
de.Deleted = true
|
||||
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
|
||||
deletedUuids = append(deletedUuids, message.Uuid)
|
||||
} else {
|
||||
remainingMessages = append(remainingMessages, message)
|
||||
}
|
||||
deletedUuids = append(deletedUuids, uuid)
|
||||
queue.RemoveIndex(i)
|
||||
}
|
||||
// Удаляем сообщения из Redis отдельно — O(batch_size). Ошибка → InternalError.
|
||||
if err := persistence.DeleteMessagesPersist(key, deletedUuids); err != nil {
|
||||
log.Errorf("DeleteMessageBatch: не удалось удалить сообщения из Redis: %v", err)
|
||||
return utils.CreateErrorResponseV1("InternalError", true)
|
||||
}
|
||||
|
||||
queue.Messages = remainingMessages
|
||||
// Удаляем сообщения из Redis отдельно — O(batch_size)
|
||||
persistence.DeleteMessagesPersist(key, deletedUuids)
|
||||
// Метаданные очереди (duplicates, FIFO state)
|
||||
persistence.SaveQueue(key, queue)
|
||||
|
||||
notFoundEntries := make([]models.BatchResultErrorEntry, 0)
|
||||
for _, de := range deleteMessageMap {
|
||||
for _, de := range entriesByID {
|
||||
if !de.Deleted {
|
||||
notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{
|
||||
Code: "1",
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/test"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeleteMessageBatchV1_success_all_message(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "testing",
|
||||
Messages: []models.SqsMessage{
|
||||
{
|
||||
MessageBody: "test%20message%20body%201",
|
||||
ReceiptHandle: "test1",
|
||||
},
|
||||
{
|
||||
MessageBody: "test%20message%20body%202",
|
||||
ReceiptHandle: "test2",
|
||||
},
|
||||
{
|
||||
MessageBody: "test%20message%20body%203",
|
||||
ReceiptHandle: "test3",
|
||||
},
|
||||
},
|
||||
}
|
||||
models.SyncQueues.Queues["testing"] = q
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
||||
*v = models.DeleteMessageBatchRequest{
|
||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "delete-test-1",
|
||||
ReceiptHandle: "test1",
|
||||
},
|
||||
{
|
||||
Id: "delete-test-2",
|
||||
ReceiptHandle: "test2",
|
||||
},
|
||||
{
|
||||
Id: "delete-test-3",
|
||||
ReceiptHandle: "test3",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
_, request2 := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
|
||||
status, response2 := DeleteMessageBatchV1(request2)
|
||||
deleteMessageBatchResponse := response2.(models.DeleteMessageBatchResponse)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id)
|
||||
assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Successful[1].Id)
|
||||
assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[2].Id)
|
||||
assert.Empty(t, deleteMessageBatchResponse.Result.Failed)
|
||||
assert.Empty(t, models.SyncQueues.Queues["testing"].Messages)
|
||||
}
|
||||
func TestDeleteMessageBatchV1_success_not_found_message(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "testing",
|
||||
Messages: []models.SqsMessage{
|
||||
{
|
||||
MessageBody: "test%20message%20body%201",
|
||||
ReceiptHandle: "test1",
|
||||
},
|
||||
{
|
||||
MessageBody: "test%20message%20body%203",
|
||||
ReceiptHandle: "test3",
|
||||
},
|
||||
},
|
||||
}
|
||||
models.SyncQueues.Queues["testing"] = q
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
||||
*v = models.DeleteMessageBatchRequest{
|
||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "delete-test-1",
|
||||
ReceiptHandle: "test1",
|
||||
},
|
||||
{
|
||||
Id: "delete-test-2",
|
||||
ReceiptHandle: "test2",
|
||||
},
|
||||
{
|
||||
Id: "delete-test-3",
|
||||
ReceiptHandle: "test3",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
_, request := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
|
||||
status, response := DeleteMessageBatchV1(request)
|
||||
deleteMessageBatchResponse := response.(models.DeleteMessageBatchResponse)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id)
|
||||
assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[1].Id)
|
||||
assert.Equal(t, "1", deleteMessageBatchResponse.Result.Failed[0].Code)
|
||||
assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Failed[0].Id)
|
||||
assert.Equal(t, "Message not found", deleteMessageBatchResponse.Result.Failed[0].Message)
|
||||
assert.True(t, deleteMessageBatchResponse.Result.Failed[0].SenderFault)
|
||||
assert.Empty(t, models.SyncQueues.Queues["testing"].Messages)
|
||||
}
|
||||
|
||||
func TestDeleteMessageBatchV1_error_not_found_queue(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
||||
*v = models.DeleteMessageBatchRequest{
|
||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "delete-test-1",
|
||||
ReceiptHandle: "test1",
|
||||
},
|
||||
{
|
||||
Id: "delete-test-2",
|
||||
ReceiptHandle: "test2",
|
||||
},
|
||||
{
|
||||
Id: "delete-test-3",
|
||||
ReceiptHandle: "test3",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
_, r := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
|
||||
status, _ := DeleteMessageBatchV1(r)
|
||||
assert.Equal(t, status, http.StatusBadRequest)
|
||||
|
||||
}
|
||||
|
||||
func TestDeleteMessageBatchV1_error_no_entry(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
||||
*v = models.DeleteMessageBatchRequest{
|
||||
Entries: make([]models.DeleteMessageBatchRequestEntry, 0),
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
_, r := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
|
||||
status, _ := DeleteMessageBatchV1(r)
|
||||
assert.Equal(t, status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestDeleteMessageBatchV1_error_too_many_entries(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
||||
*v = models.DeleteMessageBatchRequest{
|
||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "test-1",
|
||||
ReceiptHandle: "test-1",
|
||||
},
|
||||
{
|
||||
Id: "test-2",
|
||||
ReceiptHandle: "test-2",
|
||||
},
|
||||
{
|
||||
Id: "test-3",
|
||||
ReceiptHandle: "test-3",
|
||||
},
|
||||
{
|
||||
Id: "test-4",
|
||||
ReceiptHandle: "test-4",
|
||||
},
|
||||
{
|
||||
Id: "test-5",
|
||||
ReceiptHandle: "test-5",
|
||||
},
|
||||
{
|
||||
Id: "test-6",
|
||||
ReceiptHandle: "test-6",
|
||||
},
|
||||
{
|
||||
Id: "test-7",
|
||||
ReceiptHandle: "test-7",
|
||||
},
|
||||
{
|
||||
Id: "test-8",
|
||||
ReceiptHandle: "test-8",
|
||||
},
|
||||
{
|
||||
Id: "test-9",
|
||||
ReceiptHandle: "test-9",
|
||||
},
|
||||
{
|
||||
Id: "test-10",
|
||||
ReceiptHandle: "test-10",
|
||||
},
|
||||
{
|
||||
Id: "test-11",
|
||||
ReceiptHandle: "test-11",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
_, r := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
|
||||
status, _ := DeleteMessageBatchV1(r)
|
||||
assert.Equal(t, status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestDeleteMessageBatchV1_Error_IdNotDistinct(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
||||
*v = models.DeleteMessageBatchRequest{
|
||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "delete-test-1",
|
||||
ReceiptHandle: "test1",
|
||||
},
|
||||
{
|
||||
Id: "delete-test-1",
|
||||
ReceiptHandle: "test2",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
_, r := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
|
||||
status, _ := DeleteMessageBatchV1(r)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
}
|
||||
|
||||
func TestDeleteMessageBatchV1_Error_transformer(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, _ := DeleteMessageBatchV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeleteMessage(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "testing",
|
||||
Messages: []models.SqsMessage{{
|
||||
MessageBody: "test1",
|
||||
ReceiptHandle: "123",
|
||||
}},
|
||||
}
|
||||
|
||||
models.SyncQueues.Queues["testing"] = q
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/testing",
|
||||
ReceiptHandle: "123",
|
||||
}, true)
|
||||
status, _ := DeleteMessageV1(r)
|
||||
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
assert.Empty(t, q.Messages)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// app/gosqs/delete_middle_test.go
|
||||
// Тест P1.2: удаление сообщения из СЕРЕДИНЫ очереди не ломает остальные —
|
||||
// индексы receipt/uuid корректны (tombstone), удалённое не возвращается,
|
||||
// оставшиеся находятся по своим ReceiptHandle.
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/models"
|
||||
)
|
||||
|
||||
func TestDeleteMessageV1_MiddleKeepsOthersReceivable(t *testing.T) {
|
||||
resetTestEnv(t)
|
||||
tnt := testTenant("SSAK-aaa")
|
||||
injectRequest(t, func(v interface{}) { v.(*models.CreateQueueRequest).QueueName = "q" })
|
||||
if code, _ := CreateQueueV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||
t.Fatal("create failed")
|
||||
}
|
||||
q := models.SyncQueues.Queues["SSAK-aaa:q"]
|
||||
q.MaximumMessageSize = 262144
|
||||
|
||||
// Три сообщения.
|
||||
for _, body := range []string{"m1", "m2", "m3"} {
|
||||
b := body
|
||||
injectRequest(t, func(v interface{}) {
|
||||
r := v.(*models.SendMessageRequest)
|
||||
r.QueueUrl = "http://x/tenant/q"
|
||||
r.MessageBody = b
|
||||
})
|
||||
if code, _ := SendMessageV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||
t.Fatal("send failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем все три разом.
|
||||
injectRequest(t, func(v interface{}) {
|
||||
r := v.(*models.ReceiveMessageRequest)
|
||||
r.QueueUrl = "http://x/tenant/q"
|
||||
r.MaxNumberOfMessages = 10
|
||||
})
|
||||
_, resp := ReceiveMessageV1(reqWithTenant("/", tnt))
|
||||
rr := resp.(models.ReceiveMessageResponse)
|
||||
if len(rr.Result.Messages) != 3 {
|
||||
t.Fatalf("receive вернул %d сообщений, want 3", len(rr.Result.Messages))
|
||||
}
|
||||
|
||||
// Удаляем СРЕДНЕЕ.
|
||||
middle := rr.Result.Messages[1]
|
||||
injectRequest(t, func(v interface{}) {
|
||||
r := v.(*models.DeleteMessageRequest)
|
||||
r.QueueUrl = "http://x/tenant/q"
|
||||
r.ReceiptHandle = middle.ReceiptHandle
|
||||
})
|
||||
if code, _ := DeleteMessageV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||
t.Fatal("delete failed")
|
||||
}
|
||||
if q.LiveCount() != 2 {
|
||||
t.Fatalf("LiveCount = %d, want 2", q.LiveCount())
|
||||
}
|
||||
if _, ok := q.IndexOfUUID(middle.MessageId); ok {
|
||||
t.Fatal("удалённое сообщение осталось в uuid-индексе")
|
||||
}
|
||||
// Оставшиеся по-прежнему in-flight и находятся по своим handle (индексы не «съехали»).
|
||||
for _, m := range []*models.ResultMessage{rr.Result.Messages[0], rr.Result.Messages[2]} {
|
||||
if _, ok := q.IndexOfReceipt(m.ReceiptHandle); !ok {
|
||||
t.Fatalf("handle оставшегося сообщения потерян: %s", m.ReceiptHandle)
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращаем видимость оставшимся и получаем их снова — удалённого быть не должно.
|
||||
for _, m := range []*models.ResultMessage{rr.Result.Messages[0], rr.Result.Messages[2]} {
|
||||
handle := m.ReceiptHandle
|
||||
injectRequest(t, func(v interface{}) {
|
||||
r := v.(*models.ChangeMessageVisibilityRequest)
|
||||
r.QueueUrl = "http://x/tenant/q"
|
||||
r.ReceiptHandle = handle
|
||||
r.VisibilityTimeout = 0
|
||||
})
|
||||
if code, _ := ChangeMessageVisibilityV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||
t.Fatal("change visibility failed")
|
||||
}
|
||||
}
|
||||
|
||||
injectRequest(t, func(v interface{}) {
|
||||
r := v.(*models.ReceiveMessageRequest)
|
||||
r.QueueUrl = "http://x/tenant/q"
|
||||
r.MaxNumberOfMessages = 10
|
||||
})
|
||||
_, resp2 := ReceiveMessageV1(reqWithTenant("/", tnt))
|
||||
rr2 := resp2.(models.ReceiveMessageResponse)
|
||||
if len(rr2.Result.Messages) != 2 {
|
||||
t.Fatalf("повторный receive вернул %d, want 2", len(rr2.Result.Messages))
|
||||
}
|
||||
for _, m := range rr2.Result.Messages {
|
||||
if m.MessageId == middle.MessageId {
|
||||
t.Fatal("удалённое сообщение вернулось в receive")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
log.Warnf("Delete Queue: %s does not exist (tenant: %s)", queueName, t.ID)
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
delete(models.SyncQueues.Queues, key)
|
||||
models.SyncQueues.RemoveQueue(key)
|
||||
|
||||
// Удаляем из Redis асинхронно
|
||||
persistence.DeleteQueue(key)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
)
|
||||
|
||||
func TestDeleteQueueV1_success(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteQueueRequest)
|
||||
*v = models.DeleteQueueRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
expectedResponse := models.DeleteQueueResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := DeleteQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
|
||||
_, ok := models.SyncQueues.Queues["unit-queue1"]
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestDeleteQueueV1_success_unknown_queue(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.DeleteQueueRequest)
|
||||
*v = models.DeleteQueueRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unknown-queue1"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
expectedResponse := models.DeleteQueueResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := DeleteQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
}
|
||||
|
||||
func TestDeleteQueueV1_error_invalid_request(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := DeleteQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
@@ -64,13 +65,12 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
||||
log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID)
|
||||
queueAttributes := make([]models.Attribute, 0)
|
||||
|
||||
models.SyncQueues.RLock()
|
||||
defer models.SyncQueues.RUnlock()
|
||||
queue, ok := models.SyncQueues.Queues[key]
|
||||
if !ok {
|
||||
queue := models.SyncQueues.GetRLocked(key)
|
||||
if queue == nil {
|
||||
log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID)
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.RUnlockMu()
|
||||
|
||||
if shouldInclude("DelaySeconds") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)})
|
||||
@@ -87,17 +87,24 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
||||
if shouldInclude("VisibilityTimeout") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)})
|
||||
}
|
||||
// FIFO-атрибуты: читаются Terraform (aws_sqs_queue) и aws cli; без них — drift.
|
||||
if shouldInclude("FifoQueue") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "FifoQueue", Value: strconv.FormatBool(queue.IsFIFO)})
|
||||
}
|
||||
if shouldInclude("ContentBasedDeduplication") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ContentBasedDeduplication", Value: strconv.FormatBool(queue.ContentBasedDeduplication)})
|
||||
}
|
||||
if shouldInclude("ApproximateNumberOfMessages") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))})
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(queue.LiveCount())})
|
||||
}
|
||||
if shouldInclude("ApproximateNumberOfMessagesNotVisible") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(*queue))})
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(queue))})
|
||||
}
|
||||
if shouldInclude("CreatedTimestamp") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"})
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: epochSeconds(queue.CreatedAt)})
|
||||
}
|
||||
if shouldInclude("LastModifiedTimestamp") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"})
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: epochSeconds(queue.LastModifiedAt)})
|
||||
}
|
||||
if shouldInclude("QueueArn") {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn})
|
||||
@@ -108,6 +115,10 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
||||
Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn),
|
||||
})
|
||||
}
|
||||
// Policy — отдаём сохранённую JSON-строку как есть (совместимость с Terraform).
|
||||
if shouldInclude("Policy") && queue.Policy != "" {
|
||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "Policy", Value: queue.Policy})
|
||||
}
|
||||
|
||||
respStruct := models.GetQueueAttributesResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
@@ -116,3 +127,12 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
||||
}
|
||||
return http.StatusOK, respStruct
|
||||
}
|
||||
|
||||
// epochSeconds — timestamp в формате AWS (unix-секунды строкой).
|
||||
// Нулевое время отдаём как "0" (стабильно, без drift при чтении).
|
||||
func epochSeconds(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatInt(t.Unix(), 10)
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"github.com/mitchellh/copystructure"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetQueueAttributesV1_success_all(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
||||
*v = fixtures.GetQueueAttributesRequest
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := GetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, fixtures.GetQueueAttributesResponse, response)
|
||||
}
|
||||
|
||||
func TestGetQueueAttributesV1_success_no_request_attrs_returns_all(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
||||
*v = models.GetQueueAttributesRequest{
|
||||
QueueUrl: "unit-queue1",
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := GetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, fixtures.GetQueueAttributesResponse, response)
|
||||
}
|
||||
|
||||
func TestGetQueueAttributesV1_success_all_with_redrive_queue(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
||||
*v = models.GetQueueAttributesRequest{
|
||||
QueueUrl: "unit-queue2",
|
||||
AttributeNames: []string{"All"},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := GetQueueAttributesV1(r)
|
||||
|
||||
dupe, _ := copystructure.Copy(fixtures.GetQueueAttributesResponse)
|
||||
expectedResponse, _ := dupe.(models.GetQueueAttributesResponse)
|
||||
expectedResponse.Result.Attrs[9].Value = fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, "unit-queue2")
|
||||
expectedResponse.Result.Attrs = append(expectedResponse.Result.Attrs,
|
||||
models.Attribute{
|
||||
Name: "RedrivePolicy",
|
||||
Value: fmt.Sprintf(`{"maxReceiveCount":"1", "deadLetterTargetArn":"%s:%s"}`, fixtures.BASE_SQS_ARN, "dead-letter-queue1"),
|
||||
},
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
}
|
||||
|
||||
func TestGetQueueAttributesV1_success_specific_fields(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
||||
*v = models.GetQueueAttributesRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/unit-queue1", fixtures.BASE_URL),
|
||||
AttributeNames: []string{"DelaySeconds"},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := GetQueueAttributesV1(r)
|
||||
|
||||
expectedResponse := models.GetQueueAttributesResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Result: models.GetQueueAttributesResult{Attrs: []models.Attribute{
|
||||
models.Attribute{
|
||||
Name: "DelaySeconds",
|
||||
Value: "0",
|
||||
},
|
||||
}},
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
}
|
||||
|
||||
func TestGetQueueAttributesV1_request_transformer_error(t *testing.T) {
|
||||
defer func() {
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := GetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestGetQueueAttributesV1_missing_queue_url_in_request_returns_error(t *testing.T) {
|
||||
defer func() {
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
||||
*v = models.GetQueueAttributesRequest{
|
||||
QueueUrl: "",
|
||||
AttributeNames: []string{},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := GetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestGetQueueAttributesV1_missing_queue_returns_error(t *testing.T) {
|
||||
defer func() {
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
||||
*v = fixtures.GetQueueAttributesRequest
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := GetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetQueueUrlV1_success(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueUrlRequest)
|
||||
*v = models.GetQueueUrlRequest{
|
||||
QueueName: "unit-queue1",
|
||||
QueueOwnerAWSAccountId: "fugafuga",
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
code, response := GetQueueUrlV1(r)
|
||||
|
||||
get_queue_url_response := response.(models.GetQueueUrlResponse)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, get_queue_url_response.Result.QueueUrl, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"))
|
||||
|
||||
}
|
||||
|
||||
func TestGetQueueUrlV1_error_no_queue(t *testing.T) {
|
||||
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.GetQueueUrlRequest)
|
||||
*v = models.GetQueueUrlRequest{
|
||||
QueueName: "not-exist-unit-queue1",
|
||||
QueueOwnerAWSAccountId: "fugafuga",
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
code, response := GetQueueUrlV1(r)
|
||||
|
||||
expected := models.ErrorResult{
|
||||
Type: "Not Found",
|
||||
Code: "AWS.SimpleQueueService.NonExistentQueue",
|
||||
Message: "The specified queue does not exist for this wsdl version.",
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
assert.Equal(t, response.GetResult().(models.ErrorResult), expected)
|
||||
}
|
||||
|
||||
func TestGetQueueUrlV1_error_request_transformer(t *testing.T) {
|
||||
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo(
|
||||
"POST",
|
||||
"/",
|
||||
nil,
|
||||
true)
|
||||
code, _ := GetQueueUrlV1(r)
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
+95
-24
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/persistence"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -30,22 +31,35 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Снапшот указателей на очереди под коротким RLock.
|
||||
// Снапшот (key, указатель) очередей под коротким RLock.
|
||||
// Указатели остаются валидными после снятия RLock: очереди из map
|
||||
// не удаляются во время итерации, а объекты живут в куче.
|
||||
models.SyncQueues.RLock()
|
||||
queues := make([]*models.Queue, 0, len(models.SyncQueues.Queues))
|
||||
for _, queue := range models.SyncQueues.Queues {
|
||||
queues = append(queues, queue)
|
||||
type qEntry struct {
|
||||
key string
|
||||
queue *models.Queue
|
||||
}
|
||||
queues := make([]qEntry, 0, len(models.SyncQueues.Queues))
|
||||
for key, queue := range models.SyncQueues.Queues {
|
||||
queues = append(queues, qEntry{key, queue})
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
|
||||
// Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной
|
||||
// очереди не мешает обработке остальных и HTTP-запросам.
|
||||
for _, queue := range queues {
|
||||
models.SyncQueues.Lock()
|
||||
processQueueTick(queue)
|
||||
models.SyncQueues.Unlock()
|
||||
for _, qi := range queues {
|
||||
func() {
|
||||
// recover: паника в processQueueTick одной очереди не должна
|
||||
// убивать весь процесс; mutex освобождается в defer.
|
||||
defer qi.queue.UnlockMu()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("gosqs: panic in processQueueTick for queue %q: %v", qi.key, r)
|
||||
}
|
||||
}()
|
||||
qi.queue.LockMu()
|
||||
processQueueTick(qi.key, qi.queue)
|
||||
}()
|
||||
}
|
||||
case <-quit:
|
||||
ticker.Stop()
|
||||
@@ -56,9 +70,10 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
|
||||
|
||||
// processQueueTick — обработка ОДНОЙ очереди за один такт.
|
||||
//
|
||||
// ВЫЗЫВАТЬ ТОЛЬКО под удержанием SyncQueues.Lock.
|
||||
// ВЫЗЫВАТЬ под per-queue блокировкой этой очереди (queue.LockMu).
|
||||
//
|
||||
// Логическая схема:
|
||||
// 0. Эвикция сообщений по MessageRetentionPeriod (старше retention удаляются).
|
||||
// 1. Deduplication-окно (FIFO): удаляем записи старше DeduplicationPeriod.
|
||||
// 2. Проходим по сообщениям очереди:
|
||||
// - пустой ReceiptHandle (не in-flight) — пропускаем;
|
||||
@@ -67,7 +82,41 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
|
||||
// FIFO-группы, обновление ReceiptTime), инкрементируем Retry;
|
||||
// - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение
|
||||
// в DLQ и удаляем из исходной очереди.
|
||||
func processQueueTick(queue *models.Queue) {
|
||||
func processQueueTick(key string, queue *models.Queue) {
|
||||
// Ленивая починка индексов (P1.2): очередь могла быть собрана литералом или
|
||||
// загружена из Redis — тогда индексы строятся здесь один раз.
|
||||
queue.EnsureIndexes()
|
||||
|
||||
// 0. Эвикция просроченных сообщений по MessageRetentionPeriod.
|
||||
// Retention == 0 означает «без истечения» (не эвиктируем).
|
||||
// Удаление — tombstone за O(1); физическая чистка — CompactIfNeeded ниже.
|
||||
if queue.MessageRetentionPeriod > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(queue.MessageRetentionPeriod) * time.Second)
|
||||
expired := make([]string, 0) // uuid просроченных сообщений для удаления из Redis
|
||||
for i := range queue.Messages {
|
||||
if queue.IsDead(i) {
|
||||
continue
|
||||
}
|
||||
msg := queue.Messages[i]
|
||||
if !msg.SentTime.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
expired = append(expired, msg.Uuid)
|
||||
if msg.GroupID != "" {
|
||||
queue.UnlockGroup(msg.GroupID)
|
||||
}
|
||||
if msg.ReceiptHandle != "" {
|
||||
queue.ClearInFlight(i, msg.ReceiptHandle)
|
||||
}
|
||||
queue.RemoveIndex(i)
|
||||
}
|
||||
if len(expired) > 0 {
|
||||
log.Infof("Queue [%s]: evicted %d messages by MessageRetentionPeriod", queue.Name, len(expired))
|
||||
// Удаляем просроченные из Redis, чтобы не воскресли после рестарта.
|
||||
_ = persistence.DeleteMessagesPersist(key, expired)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Просроченные записи deduplication-окна.
|
||||
for dedupId, startTime := range queue.Duplicates {
|
||||
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
|
||||
@@ -77,18 +126,25 @@ func processQueueTick(queue *models.Queue) {
|
||||
}
|
||||
|
||||
// 2. Возврат видимости истёкших сообщений + перенос в DLQ.
|
||||
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
|
||||
for i := 0; i < len(queue.Messages); i++ {
|
||||
msg := &queue.Messages[i]
|
||||
|
||||
if msg.ReceiptHandle == "" {
|
||||
continue // сообщение не в полёте
|
||||
// Итерируем ТОЛЬКО по in-flight (receiptIdx), а не по всему срезу — стоимость
|
||||
// пропорциональна числу сообщений «в полёте», а не размеру очереди (P1.2).
|
||||
log.Debugf("Queue [%s] live [%d]", queue.Name, queue.LiveCount())
|
||||
now := time.Now()
|
||||
for _, i := range queue.InFlightIndexes() {
|
||||
if i < 0 || i >= len(queue.Messages) || queue.IsDead(i) {
|
||||
continue
|
||||
}
|
||||
if msg.VisibilityTimeout.After(time.Now()) {
|
||||
msg := &queue.Messages[i]
|
||||
rh := msg.ReceiptHandle
|
||||
if rh == "" {
|
||||
continue // уже возвращено в видимость
|
||||
}
|
||||
if msg.VisibilityTimeout.After(now) {
|
||||
continue // таймаут видимости ещё действует
|
||||
}
|
||||
|
||||
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
|
||||
log.Debugf("Making message visible again %s", rh)
|
||||
queue.ClearInFlight(i, rh)
|
||||
queue.UnlockGroup(msg.GroupID)
|
||||
msg.ReceiptHandle = ""
|
||||
msg.ReceiptTime = time.Now().UTC()
|
||||
@@ -98,16 +154,31 @@ func processQueueTick(queue *models.Queue) {
|
||||
if queue.MaxReceiveCount > 0 &&
|
||||
queue.DeadLetterQueue != nil &&
|
||||
msg.Retry >= queue.MaxReceiveCount {
|
||||
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
|
||||
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
|
||||
i-- // элемент на позиции i удалён — повторно проверяем новый элемент на ней
|
||||
}
|
||||
dlqMsg := *msg
|
||||
msgUuid := msg.Uuid
|
||||
dlqName := queue.DeadLetterQueue.Name
|
||||
queue.DeadLetterQueue.LockMu()
|
||||
queue.DeadLetterQueue.AppendMessage(dlqMsg)
|
||||
queue.DeadLetterQueue.UnlockMu()
|
||||
queue.RemoveIndex(i)
|
||||
// Персист переноса: иначе после рестарта сообщение «воскреснет»
|
||||
// в исходной очереди (не удалено из Redis) и пропадёт из DLQ.
|
||||
_ = persistence.DeleteMessagePersist(key, msgUuid)
|
||||
persistence.SaveMessage(dlqKeyFor(key, dlqName), &dlqMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func numberOfHiddenMessagesInQueue(queue models.Queue) int {
|
||||
// 4. Компакция tombstone-слотов (амортизированно): срез не растёт бесконечно.
|
||||
queue.CompactIfNeeded()
|
||||
}
|
||||
|
||||
func numberOfHiddenMessagesInQueue(queue *models.Queue) int {
|
||||
num := 0
|
||||
for _, m := range queue.Messages {
|
||||
for i := range queue.Messages {
|
||||
if queue.IsDead(i) {
|
||||
continue
|
||||
}
|
||||
m := &queue.Messages[i]
|
||||
if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) {
|
||||
num++
|
||||
}
|
||||
|
||||
@@ -1,668 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
|
||||
"shared-sqs/app/fixtures"
|
||||
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TODO - Admiral-Piett these are better but still screwy. It's easy to have race conditions in here, so
|
||||
// we have to name all the queues uniquely and leave them around so we're not resetting ourselves.
|
||||
// Stupid. Handle the global memory issues and this can be easily resolved.
|
||||
func Test_PeriodicTasks_deletes_deduplication_period_upon_expiration(t *testing.T) {
|
||||
models.DeduplicationPeriod = 20 * time.Millisecond
|
||||
quit := make(chan bool)
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
quit <- true
|
||||
models.DeduplicationPeriod = 5 * time.Minute
|
||||
}()
|
||||
|
||||
qName := "gosqs-dedupe-queue1"
|
||||
mainQueue := &models.Queue{
|
||||
Name: qName,
|
||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
|
||||
Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName),
|
||||
Duplicates: map[string]time.Time{
|
||||
"12345": time.Now(),
|
||||
},
|
||||
}
|
||||
models.SyncQueues.Lock()
|
||||
models.SyncQueues.Queues[qName] = mainQueue
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
go PeriodicTasks(10*time.Millisecond, quit)
|
||||
|
||||
assertions := func() bool {
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
ok := 0 == len(mainQueue.Duplicates)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func Test_PeriodicTasks_VisibilityTimeout_expires(t *testing.T) {
|
||||
quit := make(chan bool)
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
quit <- true
|
||||
}()
|
||||
qName := "gosqs-visibility-queue1"
|
||||
mainQueue := &models.Queue{
|
||||
Name: qName,
|
||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
|
||||
Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName),
|
||||
}
|
||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
||||
MessageBody: "1",
|
||||
ReceiptHandle: "12345",
|
||||
VisibilityTimeout: time.Now().Add(30 * time.Millisecond),
|
||||
})
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
models.SyncQueues.Queues[qName] = mainQueue
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
go PeriodicTasks(10*time.Millisecond, quit)
|
||||
|
||||
assertions := func() bool {
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
ok := !mainQueue.Messages[0].ReceiptTime.IsZero()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ok = "1" == mainQueue.Messages[0].MessageBody
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ok = "" == mainQueue.Messages[0].ReceiptHandle
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ok = 1 == mainQueue.Messages[0].Retry
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func Test_PeriodicTasks_moves_single_message_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) {
|
||||
quit := make(chan bool)
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
quit <- true
|
||||
}()
|
||||
|
||||
qName := "gosqs-main-queue1"
|
||||
dlqName := "gosqs-dead-letter-queue1"
|
||||
dlqQueue := &models.Queue{
|
||||
Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, dlqName),
|
||||
Name: dlqName,
|
||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, dlqName),
|
||||
}
|
||||
mainQueue := &models.Queue{
|
||||
Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, qName),
|
||||
DeadLetterQueue: dlqQueue,
|
||||
MaxReceiveCount: 1,
|
||||
Name: qName,
|
||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
|
||||
}
|
||||
|
||||
go PeriodicTasks(10*time.Millisecond, quit)
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
||||
MessageBody: "1",
|
||||
Retry: 100,
|
||||
ReceiptHandle: "12345",
|
||||
VisibilityTimeout: time.Now().Add(10 * time.Millisecond),
|
||||
})
|
||||
models.SyncQueues.Queues[qName] = mainQueue
|
||||
models.SyncQueues.Queues[dlqName] = dlqQueue
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
assertions := func() bool {
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
ok := len(dlqQueue.Messages) == 1
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ok = "1" == dlqQueue.Messages[0].MessageBody
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func Test_PeriodicTasks_moves_multiple_messages_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) {
|
||||
quit := make(chan bool)
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
quit <- true
|
||||
}()
|
||||
|
||||
mainQueue := models.SyncQueues.Queues["unit-queue2"]
|
||||
dlqQueue := models.SyncQueues.Queues["dead-letter-queue1"]
|
||||
|
||||
assert.Len(t, dlqQueue.Messages, 0)
|
||||
|
||||
go PeriodicTasks(10*time.Millisecond, quit)
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
||||
MessageBody: "1",
|
||||
Retry: 100,
|
||||
ReceiptHandle: "12345",
|
||||
})
|
||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
||||
MessageBody: "2",
|
||||
Retry: 100,
|
||||
ReceiptHandle: "23456",
|
||||
})
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
assertions := func() bool {
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
ok := len(dlqQueue.Messages) == 2
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ok = "1" == dlqQueue.Messages[0].MessageBody
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ok = "2" == dlqQueue.Messages[1].MessageBody
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
// TODO - I think all these below belong in handler tests, not in here. Double check the relevant
|
||||
// handlers for coverage and delete.
|
||||
func TestSendingAndReceivingFromFIFOQueueReturnsSameMessageOnError(t *testing.T) {
|
||||
done := make(chan bool)
|
||||
go PeriodicTasks(1*time.Second, done)
|
||||
|
||||
// create a queue
|
||||
req, err := http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("Action", "CreateQueue")
|
||||
form.Add("QueueName", "requeue-reset.fifo")
|
||||
form.Add("Attribute.1.Name", "VisibilityTimeout")
|
||||
form.Add("Attribute.1.Value", "2")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ := CreateQueueV1(req)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
// send a message
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
||||
form.Add("MessageBody", "1")
|
||||
form.Add("MessageGroupId", "GROUP-X")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
// send a message
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
||||
form.Add("MessageBody", "2")
|
||||
form.Add("MessageGroupId", "GROUP-X")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
// receive message
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "ReceiveMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, resp := ReceiveMessageV1(req)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
||||
receiptHandleFirst := result.Messages[0].ReceiptHandle
|
||||
if string(result.Messages[0].Body) != "1" {
|
||||
t.Fatalf("should have received body 1: %s", err)
|
||||
}
|
||||
|
||||
// try to receive another message and we should get none
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "ReceiveMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = ReceiveMessageV1(req)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
if len(models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages) != 1 {
|
||||
t.Fatal("there should be only 1 group locked")
|
||||
}
|
||||
|
||||
if models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages["GROUP-X"] != 0 {
|
||||
t.Fatal("there should be GROUP-X locked")
|
||||
}
|
||||
|
||||
// remove message
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "DeleteMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
||||
form.Add("ReceiptHandle", receiptHandleFirst)
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = DeleteMessageV1(req)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
if len(models.SyncQueues.Queues["requeue-reset.fifo"].Messages) != 1 {
|
||||
t.Fatal("there should be only 1 message in queue")
|
||||
}
|
||||
|
||||
// receive message - loop until visibility timeouts
|
||||
for {
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "ReceiveMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, resp := ReceiveMessageV1(req)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
||||
if len(result.Messages) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if string(result.Messages[0].Body) != "2" {
|
||||
t.Fatalf("should have received body 2: %s", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
done <- true
|
||||
}
|
||||
|
||||
func TestSendMessage_POST_DuplicatationNotAppliedToStandardQueue(t *testing.T) {
|
||||
done := make(chan bool)
|
||||
go PeriodicTasks(1*time.Second, done)
|
||||
|
||||
// create a queue
|
||||
req, err := http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("Action", "CreateQueue")
|
||||
form.Add("QueueName", "stantdard-testing")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ := CreateQueueV1(req)
|
||||
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing")
|
||||
form.Add("MessageBody", "Test1")
|
||||
form.Add("MessageDeduplicationId", "123")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 0 {
|
||||
t.Fatal("there should be 1 message in queue")
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing")
|
||||
form.Add("MessageBody", "Test2")
|
||||
form.Add("MessageDeduplicationId", "123")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 1 {
|
||||
t.Fatal("there should be 2 messages in queue")
|
||||
}
|
||||
done <- true
|
||||
}
|
||||
|
||||
func TestSendMessage_POST_DuplicatationDisabledOnFifoQueue(t *testing.T) {
|
||||
done := make(chan bool)
|
||||
go PeriodicTasks(1*time.Second, done)
|
||||
|
||||
// create a queue
|
||||
req, err := http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("Action", "CreateQueue")
|
||||
form.Add("QueueName", "no-dup-testing.fifo")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ := CreateQueueV1(req)
|
||||
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo")
|
||||
form.Add("MessageBody", "Test1")
|
||||
form.Add("MessageDeduplicationId", "123")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) == 0 {
|
||||
t.Fatal("there should be 1 message in queue")
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo")
|
||||
form.Add("MessageBody", "Test2")
|
||||
form.Add("MessageDeduplicationId", "123")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) != 2 {
|
||||
t.Fatal("there should be 2 message in queue")
|
||||
}
|
||||
done <- true
|
||||
}
|
||||
|
||||
func TestSendMessage_POST_DuplicatationEnabledOnFifoQueue(t *testing.T) {
|
||||
done := make(chan bool)
|
||||
go PeriodicTasks(1*time.Second, done)
|
||||
|
||||
// create a queue
|
||||
req, err := http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("Action", "CreateQueue")
|
||||
form.Add("QueueName", "dup-testing.fifo")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ := CreateQueueV1(req)
|
||||
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
models.SyncQueues.Queues["dup-testing.fifo"].EnableDuplicates = true
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo")
|
||||
form.Add("MessageBody", "Test1")
|
||||
form.Add("MessageDeduplicationId", "123")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) == 0 {
|
||||
t.Fatal("there should be 1 message in queue")
|
||||
}
|
||||
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo")
|
||||
form.Add("MessageBody", "Test2")
|
||||
form.Add("MessageDeduplicationId", "123")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) != 1 {
|
||||
t.Fatal("there should be 1 message in queue")
|
||||
}
|
||||
if body := models.SyncQueues.Queues["dup-testing.fifo"].Messages[0].MessageBody; string(body) == "Test2" {
|
||||
t.Fatal("duplicate message should not be added to queue")
|
||||
}
|
||||
done <- true
|
||||
}
|
||||
|
||||
func TestSendMessage_POST_DelaySeconds(t *testing.T) {
|
||||
// create a queue
|
||||
req, err := http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Add("Action", "CreateQueue")
|
||||
form.Add("QueueName", "sendmessage-delay")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ := CreateQueueV1(req)
|
||||
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
// send a message
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form = url.Values{}
|
||||
form.Add("Action", "SendMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
|
||||
form.Add("MessageBody", "1")
|
||||
form.Add("DelaySeconds", "2")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
|
||||
status, _ = SendMessageV1(req)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
// receive message before delay is up
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form = url.Values{}
|
||||
form.Add("Action", "ReceiveMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
status, _ = ReceiveMessageV1(req)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
|
||||
// receive message with wait should return after delay
|
||||
req, err = http.NewRequest("POST", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form = url.Values{}
|
||||
form.Add("Action", "ReceiveMessage")
|
||||
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
|
||||
form.Add("WaitTimeSeconds", "10")
|
||||
form.Add("Version", "2012-11-05")
|
||||
req.PostForm = form
|
||||
start := time.Now()
|
||||
status, _ = ReceiveMessageV1(req)
|
||||
elapsed := time.Since(start)
|
||||
assert.Equal(t, status, http.StatusOK)
|
||||
if elapsed < 1*time.Second {
|
||||
t.Errorf("handler didn't wait at all")
|
||||
}
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateErrorResponseV1(t *testing.T) {
|
||||
expectedResponse := models.ErrorResponse{
|
||||
Result: models.ErrorResult{
|
||||
Type: "Not Found",
|
||||
Code: "AWS.SimpleQueueService.NonExistentQueue",
|
||||
Message: "The specified queue does not exist for this wsdl version.",
|
||||
},
|
||||
RequestId: "00000000-0000-0000-0000-000000000000",
|
||||
}
|
||||
status, response := utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
}
|
||||
|
||||
// waitTimeout waits for the waitgroup for the specified max timeout.
|
||||
// Returns true if waiting timed out.
|
||||
// credits: https://stackoverflow.com/questions/32840687/timeout-for-waitgroup-wait
|
||||
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
|
||||
c := make(chan struct{})
|
||||
go func() {
|
||||
defer close(c)
|
||||
wg.Wait()
|
||||
}()
|
||||
select {
|
||||
case <-c:
|
||||
return false // completed normally
|
||||
case <-time.After(timeout):
|
||||
return true // timed out
|
||||
}
|
||||
}
|
||||
@@ -40,13 +40,11 @@ func ListQueueTagsV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
models.SyncQueues.RLock()
|
||||
defer models.SyncQueues.RUnlock()
|
||||
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
queue := models.SyncQueues.GetRLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.RUnlockMu()
|
||||
|
||||
tags := queue.Tags
|
||||
if tags == nil {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListQueuesV1_success(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.ListQueueRequest)
|
||||
*v = models.ListQueueRequest{}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := ListQueuesV1(r)
|
||||
r1 := response.(models.ListQueuesResponse)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"))
|
||||
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue2"))
|
||||
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1"))
|
||||
}
|
||||
|
||||
func TestListQueuesV1_success_no_queues(t *testing.T) {
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.ListQueueRequest)
|
||||
*v = models.ListQueueRequest{}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := ListQueuesV1(r)
|
||||
r1 := response.(models.ListQueuesResponse)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, r1.Result.QueueUrls, []string{})
|
||||
}
|
||||
|
||||
func TestListQueuesV1_success_with_queue_name_prefix(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.ListQueueRequest)
|
||||
*v = models.ListQueueRequest{QueueNamePrefix: "dead-letter"}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := ListQueuesV1(r)
|
||||
r1 := response.(models.ListQueuesResponse)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, []string{fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1")}, r1.Result.QueueUrls)
|
||||
}
|
||||
|
||||
func TestListQueuesV1_success_with_queue_name_prefix_no_matching_queues(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.ListQueueRequest)
|
||||
*v = models.ListQueueRequest{QueueNamePrefix: "garbage"}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := ListQueuesV1(r)
|
||||
r1 := response.(models.ListQueuesResponse)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, []string{}, r1.Result.QueueUrls)
|
||||
}
|
||||
|
||||
func TestListQueuesV1_request_transformer_error(t *testing.T) {
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := ListQueuesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// app/gosqs/memory_guard.go
|
||||
// Глобальный предохранитель памяти процесса (защита от OOM одним тенантом).
|
||||
//
|
||||
// Лимиты по числу сообщений (MaxMessagesForQueue) не ограничивают СУММАРНЫЙ
|
||||
// объём: 120k × 256 КБ ≈ 30 ГБ на одну очередь. Этот предохранитель отказывает
|
||||
// в SendMessage/SendMessageBatch по HeapAlloc выше порога — единственная защита
|
||||
// процесса от суммы всех тенантов.
|
||||
//
|
||||
// Задаётся env `SHARED_SQS_MAX_HEAP_MB` (0 = выключен). По умолчанию выключен:
|
||||
// конкретное число зависит от RAM ВМ и целевого числа тенантов (решение владельца).
|
||||
// Рекомендация на старт: ~70% от RAM ВМ, напр. 1024 для 1.5 ГБ ВМ.
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// maxHeapBytes — порог HeapAlloc в байтах; 0 = предохранитель выключен.
|
||||
var maxHeapBytes = func() int64 {
|
||||
mb, err := strconv.ParseInt(os.Getenv("SHARED_SQS_MAX_HEAP_MB"), 10, 64)
|
||||
if err != nil || mb <= 0 {
|
||||
return 0
|
||||
}
|
||||
return mb * 1024 * 1024
|
||||
}()
|
||||
|
||||
// heapOverLimit — true, если текущий HeapAlloc превышает порог.
|
||||
func heapOverLimit() bool {
|
||||
if maxHeapBytes <= 0 {
|
||||
return false
|
||||
}
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
return int64(ms.HeapAlloc) > maxHeapBytes
|
||||
}
|
||||
@@ -45,29 +45,29 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
queueName := uriSegments[len(uriSegments)-1]
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
if _, ok := models.SyncQueues.Queues[key]; !ok {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID)
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
// AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь.
|
||||
// Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает.
|
||||
if time.Since(models.SyncQueues.Queues[key].LastPurgeTime) < 60*time.Second {
|
||||
log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, models.SyncQueues.Queues[key].LastPurgeTime.Format(time.RFC3339))
|
||||
if time.Since(queue.LastPurgeTime) < 60*time.Second {
|
||||
log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, queue.LastPurgeTime.Format(time.RFC3339))
|
||||
return utils.CreateErrorResponseV1("PurgeQueueInProgress", true)
|
||||
}
|
||||
|
||||
log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID)
|
||||
models.SyncQueues.Queues[key].Messages = nil
|
||||
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
|
||||
queue.Reset()
|
||||
queue.Duplicates = make(map[string]time.Time)
|
||||
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
|
||||
models.SyncQueues.Queues[key].LastPurgeTime = time.Now()
|
||||
queue.LastPurgeTime = time.Now()
|
||||
// Удаляем все сообщения из Redis одной командой DEL
|
||||
persistence.PurgeMessagesPersist(key)
|
||||
// Сохраняем пустые метаданные очереди
|
||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
||||
persistence.SaveQueue(key, queue)
|
||||
|
||||
respStruct := models.PurgeQueueResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPurgeQueueV1_success(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.PurgeQueueRequest)
|
||||
*v = models.PurgeQueueRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Put a message on the queue
|
||||
targetQueue := models.SyncQueues.Queues["unit-queue1"]
|
||||
models.SyncQueues.Lock()
|
||||
targetQueue.Messages = []models.SqsMessage{models.SqsMessage{}}
|
||||
targetQueue.Duplicates = map[string]time.Time{
|
||||
"dedupe-id": time.Now(),
|
||||
}
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
expectedResponse := models.PurgeQueueResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := PurgeQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
|
||||
assert.Nil(t, targetQueue.Messages)
|
||||
assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates)
|
||||
}
|
||||
|
||||
func TestPurgeQueueV1_success_no_messages_on_queue(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.PurgeQueueRequest)
|
||||
*v = models.PurgeQueueRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
expectedResponse := models.PurgeQueueResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := PurgeQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
|
||||
targetQueue := models.SyncQueues.Queues["unit-queue1"]
|
||||
assert.Nil(t, targetQueue.Messages)
|
||||
assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates)
|
||||
}
|
||||
|
||||
func TestPurgeQueueV1_request_transformer_error(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := PurgeQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestPurgeQueueV1_requested_queue_does_not_exist(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.PurgeQueueRequest)
|
||||
*v = models.PurgeQueueRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "garbage"),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := PurgeQueueV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -22,9 +24,11 @@ import (
|
||||
// обнулял все остальные атрибуты очереди.
|
||||
// 3. Значения вне диапазонов AWS → ошибка InvalidParameterValue
|
||||
// (раньше значения молча клэмпились в допустимый диапазон).
|
||||
// 4. RedrivePolicy: ARN DLQ разбирается, DLQ должна существовать, иначе
|
||||
// InvalidAttributeValue.
|
||||
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided map[string]string) error {
|
||||
// 4. RedrivePolicy: ARN DLQ разбирается, DLQ ищется по TENANT-SCOPED ключу
|
||||
// "{accessKey}:{queueName}" (раньше — по голому имени, а ключи в map
|
||||
// tenant-scoped → DLQ никогда не находилась, RedrivePolicy не работал).
|
||||
// DLQ должна принадлежать тому же тенанту.
|
||||
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided map[string]string, tenantAccessKey string) error {
|
||||
// Шаг 1: whitelist имён атрибутов (единый список — models.AttrNameWhitelist).
|
||||
for name := range provided {
|
||||
if !models.AttrNameWhitelist[name] {
|
||||
@@ -71,13 +75,51 @@ func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided
|
||||
if attr.RedrivePolicy != (models.RedrivePolicy{}) {
|
||||
arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":")
|
||||
queueName := arnArray[len(arnArray)-1]
|
||||
deadLetterQueue, ok := models.SyncQueues.Queues[queueName]
|
||||
// DLQ ищем по tenant-scoped ключу — как хранятся все очереди.
|
||||
dlqKey := tenantAccessKey + ":" + queueName
|
||||
models.SyncQueues.RLock()
|
||||
deadLetterQueue, ok := models.SyncQueues.Queues[dlqKey]
|
||||
models.SyncQueues.RUnlock()
|
||||
if !ok {
|
||||
log.Error("Invalid RedrivePolicy Attribute")
|
||||
return fmt.Errorf("InvalidAttributeValue")
|
||||
}
|
||||
// Запрет self-DLQ (q → q) и циклов (A → B → A): при переносе в DLQ
|
||||
// переносящий код берёт DeadLetterQueue.LockMu() под уже взятым q.mu —
|
||||
// self/цикл даёт немедленный self-deadlock (или AB/BA-deadlock).
|
||||
if deadLetterQueue == q {
|
||||
log.Error("Invalid RedrivePolicy Attribute: self DLQ")
|
||||
return fmt.Errorf("InvalidAttributeValue")
|
||||
}
|
||||
// Обход цепочки DLQ для детекта циклов. Лимит 100 — защита от уже
|
||||
// существующего в данных цикла (если бы он там был).
|
||||
for cur, steps := deadLetterQueue, 0; cur != nil && steps < 100; cur, steps = cur.DeadLetterQueue, steps+1 {
|
||||
if cur == q {
|
||||
log.Error("Invalid RedrivePolicy Attribute: cyclic DLQ")
|
||||
return fmt.Errorf("InvalidAttributeValue")
|
||||
}
|
||||
}
|
||||
q.DeadLetterQueue = deadLetterQueue
|
||||
q.MaxReceiveCount = attr.RedrivePolicy.MaxReceiveCount.Int()
|
||||
}
|
||||
|
||||
// Шаг 4: FIFO-атрибуты и Policy — храним как есть (совместимость с Terraform).
|
||||
// FifoQueue только включаем (выключить FIFO у существующей очереди нельзя —
|
||||
// как и в AWS). ContentBasedDeduplication и Policy не исполняются,
|
||||
// но должны возвращаться в GetQueueAttributes, иначе drift в Terraform.
|
||||
if attr.FifoQueue.Bool() {
|
||||
q.IsFIFO = true
|
||||
}
|
||||
if attr.ContentBasedDeduplication.Bool() {
|
||||
q.ContentBasedDeduplication = true
|
||||
}
|
||||
if attr.Policy != nil {
|
||||
// Сохраняем канонический JSON (map → marshal сортирует ключи) —
|
||||
// детерминированно, без drift при чтении.
|
||||
if data, err := json.Marshal(attr.Policy); err == nil {
|
||||
q.Policy = string(data)
|
||||
}
|
||||
}
|
||||
q.LastModifiedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"shared-sqs/app/models"
|
||||
)
|
||||
|
||||
func TestSetQueueAttributesV1_success_no_redrive_policy(t *testing.T) {
|
||||
var emptyQueue *models.Queue
|
||||
q := &models.Queue{}
|
||||
attrs := models.QueueAttributes{
|
||||
DelaySeconds: 1,
|
||||
MaximumMessageSize: 2,
|
||||
MessageRetentionPeriod: 3,
|
||||
ReceiveMessageWaitTimeSeconds: 4,
|
||||
VisibilityTimeout: 5,
|
||||
}
|
||||
err := setQueueAttributesV1(q, attrs)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, q.DelaySeconds)
|
||||
assert.Equal(t, 2, q.MaximumMessageSize)
|
||||
assert.Equal(t, 3, q.MessageRetentionPeriod)
|
||||
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
|
||||
assert.Equal(t, 5, q.VisibilityTimeout)
|
||||
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
|
||||
assert.Equal(t, 0, q.MaxReceiveCount)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_success_no_request_attributes(t *testing.T) {
|
||||
var emptyQueue *models.Queue
|
||||
q := &models.Queue{}
|
||||
attrs := models.QueueAttributes{}
|
||||
err := setQueueAttributesV1(q, attrs)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, q.DelaySeconds)
|
||||
assert.Equal(t, 0, q.MaximumMessageSize)
|
||||
assert.Equal(t, 0, q.MessageRetentionPeriod)
|
||||
assert.Equal(t, 0, q.ReceiveMessageWaitTimeSeconds)
|
||||
assert.Equal(t, 0, q.VisibilityTimeout)
|
||||
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
|
||||
assert.Equal(t, 0, q.MaxReceiveCount)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_success_can_set_0_values_where_applicable(t *testing.T) {
|
||||
var emptyQueue *models.Queue
|
||||
q := &models.Queue{
|
||||
DelaySeconds: 1,
|
||||
MaximumMessageSize: 2,
|
||||
MessageRetentionPeriod: 3,
|
||||
ReceiveMessageWaitTimeSeconds: 4,
|
||||
VisibilityTimeout: 5,
|
||||
}
|
||||
attrs := models.QueueAttributes{}
|
||||
err := setQueueAttributesV1(q, attrs)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, q.DelaySeconds)
|
||||
assert.Equal(t, 0, q.MaximumMessageSize)
|
||||
assert.Equal(t, 3, q.MessageRetentionPeriod)
|
||||
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
|
||||
assert.Equal(t, 0, q.VisibilityTimeout)
|
||||
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
|
||||
assert.Equal(t, 0, q.MaxReceiveCount)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_success_with_redrive_policy(t *testing.T) {
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
existingQueueName := "existing-queue"
|
||||
existingQueue := &models.Queue{Name: existingQueueName}
|
||||
models.SyncQueues.Queues[existingQueueName] = existingQueue
|
||||
|
||||
q := &models.Queue{}
|
||||
attrs := models.QueueAttributes{
|
||||
DelaySeconds: 1,
|
||||
MaximumMessageSize: 2,
|
||||
MessageRetentionPeriod: 3,
|
||||
ReceiveMessageWaitTimeSeconds: 4,
|
||||
VisibilityTimeout: 5,
|
||||
RedrivePolicy: models.RedrivePolicy{
|
||||
MaxReceiveCount: 10,
|
||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName),
|
||||
},
|
||||
}
|
||||
err := setQueueAttributesV1(q, attrs)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, q.DelaySeconds)
|
||||
assert.Equal(t, 2, q.MaximumMessageSize)
|
||||
assert.Equal(t, 3, q.MessageRetentionPeriod)
|
||||
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
|
||||
assert.Equal(t, 5, q.VisibilityTimeout)
|
||||
assert.Equal(t, existingQueue, q.DeadLetterQueue)
|
||||
assert.Equal(t, 10, q.MaxReceiveCount)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_error_redrive_policy_targets_missing_queue(t *testing.T) {
|
||||
existingQueueName := "existing-queue"
|
||||
|
||||
q := &models.Queue{}
|
||||
attrs := models.QueueAttributes{
|
||||
DelaySeconds: 1,
|
||||
MaximumMessageSize: 2,
|
||||
MessageRetentionPeriod: 3,
|
||||
ReceiveMessageWaitTimeSeconds: 4,
|
||||
VisibilityTimeout: 5,
|
||||
RedrivePolicy: models.RedrivePolicy{
|
||||
MaxReceiveCount: 10,
|
||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName),
|
||||
},
|
||||
}
|
||||
err := setQueueAttributesV1(q, attrs)
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -86,17 +86,15 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
|
||||
// ОДИН RLock на проверку существования И чтение атрибута очереди:
|
||||
// раньше было два отдельных RLock — между ними очередь могла быть удалена
|
||||
// (nil-deref при чтении ReceiveMessageWaitTimeSeconds).
|
||||
models.SyncQueues.RLock()
|
||||
queue, queueExists := models.SyncQueues.Queues[key]
|
||||
if !queueExists {
|
||||
models.SyncQueues.RUnlock()
|
||||
queue := models.SyncQueues.GetRLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
waitTimeSeconds := requestBody.WaitTimeSeconds
|
||||
if waitTimeSeconds == 0 {
|
||||
waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
queue.RUnlockMu()
|
||||
if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
@@ -155,11 +153,15 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
|
||||
// (ApproximateReceiveCount в ответе);
|
||||
// - для FIFO-очереди блокируется группа (LockGroup) до DeleteMessage/таймаута.
|
||||
func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages int) ([]*models.ResultMessage, bool) {
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
queue, queueFound := models.SyncQueues.Queues[key]
|
||||
if !queueFound || len(queue.Messages) == 0 {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
return nil, false
|
||||
}
|
||||
// ВАЖНО: defer — ДО любых return ниже. Иначе возврат на пустой очереди
|
||||
// оставлял бы per-queue блокировку захваченной навсегда (очередь «зависала»:
|
||||
// все последующие Lock/RLock ждали бы вечно).
|
||||
defer queue.UnlockMu()
|
||||
if queue.LiveCount() == 0 {
|
||||
return nil, false
|
||||
}
|
||||
// Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
|
||||
@@ -173,6 +175,9 @@ func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages
|
||||
if numMsg >= maxNumberOfMessages {
|
||||
break
|
||||
}
|
||||
if queue.IsDead(i) {
|
||||
continue // tombstone: сообщение логически удалено (P1.2)
|
||||
}
|
||||
msg := &queue.Messages[i]
|
||||
if msg.ReceiptHandle != "" {
|
||||
continue // сообщение уже в полёте у другого получателя
|
||||
@@ -196,6 +201,9 @@ func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages
|
||||
msg.VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second)
|
||||
}
|
||||
msg.NumberOfReceives++
|
||||
// Регистрируем handle→index: по нему DeleteMessage/ChangeMessageVisibility
|
||||
// работают за O(1), а visibility-тик итерирует только in-flight (P1.2).
|
||||
queue.MarkInFlight(i, msg.ReceiptHandle)
|
||||
|
||||
messages = append(messages, buildResultMessage(msg))
|
||||
numMsg++
|
||||
@@ -236,7 +244,7 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
|
||||
Attributes: map[string]string{
|
||||
"ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)),
|
||||
"SenderId": models.CurrentEnvironment.AccountID,
|
||||
"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives+1),
|
||||
"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives),
|
||||
"SentTimestamp": fmt.Sprintf("%d", m.SentTime.UnixNano()/int64(time.Millisecond)), // Фикс: реальное время отправки
|
||||
},
|
||||
}
|
||||
@@ -250,6 +258,9 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
|
||||
// читает общее состояние очереди.
|
||||
func queueHasReceivableMessages(queue *models.Queue) bool {
|
||||
for i := range queue.Messages {
|
||||
if queue.IsDead(i) {
|
||||
continue // tombstone (P1.2)
|
||||
}
|
||||
msg := &queue.Messages[i]
|
||||
if msg.ReceiptHandle != "" {
|
||||
continue
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TODO Admiral-Piett - fix all these tests, we don't want to be waiting for 5 seconds plus like this.
|
||||
func TestReceiveMessageV1_with_WaitTimeEnforced(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "waiting-queue",
|
||||
ReceiveMessageWaitTimeSeconds: 2,
|
||||
//MaximumMessageSize: 262144,
|
||||
}
|
||||
models.SyncQueues.Queues["waiting-queue"] = q
|
||||
|
||||
// receive message ensure delay
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/waiting-queue",
|
||||
}, true)
|
||||
|
||||
start := time.Now()
|
||||
status, response := ReceiveMessageV1(r)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
if elapsed < 2*time.Second {
|
||||
t.Fatalf("handler didn't wait ReceiveMessageWaitTimeSeconds %s", response)
|
||||
}
|
||||
|
||||
// mock sending a message
|
||||
q.Messages = append(q.Messages, models.SqsMessage{MessageBody: "1"})
|
||||
|
||||
// receive message
|
||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/waiting-queue",
|
||||
}, true)
|
||||
start = time.Now()
|
||||
status, resp := ReceiveMessageV1(r)
|
||||
elapsed = time.Since(start)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
if elapsed > 1*time.Second {
|
||||
t.Fatal("handler waited when message was available, expected not to wait")
|
||||
}
|
||||
|
||||
assert.Equal(t, "1", string(resp.GetResult().(models.ReceiveMessageResult).Messages[0].Body))
|
||||
}
|
||||
|
||||
func TestReceiveMessageV1_CanceledByClient(t *testing.T) {
|
||||
// create a queue
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "cancel-queue",
|
||||
ReceiveMessageWaitTimeSeconds: 20,
|
||||
}
|
||||
models.SyncQueues.Queues["cancel-queue"] = q
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancelReceive := context.WithCancel(context.Background())
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// receive message (that will be canceled)
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/cancel-queue",
|
||||
}, true)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
status, resp := ReceiveMessageV1(r)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
|
||||
if len(resp.GetResult().(models.ReceiveMessageResult).Messages) != 0 {
|
||||
t.Fatal("expecting this ReceiveMessage() to not pickup this message as it should canceled before the Send()")
|
||||
}
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond) // let enought time for the Receive go to wait mode
|
||||
cancelReceive() // cancel the first ReceiveMessage(), make sure it will not pickup the sent message below
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// send a message
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/cancel-queue",
|
||||
MessageBody: "12345",
|
||||
}, true)
|
||||
status, _ := SendMessageV1(r)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK)
|
||||
}
|
||||
|
||||
// receive message
|
||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/cancel-queue",
|
||||
}, true)
|
||||
start := time.Now()
|
||||
status, resp := ReceiveMessageV1(r)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
result, ok := resp.GetResult().(models.ReceiveMessageResult)
|
||||
if !ok {
|
||||
t.Fatal("handler should return a message")
|
||||
}
|
||||
|
||||
if len(result.Messages) == 0 || string(result.Messages[0].Body) == "12345\n" {
|
||||
t.Fatal("handler should return a message")
|
||||
}
|
||||
if elapsed > 1*time.Second {
|
||||
t.Fatal("handler waited when message was available, expected not to wait")
|
||||
}
|
||||
|
||||
if timedout := waitTimeout(&wg, 2*time.Second); timedout {
|
||||
t.Errorf("expected ReceiveMessage() in goroutine to exit quickly due to cancelReceive() called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiveMessageV1_with_DelaySeconds(t *testing.T) {
|
||||
// create a queue
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "delay-seconds-queue",
|
||||
DelaySeconds: 2,
|
||||
}
|
||||
models.SyncQueues.Queues["delay-seconds-queue"] = q
|
||||
|
||||
// send a message
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/delay-seconds-queue",
|
||||
MessageBody: "1",
|
||||
}, true)
|
||||
status, _ := SendMessageV1(r)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK)
|
||||
}
|
||||
|
||||
// receive message before delay is up
|
||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/delay-seconds-queue"}, true)
|
||||
status, _ = ReceiveMessageV1(r)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
|
||||
// receive message with wait should return after delay
|
||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/delay-seconds-queue",
|
||||
WaitTimeSeconds: 10,
|
||||
}, true)
|
||||
start := time.Now()
|
||||
status, _ = ReceiveMessageV1(r)
|
||||
elapsed := time.Since(start)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
if elapsed < 1*time.Second {
|
||||
t.Errorf("handler didn't wait at all")
|
||||
}
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiveMessageV1_with_MessageAttributes(t *testing.T) {
|
||||
// create a queue
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
q := &models.Queue{Name: "waiting-queue"}
|
||||
models.SyncQueues.Queues["waiting-queue"] = q
|
||||
|
||||
// send a message
|
||||
q.Messages = append(q.Messages, models.SqsMessage{
|
||||
MessageBody: "1",
|
||||
MessageAttributes: map[string]models.MessageAttribute{
|
||||
"TestMessageAttrName": {
|
||||
DataType: "String",
|
||||
StringValue: "TestMessageAttrValue",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// receive message
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/waiting-queue"}, true)
|
||||
status, resp := ReceiveMessageV1(r)
|
||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
||||
|
||||
assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateFirstReceiveTimestamp"])
|
||||
assert.NotEmpty(t, result.Messages[0].Attributes["SenderId"])
|
||||
assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateReceiveCount"])
|
||||
assert.NotEmpty(t, result.Messages[0].Attributes["SentTimestamp"])
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "1", string(result.Messages[0].Body))
|
||||
assert.Equal(t, 1, len(result.Messages[0].MessageAttributes))
|
||||
assert.Equal(t, "String", result.Messages[0].MessageAttributes["TestMessageAttrName"].DataType)
|
||||
assert.Equal(t, "TestMessageAttrValue", result.Messages[0].MessageAttributes["TestMessageAttrName"].StringValue)
|
||||
}
|
||||
|
||||
func TestReceiveMessageV1_request_transformer_error(t *testing.T) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
func TestReceiveMessageV1_with_CustomVisibilityTimeout(t *testing.T) {
|
||||
// create a queue
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
// Create a queue with a default visibility timeout of 30 seconds
|
||||
q := &models.Queue{
|
||||
Name: "custom-visibility-queue",
|
||||
VisibilityTimeout: 30,
|
||||
}
|
||||
models.SyncQueues.Queues["custom-visibility-queue"] = q
|
||||
|
||||
// Add a message to the queue
|
||||
q.Messages = append(q.Messages, models.SqsMessage{
|
||||
MessageBody: "test-message",
|
||||
Uuid: "test-uuid",
|
||||
})
|
||||
|
||||
// Test 1: Receive message with custom visibility timeout
|
||||
customTimeout := 60 // 60 seconds
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/custom-visibility-queue",
|
||||
VisibilityTimeout: customTimeout,
|
||||
}, true)
|
||||
|
||||
status, resp := ReceiveMessageV1(r)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
|
||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
||||
assert.Equal(t, 1, len(result.Messages))
|
||||
assert.Equal(t, "test-message", string(result.Messages[0].Body))
|
||||
|
||||
// Verify the message in the queue has the custom visibility timeout
|
||||
// We can't directly check the exact time, but we can verify it's not using the queue's default
|
||||
// by checking that the visibility timeout is greater than now + default timeout - 1 second
|
||||
// and less than now + custom timeout + 1 second
|
||||
now := time.Now()
|
||||
defaultExpiry := now.Add(time.Duration(q.VisibilityTimeout) * time.Second)
|
||||
customExpiry := now.Add(time.Duration(customTimeout) * time.Second)
|
||||
|
||||
// The first message should have the custom visibility timeout
|
||||
msgVisibilityTimeout := q.Messages[0].VisibilityTimeout
|
||||
assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)),
|
||||
"Message visibility timeout should be greater than default timeout")
|
||||
assert.True(t, msgVisibilityTimeout.Before(customExpiry.Add(1*time.Second)),
|
||||
"Message visibility timeout should be less than custom timeout + 1 second")
|
||||
|
||||
// Test 2: Reset the queue and test with zero visibility timeout (should use queue default)
|
||||
models.SyncQueues.Queues["custom-visibility-queue"] = &models.Queue{
|
||||
Name: "custom-visibility-queue",
|
||||
VisibilityTimeout: 30,
|
||||
}
|
||||
q = models.SyncQueues.Queues["custom-visibility-queue"]
|
||||
q.Messages = append(q.Messages, models.SqsMessage{
|
||||
MessageBody: "test-message-2",
|
||||
Uuid: "test-uuid-2",
|
||||
})
|
||||
|
||||
// Receive message with zero visibility timeout (should use queue default)
|
||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: "http://localhost:4100/queue/custom-visibility-queue",
|
||||
VisibilityTimeout: 0, // Zero should use queue default
|
||||
}, true)
|
||||
|
||||
status, resp = ReceiveMessageV1(r)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
|
||||
// Verify the message in the queue has the default visibility timeout
|
||||
now = time.Now()
|
||||
defaultExpiry = now.Add(time.Duration(q.VisibilityTimeout) * time.Second)
|
||||
|
||||
// The message should have the default visibility timeout
|
||||
msgVisibilityTimeout = q.Messages[0].VisibilityTimeout
|
||||
assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)),
|
||||
"Message visibility timeout should be greater than default timeout - 1 second")
|
||||
assert.True(t, msgVisibilityTimeout.Before(defaultExpiry.Add(1*time.Second)),
|
||||
"Message visibility timeout should be less than default timeout + 1 second")
|
||||
}
|
||||
|
||||
func TestReceiveMessageV1_FIFOSecondMessageAvailableAfterDelete(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
}()
|
||||
|
||||
queueName := "fifo-delay-queue"
|
||||
queueURL := fmt.Sprintf("http://localhost:4100/queue/%s", queueName)
|
||||
now := time.Now().Add(-1 * time.Minute)
|
||||
|
||||
// create a queue with a visibility timeout of 10 seconds
|
||||
q := &models.Queue{
|
||||
Name: queueName,
|
||||
VisibilityTimeout: 10,
|
||||
IsFIFO: true,
|
||||
FIFOMessages: map[string]int{},
|
||||
FIFOSequenceNumbers: map[string]int{},
|
||||
Duplicates: map[string]time.Time{},
|
||||
Messages: []models.SqsMessage{
|
||||
{
|
||||
MessageBody: "first",
|
||||
Uuid: "first-uuid",
|
||||
GroupID: "company#worker",
|
||||
SentTime: now,
|
||||
},
|
||||
{
|
||||
MessageBody: "second",
|
||||
Uuid: "second-uuid",
|
||||
GroupID: "company#worker",
|
||||
SentTime: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
models.SyncQueues.Queues[queueName] = q
|
||||
|
||||
// receive the first FIFO message only
|
||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: queueURL,
|
||||
MaxNumberOfMessages: 1,
|
||||
}, true)
|
||||
status, resp := ReceiveMessageV1(r)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
||||
if len(result.Messages) != 1 {
|
||||
t.Fatalf("expected to receive the first FIFO message, got %d", len(result.Messages))
|
||||
}
|
||||
assert.Equal(t, "first", result.Messages[0].Body)
|
||||
|
||||
firstReceipt := result.Messages[0].ReceiptHandle
|
||||
|
||||
// verify the second FIFO message is blocked while the first is in flight
|
||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: queueURL,
|
||||
}, true)
|
||||
status, resp = ReceiveMessageV1(r)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
result = resp.GetResult().(models.ReceiveMessageResult)
|
||||
if len(result.Messages) != 0 {
|
||||
t.Fatalf("expected no FIFO message while the first is outstanding, got %d", len(result.Messages))
|
||||
}
|
||||
|
||||
// delete the first FIFO message
|
||||
_, deleteReq := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
|
||||
QueueUrl: queueURL,
|
||||
ReceiptHandle: firstReceipt,
|
||||
}, true)
|
||||
deleteStatus, _ := DeleteMessageV1(deleteReq)
|
||||
assert.Equal(t, http.StatusOK, deleteStatus)
|
||||
|
||||
// receive the second FIFO message and ensure it does not wait for full 10 second visibility timeout
|
||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
||||
QueueUrl: queueURL,
|
||||
}, true)
|
||||
start := time.Now()
|
||||
status, resp = ReceiveMessageV1(r)
|
||||
elapsed := time.Since(start)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
result = resp.GetResult().(models.ReceiveMessageResult)
|
||||
if len(result.Messages) != 1 {
|
||||
t.Fatalf("expected second FIFO message to be available immediately, got %d", len(result.Messages))
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("expected second FIFO message without waiting on visibility timeout, took %s", elapsed)
|
||||
}
|
||||
assert.Equal(t, "second", result.Messages[0].Body)
|
||||
|
||||
// delete the second FIFO message
|
||||
_, deleteReq = test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
|
||||
QueueUrl: queueURL,
|
||||
ReceiptHandle: result.Messages[0].ReceiptHandle,
|
||||
}, true)
|
||||
deleteStatus, _ = DeleteMessageV1(deleteReq)
|
||||
assert.Equal(t, http.StatusOK, deleteStatus)
|
||||
|
||||
if len(q.Messages) != 0 {
|
||||
t.Fatalf("expected all FIFO messages to be deleted, remaining %d", len(q.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - other tests
|
||||
+43
-26
@@ -83,17 +83,17 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
models.SyncQueues.RLock()
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
models.SyncQueues.RUnlock()
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
maxMessageSize := queue.MaximumMessageSize
|
||||
currentMsgCount := len(queue.Messages)
|
||||
currentMsgCount := queue.LiveCount()
|
||||
queueIsFIFO := queue.IsFIFO
|
||||
contentBasedDedup := queue.ContentBasedDeduplication
|
||||
delaySecs := queue.DelaySeconds
|
||||
models.SyncQueues.RUnlock()
|
||||
|
||||
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
|
||||
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
|
||||
@@ -101,6 +101,12 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
if queueIsFIFO && messageGroupID == "" {
|
||||
return utils.CreateErrorResponseV1("MissingParameter", true)
|
||||
}
|
||||
// ContentBasedDeduplication: если MessageDeduplicationId не передан, AWS сам
|
||||
// вычисляет его из тела сообщения (SHA-256). Без этого включённый флаг
|
||||
// ничего не делал бы и FIFO-дедупликация не работала.
|
||||
if queueIsFIFO && contentBasedDedup && messageDeduplicationID == "" {
|
||||
messageDeduplicationID = utils.GetSHA256Hash(messageBody)
|
||||
}
|
||||
|
||||
if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
|
||||
return utils.CreateErrorResponseV1("MessageTooBig", true)
|
||||
@@ -111,6 +117,9 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
|
||||
if requestBody.DelaySeconds < 0 || requestBody.DelaySeconds > 900 {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
if requestBody.DelaySeconds != 0 {
|
||||
delaySecs = requestBody.DelaySeconds
|
||||
}
|
||||
@@ -128,38 +137,46 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
msg.SentTime = time.Now()
|
||||
msg.DelaySecs = delaySecs
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой
|
||||
// выше и захватом Lock (иначе nil-pointer deref).
|
||||
queue = models.SyncQueues.Queues[key]
|
||||
if queue == nil {
|
||||
models.SyncQueues.Unlock()
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
// Повторная проверка лимита под per-queue Lock (между снимком выше и текущим
|
||||
// моментом другие горутины могли добавить сообщения — мягкий OOM-лимит).
|
||||
if queue.LiveCount() >= MaxMessagesForQueue(queue.IsFIFO) {
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
// Повторная проверка лимита под Lock: между RLock-снимком и захватом Lock
|
||||
// другие горутины могли добавить сообщения (мягкий OOM-лимит).
|
||||
if len(queue.Messages) >= MaxMessagesForQueue(queue.IsFIFO) {
|
||||
models.SyncQueues.Unlock()
|
||||
// Глобальный предохранитель памяти (HeapAlloc) — защита от OOM суммой тенантов.
|
||||
if heapOverLimit() {
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
|
||||
fifoSeqNumber := ""
|
||||
persistErr := false
|
||||
if !queue.IsDuplicate(messageDeduplicationID) {
|
||||
idx := queue.AppendMessage(msg)
|
||||
// Персистим сообщение СИНХРОННО до ответа клиенту: краш пода в окне
|
||||
// между 200 OK и async-записью приводил бы к потере подтверждённого
|
||||
// сообщения. При ошибке — откатываем append (не оставляем в памяти
|
||||
// сообщение, которого нет в Redis).
|
||||
if err := persistence.SaveMessageSync(key, &msg); err != nil {
|
||||
log.Errorf("SendMessage: не удалось персистить сообщение %s в очередь %s: %v", msg.Uuid, key, err)
|
||||
queue.RemoveIndex(idx)
|
||||
persistErr = true
|
||||
} else {
|
||||
// Регистрируем dedupId и порядковый номер ТОЛЬКО при успешной записи.
|
||||
// Иначе ретрай с тем же dedupId получил бы 200 без сохранённого сообщения
|
||||
// (InitDuplicatation после отката append помечал бы id как обработанный).
|
||||
queue.InitDuplicatation(messageDeduplicationID)
|
||||
if queue.IsFIFO {
|
||||
fifoSeqNumber = queue.NextSequenceNumber(messageGroupID)
|
||||
}
|
||||
|
||||
if !queue.IsDuplicate(messageDeduplicationID) {
|
||||
queue.Messages = append(queue.Messages, msg)
|
||||
// Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size)
|
||||
persistence.SaveMessage(key, &msg)
|
||||
// Метаданные очереди (FIFO state, duplicates) — без Messages
|
||||
persistence.SaveQueue(key, queue)
|
||||
}
|
||||
} else {
|
||||
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
|
||||
}
|
||||
|
||||
queue.InitDuplicatation(messageDeduplicationID)
|
||||
// Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
|
||||
persistence.SaveQueue(key, queue)
|
||||
models.SyncQueues.Unlock()
|
||||
if persistErr {
|
||||
return utils.CreateErrorResponseV1("InternalError", true)
|
||||
}
|
||||
// Логируем только метаданные — тело сообщения не логируется (perf + security)
|
||||
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody))
|
||||
|
||||
|
||||
@@ -43,16 +43,14 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
||||
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
models.SyncQueues.RLock()
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
models.SyncQueues.RUnlock()
|
||||
queue := models.SyncQueues.GetRLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
maxMsgSize := queue.MaximumMessageSize
|
||||
currentMsgCount := len(queue.Messages)
|
||||
currentMsgCount := queue.LiveCount()
|
||||
queueIsFIFO := queue.IsFIFO
|
||||
models.SyncQueues.RUnlock()
|
||||
queue.RUnlockMu()
|
||||
|
||||
sendEntries := requestBody.Entries
|
||||
|
||||
@@ -104,15 +102,23 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой
|
||||
// выше и захватом Lock (иначе nil-pointer deref).
|
||||
queue = models.SyncQueues.Queues[key]
|
||||
if queue == nil {
|
||||
models.SyncQueues.Unlock()
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
queue.LockMu()
|
||||
defer queue.UnlockMu()
|
||||
|
||||
// Повторная проверка лимита под per-queue Lock: снимок currentMsgCount выше
|
||||
// был сделан под RLock, между ним и взятием Lock очередь могла измениться
|
||||
// (гонка TOCTOU). Здесь — обязательная проверка по свежему числу живых.
|
||||
if queue.LiveCount()+len(sendEntries) > MaxMessagesForQueue(queue.IsFIFO) {
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
// Глобальный предохранитель памяти (HeapAlloc) — защита от OOM суммой тенантов.
|
||||
if heapOverLimit() {
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
|
||||
newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
|
||||
appendedIdx := make([]int, 0, len(sendEntries))
|
||||
appendedDedupIDs := make([]string, 0, len(sendEntries))
|
||||
for _, sendEntry := range sendEntries {
|
||||
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
|
||||
if len(sendEntry.MessageAttributes) > 0 {
|
||||
@@ -121,20 +127,41 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
||||
}
|
||||
msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody)
|
||||
msg.GroupID = sendEntry.MessageGroupId
|
||||
msg.DeduplicationID = sendEntry.MessageDeduplicationId
|
||||
msg.DelaySecs = sendEntry.DelaySeconds
|
||||
// ContentBasedDeduplication: dedupId вычисляется из тела (SHA-256),
|
||||
// если не передан явно (AWS-семантика FIFO).
|
||||
dedupID := sendEntry.MessageDeduplicationId
|
||||
if queue.IsFIFO && queue.ContentBasedDeduplication && dedupID == "" {
|
||||
dedupID = utils.GetSHA256Hash(sendEntry.MessageBody)
|
||||
}
|
||||
msg.DeduplicationID = dedupID
|
||||
msg.Uuid = uuid.NewString()
|
||||
msg.SentTime = time.Now()
|
||||
|
||||
// Дубликат проверяем ДО NextSequenceNumber/Append: иначе seq инкрементировался
|
||||
// бы и для отброшенного дубликата, а два entry с одним dedupID в одном батче
|
||||
// оба добавились бы (InitDuplicatation был после цикла — ревью 2026-10-11).
|
||||
if queue.IsDuplicate(dedupID) {
|
||||
log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", dedupID, queueName)
|
||||
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
|
||||
Id: sendEntry.Id,
|
||||
MessageId: msg.Uuid,
|
||||
MD5OfMessageBody: msg.MD5OfMessageBody,
|
||||
MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
|
||||
SequenceNumber: "",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
fifoSeqNumber := ""
|
||||
if queue.IsFIFO {
|
||||
fifoSeqNumber = queue.NextSequenceNumber(sendEntry.MessageGroupId)
|
||||
}
|
||||
if !queue.IsDuplicate(sendEntry.MessageDeduplicationId) {
|
||||
queue.Messages = append(queue.Messages, msg)
|
||||
} else {
|
||||
log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", sendEntry.MessageDeduplicationId, queueName)
|
||||
}
|
||||
queue.InitDuplicatation(sendEntry.MessageDeduplicationId)
|
||||
// Регистрируем dedupId сразу — второй entry с тем же id увидит его.
|
||||
queue.InitDuplicatation(dedupID)
|
||||
appendedIdx = append(appendedIdx, queue.AppendMessage(msg))
|
||||
newMsgs = append(newMsgs, msg)
|
||||
appendedDedupIDs = append(appendedDedupIDs, dedupID)
|
||||
|
||||
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
|
||||
Id: sendEntry.Id,
|
||||
@@ -143,15 +170,25 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
||||
MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
|
||||
SequenceNumber: fifoSeqNumber,
|
||||
})
|
||||
newMsgs = append(newMsgs, msg)
|
||||
// Логируем только метаданные — тело сообщения не логируется (perf + security)
|
||||
log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody))
|
||||
}
|
||||
// Персистим новые сообщения отдельно — O(batch_size*msg_size) вместо O(N*msg_size)
|
||||
persistence.SaveMessages(key, newMsgs)
|
||||
// Персистим СИНХРОННО до ответа (как одиночный SendMessage): иначе краш в окне
|
||||
// между 200 OK и async-записью потеряет подтверждённые сообщения.
|
||||
if err := persistence.SaveMessagesSync(key, newMsgs); err != nil {
|
||||
log.Errorf("SendMessageBatch: не удалось персистить батч в %s: %v", key, err)
|
||||
for _, idx := range appendedIdx {
|
||||
queue.RemoveIndex(idx)
|
||||
}
|
||||
// Откат dedup-регистраций: иначе retry с тем же dedupId получил бы
|
||||
// «duplicate» без сохранённых сообщений.
|
||||
for _, id := range appendedDedupIDs {
|
||||
delete(queue.Duplicates, id)
|
||||
}
|
||||
return utils.CreateErrorResponseV1("InternalError", true)
|
||||
}
|
||||
// Метаданные очереди (FIFO state, duplicates) — без Messages
|
||||
persistence.SaveQueue(key, queue)
|
||||
models.SyncQueues.Unlock()
|
||||
|
||||
respStruct := models.SendMessageBatchResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/test"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSendMessageBatchV1_Success(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageBatchRequest{
|
||||
Entries: []models.SendMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "test-msg-with-non-attribute",
|
||||
MessageBody: "test%20message%20body%201",
|
||||
},
|
||||
{
|
||||
Id: "test-msg-with-single-attirbute",
|
||||
MessageBody: "test%20message%20body%202",
|
||||
MessageAttributes: map[string]models.MessageAttribute{
|
||||
"my-attribute-name": {
|
||||
DataType: "String",
|
||||
StringValue: "my-attribute-string-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "test-msg-with-multi-attirbute",
|
||||
MessageBody: "test%20message%20body%203",
|
||||
MessageAttributes: map[string]models.MessageAttribute{
|
||||
"my-attribute-name-1": {
|
||||
BinaryValue: "binary-value-1",
|
||||
DataType: "Binary",
|
||||
},
|
||||
"my-attribute-name-2": {
|
||||
DataType: "String",
|
||||
StringValue: "my-attribute-string-value-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageBatchV1(r)
|
||||
sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.True(t, ok)
|
||||
|
||||
resultEntry := sendMessageBatchResponse.Result.Entry
|
||||
assert.Equal(t, 3, len(resultEntry))
|
||||
assert.Contains(t, resultEntry[0].Id, "test-msg-with-non-attribute")
|
||||
assert.Contains(t, resultEntry[1].Id, "test-msg-with-single-attirbute")
|
||||
assert.Contains(t, resultEntry[2].Id, "test-msg-with-multi-attirbute")
|
||||
assert.Empty(t, resultEntry[0].SequenceNumber)
|
||||
assert.Empty(t, resultEntry[1].SequenceNumber)
|
||||
assert.Empty(t, resultEntry[2].SequenceNumber)
|
||||
|
||||
}
|
||||
|
||||
func TestSendMessageBatchV1_Success_Fifo_Queue(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageBatchRequest{
|
||||
Entries: []models.SendMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "test_msg_001",
|
||||
MessageBody: "test%20message%20body%201",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_002",
|
||||
MessageBody: "test%20message%20body%202",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_003",
|
||||
MessageBody: "test%20message%20body%203",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "fifo-queue-1"),
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "fifo-queue-1",
|
||||
MaximumMessageSize: 1024,
|
||||
IsFIFO: true,
|
||||
}
|
||||
models.SyncQueues.Queues["fifo-queue-1"] = q
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageBatchV1(r)
|
||||
sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.True(t, ok)
|
||||
|
||||
resultEntry := sendMessageBatchResponse.Result.Entry
|
||||
assert.Equal(t, 3, len(resultEntry))
|
||||
assert.Contains(t, resultEntry[0].Id, "test_msg_001")
|
||||
assert.NotEmpty(t, resultEntry[0].SequenceNumber)
|
||||
assert.Contains(t, resultEntry[1].Id, "test_msg_002")
|
||||
assert.NotEmpty(t, resultEntry[1].SequenceNumber)
|
||||
assert.Contains(t, resultEntry[2].Id, "test_msg_003")
|
||||
assert.NotEmpty(t, resultEntry[2].SequenceNumber)
|
||||
}
|
||||
|
||||
func TestSendMessageBatchV1_Error_QueueNotFound(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_not_found_queue := models.SendMessageBatchRequest{
|
||||
Entries: []models.SendMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "test_msg_001",
|
||||
MessageBody: "test%20message%20body%201",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_002",
|
||||
MessageBody: "test%20message%20body%202",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_003",
|
||||
MessageBody: "test%20message%20body%203",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue1"),
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
||||
*v = sendMessageRequest_not_found_queue
|
||||
return true
|
||||
}
|
||||
|
||||
expected := models.ErrorResult{
|
||||
Type: "Not Found",
|
||||
Code: "AWS.SimpleQueueService.NonExistentQueue",
|
||||
Message: "The specified queue does not exist for this wsdl version.",
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageBatchV1(r)
|
||||
errorResult := response.GetResult().(models.ErrorResult)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, expected, errorResult)
|
||||
}
|
||||
|
||||
func TestSendMessageBatchV1_Error_NoEntry(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_no_entry := models.SendMessageBatchRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
||||
*v = sendMessageRequest_no_entry
|
||||
return true
|
||||
}
|
||||
|
||||
expected := models.ErrorResult{
|
||||
Type: "EmptyBatchRequest",
|
||||
Code: "AWS.SimpleQueueService.EmptyBatchRequest",
|
||||
Message: "The batch request doesn't contain any entries.",
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageBatchV1(r)
|
||||
errorResult := response.GetResult().(models.ErrorResult)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, expected, errorResult)
|
||||
}
|
||||
|
||||
func TestSendMessageBatchV1_Error_IdNotDistinct(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_no_entry := models.SendMessageBatchRequest{
|
||||
Entries: []models.SendMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "test_msg_001",
|
||||
MessageBody: "test%20message%20body%201",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_001",
|
||||
MessageBody: "test%20message%20body%202",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_001",
|
||||
MessageBody: "test%20message%20body%203",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
||||
*v = sendMessageRequest_no_entry
|
||||
return true
|
||||
}
|
||||
|
||||
expected := models.ErrorResult{
|
||||
Type: "BatchEntryIdsNotDistinct",
|
||||
Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct",
|
||||
Message: "Two or more batch entries in the request have the same Id.",
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageBatchV1(r)
|
||||
errorResult := response.GetResult().(models.ErrorResult)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, expected, errorResult)
|
||||
}
|
||||
|
||||
func TestSendMessageBatchV1_Error_TooManyEntries(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageBatchRequest{
|
||||
Entries: []models.SendMessageBatchRequestEntry{
|
||||
{
|
||||
Id: "test_msg_001",
|
||||
MessageBody: "test%20message%20body%201",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_002",
|
||||
MessageBody: "test%20message%20body%202",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_003",
|
||||
MessageBody: "test%20message%20body%203",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_004",
|
||||
MessageBody: "test%20message%20body%204",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_005",
|
||||
MessageBody: "test%20message%20body%205",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_006",
|
||||
MessageBody: "test%20message%20body%206",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_007",
|
||||
MessageBody: "test%20message%20body%207",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_008",
|
||||
MessageBody: "test%20message%20body%208",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_009",
|
||||
MessageBody: "test%20message%20body%209",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_010",
|
||||
MessageBody: "test%20message%20body%210",
|
||||
},
|
||||
{
|
||||
Id: "test_msg_011",
|
||||
MessageBody: "test%20message%20body%211",
|
||||
},
|
||||
},
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
expected := models.ErrorResult{
|
||||
Type: "TooManyEntriesInBatchRequest",
|
||||
Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest",
|
||||
Message: "Maximum number of entries per request are 10.",
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageBatchV1(r)
|
||||
errorResult := response.GetResult().(models.ErrorResult)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, expected, errorResult)
|
||||
|
||||
}
|
||||
|
||||
func TestSendMessageBatchV1_Error_transformer(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := SendMessageBatchV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSendMessageV1_Success(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageRequest{
|
||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
||||
MessageBody: "Test Message",
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "new-queue-1",
|
||||
MaximumMessageSize: 1024,
|
||||
}
|
||||
models.SyncQueues.Queues["new-queue-1"] = q
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageV1(r)
|
||||
|
||||
// Check the queue
|
||||
assert.Equal(t, 1, len(q.Messages))
|
||||
msg := q.Messages[0]
|
||||
assert.Equal(t, "Test Message", string(msg.MessageBody))
|
||||
|
||||
// Check the response
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
sendMessageResponse, ok := response.(models.SendMessageResponse)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody)
|
||||
// No FIFO Sequence
|
||||
assert.Empty(t, sendMessageResponse.Result.SequenceNumber)
|
||||
}
|
||||
|
||||
func TestSendMessageV1_Success_FIFOQueue(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageRequest{
|
||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
||||
MessageBody: "Test Message",
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "new-queue-1",
|
||||
MaximumMessageSize: 1024,
|
||||
IsFIFO: true,
|
||||
}
|
||||
models.SyncQueues.Queues["new-queue-1"] = q
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageV1(r)
|
||||
|
||||
// Check the queue
|
||||
assert.Equal(t, 1, len(q.Messages))
|
||||
msg := q.Messages[0]
|
||||
assert.Equal(t, "Test Message", string(msg.MessageBody))
|
||||
|
||||
// Check the response
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
sendMessageResponse, ok := response.(models.SendMessageResponse)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody)
|
||||
// Should have FIFO Sequence
|
||||
assert.NotEmpty(t, sendMessageResponse.Result.SequenceNumber)
|
||||
}
|
||||
|
||||
func TestSendMessageV1_Success_Deduplication(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageRequest{
|
||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
||||
MessageBody: "Test Message",
|
||||
MessageDeduplicationId: "1",
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "new-queue-1",
|
||||
MaximumMessageSize: 1024,
|
||||
IsFIFO: true,
|
||||
EnableDuplicates: true,
|
||||
Duplicates: make(map[string]time.Time),
|
||||
}
|
||||
models.SyncQueues.Queues["new-queue-1"] = q
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, _ := SendMessageV1(r)
|
||||
|
||||
// Check the queue
|
||||
assert.Equal(t, 1, len(q.Messages))
|
||||
// Check the response
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
|
||||
// Send the same message (have DeduplicationId)
|
||||
status, _ = SendMessageV1(r)
|
||||
// Response is "success"
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
// Only 1 message should be in the queue
|
||||
assert.Equal(t, 1, len(q.Messages))
|
||||
}
|
||||
|
||||
func TestSendMessageV1_request_transformer_error(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := SendMessageV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestSendMessageV1_MaximumMessageSize_MessageTooBig(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageRequest{
|
||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
||||
MessageBody: "Test Message",
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
q := &models.Queue{
|
||||
Name: "new-queue-1",
|
||||
MaximumMessageSize: 1,
|
||||
}
|
||||
models.SyncQueues.Queues["new-queue-1"] = q
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageV1(r)
|
||||
|
||||
// Check the response
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
errorResponse, ok := response.(models.ErrorResponse)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "MessageTooBig", errorResponse.Result.Type)
|
||||
}
|
||||
|
||||
func TestSendMessageV1_POST_QueueNonExistant(t *testing.T) {
|
||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
sendMessageRequest_success := models.SendMessageRequest{
|
||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
||||
MessageBody: "Test Message",
|
||||
}
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SendMessageRequest)
|
||||
*v = sendMessageRequest_success
|
||||
return true
|
||||
}
|
||||
|
||||
// No test queue is added to app.SyncQueues
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
status, response := SendMessageV1(r)
|
||||
|
||||
// Check the status code is what we expect.
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// Check the response body is what we expect.
|
||||
errorResponse, ok := response.(models.ErrorResponse)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Not Found", errorResponse.Result.Type)
|
||||
}
|
||||
@@ -45,13 +45,12 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID)
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
queue, ok := models.SyncQueues.Queues[key]
|
||||
if !ok {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID)
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
// Список имён переданных атрибутов — для whitelist-валидации.
|
||||
// Query-протокол: имена видны в form (Attribute.N.Name).
|
||||
// JSON-протокол: структура уже разобрана из тела, список имён недоступен → nil
|
||||
@@ -61,7 +60,7 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
||||
provided = utils.ExtractQueueAttributes(req.PostForm)
|
||||
}
|
||||
|
||||
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided); err != nil {
|
||||
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
|
||||
return utils.CreateErrorResponseV1(err.Error(), true)
|
||||
}
|
||||
// Сохраняем атрибуты в Redis пока держим Lock (через defer)
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/test"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/fixtures"
|
||||
"shared-sqs/app/interfaces"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSetQueueAttributesV1_success_multiple_attributes(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
||||
*v = fixtures.SetQueueAttributesRequest
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := SetQueueAttributesV1(r)
|
||||
|
||||
expectedResponse := models.SetQueueAttributesResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
|
||||
actualQueue := models.SyncQueues.Queues["unit-queue1"]
|
||||
assert.Equal(t, 5, actualQueue.VisibilityTimeout)
|
||||
assert.Equal(t, 4, actualQueue.ReceiveMessageWaitTimeSeconds)
|
||||
assert.Equal(t, 1, actualQueue.DelaySeconds)
|
||||
assert.Equal(t, 2, actualQueue.MaximumMessageSize)
|
||||
assert.Equal(t, 3, actualQueue.MessageRetentionPeriod)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_success_single_attribute(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
||||
*v = models.SetQueueAttributesRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
Attributes: models.QueueAttributes{
|
||||
VisibilityTimeout: 5,
|
||||
},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, response := SetQueueAttributesV1(r)
|
||||
|
||||
expectedResponse := models.SetQueueAttributesResponse{
|
||||
Xmlns: models.BaseXmlns,
|
||||
Metadata: models.BaseResponseMetadata,
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, expectedResponse, response)
|
||||
|
||||
actualQueue := models.SyncQueues.Queues["unit-queue1"]
|
||||
assert.Equal(t, 5, actualQueue.VisibilityTimeout)
|
||||
assert.Equal(t, 0, actualQueue.ReceiveMessageWaitTimeSeconds)
|
||||
assert.Equal(t, 0, actualQueue.DelaySeconds)
|
||||
assert.Equal(t, 0, actualQueue.MaximumMessageSize)
|
||||
assert.Equal(t, 345600, actualQueue.MessageRetentionPeriod)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_invalid_request_body(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := SetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_missing_queue_url(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
||||
*v = models.SetQueueAttributesRequest{
|
||||
Attributes: models.QueueAttributes{},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := SetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_missing_expected_queue(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
||||
*v = models.SetQueueAttributesRequest{
|
||||
QueueUrl: "garbage",
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := SetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func TestSetQueueAttributesV1_invalid_redrive_queue(t *testing.T) {
|
||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
||||
defer func() {
|
||||
models.ResetApp()
|
||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
||||
}()
|
||||
|
||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
||||
*v = models.SetQueueAttributesRequest{
|
||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
||||
Attributes: models.QueueAttributes{
|
||||
RedrivePolicy: models.RedrivePolicy{
|
||||
MaxReceiveCount: 100,
|
||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"),
|
||||
},
|
||||
},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
||||
code, _ := SetQueueAttributesV1(r)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
@@ -41,13 +41,11 @@ func TagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
// Инициализируем Tags если nil (для старых очередей, созданных без Tags)
|
||||
if queue.Tags == nil {
|
||||
|
||||
+22
-12
@@ -5,6 +5,7 @@ package gosqs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"shared-sqs/app/auth"
|
||||
"shared-sqs/app/models"
|
||||
@@ -17,8 +18,15 @@ func tenantQueueKey(tenantAccessKey, queueName string) string {
|
||||
return tenantAccessKey + ":" + queueName
|
||||
}
|
||||
|
||||
// getTenantFromContext — извлекает тенанта из request context.
|
||||
// Возвращает nil если тенант не найден (не должно быть — auth middleware должен это поймать раньше).
|
||||
// dlqKeyFor — ключ DLQ по ключу исходной очереди (формат "{accessKey}:{name}")
|
||||
// и имени DLQ. Используется для персиста перенесённых в DLQ сообщений.
|
||||
func dlqKeyFor(srcKey, dlqName string) string {
|
||||
idx := strings.LastIndex(srcKey, ":")
|
||||
if idx < 0 {
|
||||
return srcKey + ":" + dlqName
|
||||
}
|
||||
return srcKey[:idx] + ":" + dlqName
|
||||
}
|
||||
func getTenantFromContext(r *http.Request) *tenant.Tenant {
|
||||
t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant)
|
||||
return t
|
||||
@@ -27,6 +35,10 @@ return t
|
||||
// tenantQueueURL — формирует URL очереди для тенанта.
|
||||
// Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive.
|
||||
func tenantQueueURL(t *tenant.Tenant, queueName string) string {
|
||||
// Публичный адрес (напр. https://sqs.sinefo.ru) — приоритетнее локального формата.
|
||||
if base := strings.TrimRight(models.CurrentEnvironment.ExternalURLBase, "/"); base != "" {
|
||||
return base + "/" + t.ID + "/" + queueName
|
||||
}
|
||||
host := models.CurrentEnvironment.Host
|
||||
port := models.CurrentEnvironment.Port
|
||||
region := models.CurrentEnvironment.Region
|
||||
@@ -36,21 +48,19 @@ return "http://" + region + "." + host + ":" + port + "/" + t.ID + "/" + queueNa
|
||||
return "http://" + host + ":" + port + "/" + t.ID + "/" + queueName
|
||||
}
|
||||
|
||||
// TenantQueueURL — экспортированная обёртка над tenantQueueURL (нужна app/cmd).
|
||||
func TenantQueueURL(t *tenant.Tenant, queueName string) string {
|
||||
return tenantQueueURL(t, queueName)
|
||||
}
|
||||
|
||||
// tenantQueueARN — формирует ARN очереди для тенанта.
|
||||
func tenantQueueARN(t *tenant.Tenant, queueName string) string {
|
||||
return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName
|
||||
}
|
||||
|
||||
// countTenantQueues — считает количество очередей тенанта в SyncQueues.
|
||||
// countTenantQueues — считает количество очередей тенанта (через tenant-индекс, O(1)).
|
||||
// Используется для проверки лимита MaxQueues.
|
||||
// Вызывать под SyncQueues.RLock().
|
||||
// Вызывать под SyncQueues.RLock()/Lock().
|
||||
func countTenantQueues(tenantAccessKey string) int {
|
||||
prefix := tenantAccessKey + ":"
|
||||
count := 0
|
||||
for key := range models.SyncQueues.Queues {
|
||||
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
return models.SyncQueues.CountTenantQueues(tenantAccessKey)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/tenant"
|
||||
)
|
||||
|
||||
// TestTenantQueueURL — проверяет формирование QueueUrl: публичный ExternalURLBase
|
||||
// приоритетнее локального формата, пустое значение даёт старый формат.
|
||||
func TestTenantQueueURL(t *testing.T) {
|
||||
old := models.CurrentEnvironment
|
||||
defer func() { models.CurrentEnvironment = old }()
|
||||
|
||||
tt := &tenant.Tenant{ID: "t-abc123"}
|
||||
|
||||
// Публичный адрес; хвостовой слеш обрезается.
|
||||
models.CurrentEnvironment = models.Environment{ExternalURLBase: "https://sqs.sinefo.ru/"}
|
||||
if got, want := tenantQueueURL(tt, "orders"), "https://sqs.sinefo.ru/t-abc123/orders"; got != want {
|
||||
t.Fatalf("external base: got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Пусто → старый формат с region.
|
||||
models.CurrentEnvironment = models.Environment{Host: "goaws.com", Port: "4100", Region: "us-east-1"}
|
||||
if got, want := tenantQueueURL(tt, "orders"), "http://us-east-1.goaws.com:4100/t-abc123/orders"; got != want {
|
||||
t.Fatalf("local format (region): got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Пусто, без region.
|
||||
models.CurrentEnvironment = models.Environment{Host: "localhost", Port: "4100"}
|
||||
if got, want := tenantQueueURL(tt, "orders"), "http://localhost:4100/t-abc123/orders"; got != want {
|
||||
t.Fatalf("local format (no region): got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,11 @@ func UntagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
models.SyncQueues.Lock()
|
||||
defer models.SyncQueues.Unlock()
|
||||
|
||||
queue, exists := models.SyncQueues.Queues[key]
|
||||
if !exists {
|
||||
queue := models.SyncQueues.GetLocked(key)
|
||||
if queue == nil {
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
|
||||
if queue.Tags != nil {
|
||||
for _, tagKey := range requestBody.TagKeys {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// app/leader/leader.go
|
||||
// Простейший leader-election для active/passive (Шаг 3, вариант C).
|
||||
//
|
||||
// Лидер держит Redis-лок (ssq:leader) с TTL и продлевает его каждые interval.
|
||||
// Пассивные периодически пытаются взять лок; когда лидер падает, TTL истекает и
|
||||
// один из пассивных становится лидером. Готовность отдаётся через IsLeader().
|
||||
//
|
||||
// Почему так, а не полный вынос состояния (вариант A): дешевле в разы и не требует
|
||||
// переписывания ядра очередей; снимает главную боль «одна ВМ = SPOF». Потолок
|
||||
// (горизонтальное масштабирование) при этом остаётся — см.
|
||||
// doc/thinking/state-extraction-and-ha-2026-10-10.md.
|
||||
//
|
||||
// ВАЖНО: при memory-only (нет Redis) — считаем себя лидером (мы единственные).
|
||||
package leader
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/persistence"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Elector — выборы лидера через Redis-лок.
|
||||
type Elector struct {
|
||||
key string
|
||||
id string
|
||||
ttl time.Duration
|
||||
interval time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
isLeader bool
|
||||
stopCh chan struct{}
|
||||
stopped bool
|
||||
|
||||
// lastRefresh — время последнего успешного refresh/acquire лока.
|
||||
// Нужен для сброса лидерства при длительном сбое refresh (split-brain).
|
||||
lastRefresh time.Time
|
||||
}
|
||||
|
||||
// New — создаёт Elector. key — ключ лока, id — уникальный id инстанса.
|
||||
func New(key, id string, ttl, interval time.Duration) *Elector {
|
||||
return &Elector{
|
||||
key: key,
|
||||
id: id,
|
||||
ttl: ttl,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
lastRefresh: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start — запускает цикл выборов в фоне (первая попытка — сразу).
|
||||
func (e *Elector) Start() {
|
||||
log.Infof("leader: старт выборов (key=%s, id=%s, ttl=%s)", e.key, e.id, e.ttl)
|
||||
go e.loop()
|
||||
}
|
||||
|
||||
// IsLeader — текущий статус (готовность обслуживать).
|
||||
func (e *Elector) IsLeader() bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.isLeader
|
||||
}
|
||||
|
||||
// Stop — останавливает цикл и освобождает лок (если были лидером).
|
||||
func (e *Elector) Stop() {
|
||||
e.mu.Lock()
|
||||
if e.stopped {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
e.stopped = true
|
||||
close(e.stopCh)
|
||||
was := e.isLeader
|
||||
e.mu.Unlock()
|
||||
if was {
|
||||
persistence.ReleaseLock(e.key, e.id)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Elector) setLeader(v bool) {
|
||||
e.mu.Lock()
|
||||
changed := e.isLeader != v
|
||||
e.isLeader = v
|
||||
e.mu.Unlock()
|
||||
if changed {
|
||||
if v {
|
||||
log.Info("leader: инстанс стал ЛИДЕРОМ (активный)")
|
||||
} else {
|
||||
log.Warn("leader: потерян лидерский лок (пассивный)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Elector) loop() {
|
||||
t := time.NewTicker(e.interval)
|
||||
defer t.Stop()
|
||||
e.step()
|
||||
for {
|
||||
select {
|
||||
case <-e.stopCh:
|
||||
return
|
||||
case <-t.C:
|
||||
e.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Elector) step() {
|
||||
if e.IsLeader() {
|
||||
ok, err := persistence.RefreshLock(e.key, e.id, e.ttl)
|
||||
if err != nil {
|
||||
log.Errorf("leader: refresh: %v", err)
|
||||
// Refresh не удаётся: если с последнего успеха прошло > TTL — лок
|
||||
// гарантированно истёк, и другой инстанс мог стать лидером (split-brain).
|
||||
// Сбрасываем лидерство (fail-safe), а не остаёмся «лидером» без лока.
|
||||
if time.Since(e.lastRefreshAt()) > e.ttl {
|
||||
e.setLeader(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
e.markRefreshed()
|
||||
if !ok {
|
||||
e.setLeader(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
ok, err := persistence.TryAcquireLock(e.key, e.id, e.ttl)
|
||||
if err != nil {
|
||||
log.Errorf("leader: acquire: %v", err)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
e.setLeader(true)
|
||||
e.markRefreshed()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Elector) lastRefreshAt() time.Time {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.lastRefresh
|
||||
}
|
||||
|
||||
func (e *Elector) markRefreshed() {
|
||||
e.mu.Lock()
|
||||
e.lastRefresh = time.Now()
|
||||
e.mu.Unlock()
|
||||
}
|
||||
@@ -37,18 +37,25 @@ func StartGaugeUpdater(interval time.Duration, quit <-chan bool) {
|
||||
// updateGauges — пересчитывает sqs_queues_count и sqs_messages_count per tenant.
|
||||
// Формат ключа очереди: "{accessKey}:{queueName}" — берём часть до первого ":".
|
||||
func updateGauges() {
|
||||
// Собираем данные под RLock — не блокируем запись
|
||||
// Снимок указателей под коротким RLock, затем содержимое — под per-queue RLock
|
||||
models.SyncQueues.RLock()
|
||||
snapshot := make(map[string]*models.Queue, len(models.SyncQueues.Queues))
|
||||
for key, queue := range models.SyncQueues.Queues {
|
||||
snapshot[key] = queue
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
|
||||
tenantQueues := make(map[string]int)
|
||||
tenantMessages := make(map[string]int)
|
||||
for key, queue := range models.SyncQueues.Queues {
|
||||
for key, queue := range snapshot {
|
||||
// Ключ формата "accessKey:queueName" — tenant определяется по accessKey
|
||||
parts := strings.SplitN(key, ":", 2)
|
||||
tenantKey := parts[0]
|
||||
tenantQueues[tenantKey]++
|
||||
tenantMessages[tenantKey] += len(queue.Messages)
|
||||
queue.RLockMu()
|
||||
tenantMessages[tenantKey] += queue.LiveCount()
|
||||
queue.RUnlockMu()
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
|
||||
// Сбрасываем старые значения и записываем новые
|
||||
QueuesGauge.Reset()
|
||||
|
||||
@@ -23,6 +23,7 @@ Port string
|
||||
SqsPort string
|
||||
Region string
|
||||
AccountID string
|
||||
ExternalURLBase string
|
||||
LogToFile bool
|
||||
LogFile string
|
||||
EnableDuplicates bool
|
||||
|
||||
@@ -34,3 +34,31 @@ func (s *StringToInt) UnmarshalJSON(data []byte) error {
|
||||
func (s *StringToInt) Int() int {
|
||||
return int(*s)
|
||||
}
|
||||
|
||||
// StringToBool — аналог StringToInt для булевых атрибутов: принимает и JSON-bool
|
||||
// (true/false), и строку ("true"/"false"). Query-протокол шлёт строки,
|
||||
// JSON-протокол — булевы значения; без этого FifoQueue/ContentBasedDeduplication
|
||||
// из JSON-запроса молча терялись бы.
|
||||
type StringToBool bool
|
||||
|
||||
func (s *StringToBool) UnmarshalJSON(data []byte) error {
|
||||
var b bool
|
||||
if err := json.Unmarshal(data, &b); err == nil {
|
||||
*s = StringToBool(b)
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := strconv.ParseBool(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*s = StringToBool(tmp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StringToBool) Bool() bool {
|
||||
return bool(*s)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ func init() {
|
||||
"PurgeQueueInProgress": {HttpError: http.StatusForbidden, Type: "Sender", Code: "AWS.SimpleQueueService.PurgeQueueInProgress", Message: "Only one PurgeQueue operation on a queue is allowed every 60 seconds."},
|
||||
// OverLimit — превышен лимит сообщений в очереди (OOM-защита)
|
||||
"OverLimit": {HttpError: http.StatusBadRequest, Type: "OverLimit", Code: "OverLimit", Message: "The queue contains the maximum number of messages."},
|
||||
// InternalError — внутренняя ошибка сервера (например, не удалось персистить сообщение)
|
||||
"InternalError": {HttpError: http.StatusInternalServerError, Type: "InternalError", Code: "AWS.SimpleQueueService.InternalError", Message: "We encountered an internal error. Please try again."},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+102
-2
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,106 @@ var CurrentEnvironment = Environment{
|
||||
var LogMessages bool
|
||||
var LogFile string
|
||||
|
||||
var SyncQueues = struct {
|
||||
// QueueStore — хранилище очередей: блокировка + map ключ→очередь + индекс tenant→очереди.
|
||||
//
|
||||
// Ключ очереди в Queues — "{accessKey}:{queueName}" (tenant-scoped изоляция).
|
||||
// Индекс tenantQueues (accessKey → набор имён очередей) позволяет считать и
|
||||
// перечислять очереди тенанта за O(число его очередей), без скана всех очередей сервиса.
|
||||
//
|
||||
// ВАЖНО: методы AddQueue/RemoveQueue/CountTenantQueues/TenantQueueNames НЕ берут
|
||||
// блокировку — вызывать под уже захваченным SyncQueues.Lock()/RLock()
|
||||
// (соответствует существующей дисциплине блокировок в коде).
|
||||
type QueueStore struct {
|
||||
sync.RWMutex
|
||||
Queues map[string]*Queue
|
||||
}{Queues: make(map[string]*Queue)}
|
||||
tenantQueues map[string]map[string]struct{}
|
||||
}
|
||||
|
||||
var SyncQueues = QueueStore{
|
||||
Queues: make(map[string]*Queue),
|
||||
tenantQueues: make(map[string]map[string]struct{}),
|
||||
}
|
||||
|
||||
// splitQueueKey — разбирает tenant-scoped ключ "{accessKey}:{queueName}".
|
||||
// Легаси-ключи без ":" (yaml-очереди) tenant-независимы → ok=false.
|
||||
func splitQueueKey(key string) (accessKey, queueName string, ok bool) {
|
||||
idx := strings.Index(key, ":")
|
||||
if idx <= 0 || idx >= len(key)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return key[:idx], key[idx+1:], true
|
||||
}
|
||||
|
||||
// AddQueue — добавляет очередь и обновляет tenant-индекс.
|
||||
// Вызывать под SyncQueues.Lock().
|
||||
func (s *QueueStore) AddQueue(key string, q *Queue) {
|
||||
s.Queues[key] = q
|
||||
if ak, name, ok := splitQueueKey(key); ok {
|
||||
if s.tenantQueues[ak] == nil {
|
||||
s.tenantQueues[ak] = make(map[string]struct{})
|
||||
}
|
||||
s.tenantQueues[ak][name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveQueue — удаляет очередь и обновляет tenant-индекс.
|
||||
// Вызывать под SyncQueues.Lock().
|
||||
func (s *QueueStore) RemoveQueue(key string) {
|
||||
delete(s.Queues, key)
|
||||
if ak, name, ok := splitQueueKey(key); ok {
|
||||
if set, exists := s.tenantQueues[ak]; exists {
|
||||
delete(set, name)
|
||||
if len(set) == 0 {
|
||||
delete(s.tenantQueues, ak)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetLocked — возвращает очередь с захваченной per-queue write-блокировкой.
|
||||
// nil если очереди нет. Вызывающий ОБЯЗАН вызвать q.UnlockMu().
|
||||
func (s *QueueStore) GetLocked(key string) *Queue {
|
||||
s.RLock()
|
||||
q := s.Queues[key]
|
||||
s.RUnlock()
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
q.LockMu()
|
||||
return q
|
||||
}
|
||||
|
||||
// GetRLocked — то же, но read-блокировка. Вызывающий ОБЯЗАН вызвать q.RUnlockMu().
|
||||
func (s *QueueStore) GetRLocked(key string) *Queue {
|
||||
s.RLock()
|
||||
q := s.Queues[key]
|
||||
s.RUnlock()
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
q.RLockMu()
|
||||
return q
|
||||
}
|
||||
|
||||
// CountTenantQueues — число очередей тенанта (по accessKey) за O(1).
|
||||
// Вызывать под SyncQueues.RLock() или Lock().
|
||||
func (s *QueueStore) CountTenantQueues(accessKey string) int {
|
||||
return len(s.tenantQueues[accessKey])
|
||||
}
|
||||
|
||||
// TenantQueueNames — копия списка имён очередей тенанта.
|
||||
// Вызывать под SyncQueues.RLock() или Lock().
|
||||
func (s *QueueStore) TenantQueueNames(accessKey string) []string {
|
||||
set := s.tenantQueues[accessKey]
|
||||
names := make([]string, 0, len(set))
|
||||
for n := range set {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ResetQueueIndex — очищает tenant-индекс (для ResetResources/тестов).
|
||||
// Вызывать под SyncQueues.Lock().
|
||||
func (s *QueueStore) ResetQueueIndex() {
|
||||
s.tenantQueues = make(map[string]map[string]struct{})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func ResetApp() {
|
||||
func ResetResources() {
|
||||
SyncQueues.Lock()
|
||||
SyncQueues.Queues = make(map[string]*Queue)
|
||||
SyncQueues.ResetQueueIndex()
|
||||
SyncQueues.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// app/models/message_index_test.go
|
||||
// Тесты индексации сообщений (P1.2): tombstone-удаление, компакция, индексы
|
||||
// receipt/uuid. Проверяют ключевые свойства, на которых держится O(1)-удаление
|
||||
// и O(in-flight) visibility-перерасчёт.
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestQueue — очередь с последовательно добавленными сообщениями.
|
||||
func newTestQueue(uuids ...string) *Queue {
|
||||
q := &Queue{Name: "t", Duplicates: map[string]time.Time{}}
|
||||
for _, u := range uuids {
|
||||
q.AppendMessage(SqsMessage{Uuid: u, MessageBody: u})
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// TestQueue_RemoveIsTombstoneO1 — удаление не двигает срез и снимает индексы.
|
||||
func TestQueue_RemoveIsTombstoneO1(t *testing.T) {
|
||||
q := newTestQueue("a", "b", "c")
|
||||
if q.LiveCount() != 3 {
|
||||
t.Fatalf("LiveCount = %d, want 3", q.LiveCount())
|
||||
}
|
||||
if i, ok := q.IndexOfUUID("b"); !ok || i != 1 {
|
||||
t.Fatalf("IndexOfUUID(b) = %d,%v; want 1,true", i, ok)
|
||||
}
|
||||
|
||||
q.RemoveIndex(1)
|
||||
|
||||
if q.LiveCount() != 2 {
|
||||
t.Fatalf("LiveCount после удаления = %d, want 2", q.LiveCount())
|
||||
}
|
||||
if !q.IsDead(1) {
|
||||
t.Fatal("слот 1 должен быть tombstone")
|
||||
}
|
||||
if len(q.Messages) != 3 {
|
||||
t.Fatalf("физический размер среза изменился (%d) — удаление должно быть tombstone O(1)", len(q.Messages))
|
||||
}
|
||||
if _, ok := q.IndexOfUUID("b"); ok {
|
||||
t.Fatal("удалённый Uuid не должен находиться в индексе")
|
||||
}
|
||||
// Оставшиеся индексы не должны «съехать».
|
||||
if i, ok := q.IndexOfUUID("c"); !ok || i != 2 {
|
||||
t.Fatalf("IndexOfUUID(c) = %d,%v; want 2,true", i, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueue_CompactKeepsOrder — компакция сохраняет порядок живых и перестраивает индексы.
|
||||
func TestQueue_CompactKeepsOrder(t *testing.T) {
|
||||
q := newTestQueue("a", "b", "c", "d")
|
||||
// Помечаем сообщение "c" in-flight, чтобы проверить сохранение receipt-индекса.
|
||||
q.Messages[2].ReceiptHandle = "c#1"
|
||||
q.MarkInFlight(2, "c#1")
|
||||
|
||||
q.RemoveIndex(1) // b
|
||||
q.RemoveIndex(3) // d
|
||||
q.Compact()
|
||||
|
||||
if q.LiveCount() != 2 || len(q.Messages) != 2 {
|
||||
t.Fatalf("после компакции LiveCount=%d len=%d, want 2/2", q.LiveCount(), len(q.Messages))
|
||||
}
|
||||
if q.deadCount != 0 {
|
||||
t.Fatalf("deadCount после компакции = %d, want 0", q.deadCount)
|
||||
}
|
||||
if q.Messages[0].Uuid != "a" || q.Messages[1].Uuid != "c" {
|
||||
t.Fatalf("порядок нарушен: %q, %q", q.Messages[0].Uuid, q.Messages[1].Uuid)
|
||||
}
|
||||
if i, ok := q.IndexOfUUID("c"); !ok || i != 1 {
|
||||
t.Fatalf("IndexOfUUID(c) после компакции = %d,%v; want 1,true", i, ok)
|
||||
}
|
||||
if i, ok := q.IndexOfReceipt("c#1"); !ok || i != 1 {
|
||||
t.Fatalf("IndexOfReceipt(c#1) после компакции = %d,%v; want 1,true", i, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueue_ReceiptIndexLifecycle — регистрация/снятие in-flight.
|
||||
func TestQueue_ReceiptIndexLifecycle(t *testing.T) {
|
||||
q := newTestQueue("a")
|
||||
q.Messages[0].ReceiptHandle = "a#1"
|
||||
q.MarkInFlight(0, "a#1")
|
||||
|
||||
if i, ok := q.IndexOfReceipt("a#1"); !ok || i != 0 {
|
||||
t.Fatalf("IndexOfReceipt = %d,%v; want 0,true", i, ok)
|
||||
}
|
||||
if got := len(q.InFlightIndexes()); got != 1 {
|
||||
t.Fatalf("InFlightIndexes len = %d, want 1", got)
|
||||
}
|
||||
|
||||
q.ClearInFlight(0, "a#1")
|
||||
if _, ok := q.IndexOfReceipt("a#1"); ok {
|
||||
t.Fatal("снятый handle не должен находиться в индексе")
|
||||
}
|
||||
if got := len(q.InFlightIndexes()); got != 0 {
|
||||
t.Fatalf("InFlightIndexes len = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueue_RebuildIndexesAfterDirectReplace — защита от прямой замены среза
|
||||
// (загрузка из Redis / тесты): индексы восстанавливаются по содержимому.
|
||||
func TestQueue_RebuildIndexesAfterDirectReplace(t *testing.T) {
|
||||
q := newTestQueue("a")
|
||||
q.Messages = []SqsMessage{{Uuid: "x"}, {Uuid: "y", ReceiptHandle: "y#1"}}
|
||||
q.RebuildIndexes()
|
||||
|
||||
if q.LiveCount() != 2 {
|
||||
t.Fatalf("LiveCount = %d, want 2", q.LiveCount())
|
||||
}
|
||||
if i, ok := q.IndexOfUUID("y"); !ok || i != 1 {
|
||||
t.Fatalf("IndexOfUUID(y) = %d,%v; want 1,true", i, ok)
|
||||
}
|
||||
if i, ok := q.IndexOfReceipt("y#1"); !ok || i != 1 {
|
||||
t.Fatalf("IndexOfReceipt(y#1) = %d,%v; want 1,true", i, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueue_CompactIfNeededThreshold — пороговая компакция чистит tombstone
|
||||
// и сохраняет число живых.
|
||||
func TestQueue_CompactIfNeededThreshold(t *testing.T) {
|
||||
q := &Queue{Name: "t", Duplicates: map[string]time.Time{}}
|
||||
const total = 5000
|
||||
for i := 0; i < total; i++ {
|
||||
q.AppendMessage(SqsMessage{Uuid: fmt.Sprintf("u%d", i)})
|
||||
}
|
||||
for i := 0; i < 2000; i++ {
|
||||
q.RemoveIndex(i)
|
||||
}
|
||||
if q.deadCount != 2000 {
|
||||
t.Fatalf("deadCount = %d, want 2000", q.deadCount)
|
||||
}
|
||||
q.CompactIfNeeded()
|
||||
if q.deadCount != 0 || len(q.Messages) != total-2000 {
|
||||
t.Fatalf("после CompactIfNeeded deadCount=%d len=%d, want 0/%d", q.deadCount, len(q.Messages), total-2000)
|
||||
}
|
||||
if q.LiveCount() != total-2000 {
|
||||
t.Fatalf("LiveCount = %d, want %d", q.LiveCount(), total-2000)
|
||||
}
|
||||
// Порядок живых сохранён: первый живой — u2000.
|
||||
if q.Messages[0].Uuid != "u2000" {
|
||||
t.Fatalf("первый живой = %q, want u2000", q.Messages[0].Uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueue_Reset — purge полностью очищает сообщения и индексы.
|
||||
func TestQueue_Reset(t *testing.T) {
|
||||
q := newTestQueue("a", "b")
|
||||
q.RemoveIndex(0)
|
||||
q.Reset()
|
||||
|
||||
if q.LiveCount() != 0 || len(q.Messages) != 0 || q.deadCount != 0 {
|
||||
t.Fatalf("после Reset: LiveCount=%d len=%d deadCount=%d", q.LiveCount(), len(q.Messages), q.deadCount)
|
||||
}
|
||||
if len(q.InFlightIndexes()) != 0 {
|
||||
t.Fatal("после Reset in-flight индекс должен быть пуст")
|
||||
}
|
||||
// Очередь остаётся работоспособной.
|
||||
if idx := q.AppendMessage(SqsMessage{Uuid: "c"}); idx != 0 || q.LiveCount() != 1 {
|
||||
t.Fatalf("после Reset append: idx=%d LiveCount=%d", idx, q.LiveCount())
|
||||
}
|
||||
}
|
||||
+334
-8
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -46,6 +47,11 @@ func (m *SqsMessage) IsReadyForReceipt() bool {
|
||||
}
|
||||
|
||||
type Queue struct {
|
||||
// mu — per-queue блокировка содержимого очереди (Messages, Duplicates, FIFO-
|
||||
// состояние, LastPurgeTime). Заменяет удержание глобального SyncQueues.Lock
|
||||
// на время операций с одной очередью. sync.RWMutex сериализации не подлежит.
|
||||
mu sync.RWMutex `json:"-"`
|
||||
|
||||
Name string
|
||||
URL string
|
||||
Arn string
|
||||
@@ -53,9 +59,28 @@ type Queue struct {
|
||||
ReceiveMessageWaitTimeSeconds int
|
||||
DelaySeconds int
|
||||
MaximumMessageSize int
|
||||
MessageRetentionPeriod int // seconds // TODO - not used in the code yet
|
||||
MessageRetentionPeriod int // seconds (реализовано в processQueueTick)
|
||||
Messages []SqsMessage
|
||||
// ─── Индексация сообщений (P1.2) ────────────────────────────────────────
|
||||
// deadIdx — параллельный Messages слайс tombstone-флагов: true = слот
|
||||
// логически удалён. Удаление НЕ сдвигает элементы (O(1)), поэтому индексы
|
||||
// (receiptIdx/uuidIdx) остаются валидными; физическая чистка — Compact.
|
||||
// Менять Messages напрямую НЕЛЬЗЯ: только через AppendMessage/RemoveIndex/
|
||||
// Compact/Reset (см. ниже). Прямая замена допустима лишь для полной очистки
|
||||
// и загрузки из Redis (с последующим RebuildIndexes).
|
||||
deadIdx []bool `json:"-"`
|
||||
// receiptIdx — ReceiptHandle → индекс в Messages (только in-flight сообщения).
|
||||
receiptIdx map[string]int `json:"-"`
|
||||
// uuidIdx — Uuid → индекс в Messages (только живые сообщения).
|
||||
uuidIdx map[string]int `json:"-"`
|
||||
// liveCount — число живых сообщений; deadCount — число tombstone-слотов.
|
||||
liveCount int `json:"-"`
|
||||
deadCount int `json:"-"`
|
||||
DeadLetterQueue *Queue
|
||||
// DeadLetterQueueName — имя DLQ. В Redis-метаданных хранится ТОЛЬКО имя
|
||||
// (а не вложенный объект): после загрузки указатель DeadLetterQueue
|
||||
// релинкуется на объект из общей map (см. persistence.LoadAllQueues).
|
||||
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
|
||||
MaxReceiveCount int
|
||||
IsFIFO bool
|
||||
FIFOMessages map[string]int
|
||||
@@ -67,8 +92,25 @@ type Queue struct {
|
||||
// Используется для запрета повторного purge ранее чем через 60 секунд
|
||||
// (требование AWS: PurgeQueueInProgress). Нулевое значение = purge ещё не выполнялся.
|
||||
LastPurgeTime time.Time
|
||||
// CreatedAt/LastModifiedAt — для атрибутов CreatedTimestamp/LastModifiedTimestamp
|
||||
// (AWS отдаёт их в GetQueueAttributes; Terraform читает и без них видел бы drift).
|
||||
CreatedAt time.Time
|
||||
LastModifiedAt time.Time
|
||||
// Policy — resource policy (JSON-строка), храним и отдаём как есть для
|
||||
// совместимости с Terraform. НЕ исполняется (нет enforcement).
|
||||
Policy string
|
||||
// ContentBasedDeduplication — FIFO-атрибут. Храним и отдаём значение,
|
||||
// переданное клиентом (для совместимости), см. раздел про dedup.
|
||||
ContentBasedDeduplication bool
|
||||
}
|
||||
|
||||
// LockMu/UnlockMu/RLockMu/RUnlockMu — доступ к per-queue блокировке извне пакета models
|
||||
// (поле mu неэкспортируемо).
|
||||
func (q *Queue) LockMu() { q.mu.Lock() }
|
||||
func (q *Queue) UnlockMu() { q.mu.Unlock() }
|
||||
func (q *Queue) RLockMu() { q.mu.RLock() }
|
||||
func (q *Queue) RUnlockMu() { q.mu.RUnlock() }
|
||||
|
||||
// NextSequenceNumber — возвращает следующий порядковый номер сообщения FIFO-группы.
|
||||
//
|
||||
// Логическая схема: каждый вызов инкрементирует счётчик группы и возвращает
|
||||
@@ -118,13 +160,12 @@ func (q *Queue) UnlockGroup(groupId string) {
|
||||
}
|
||||
|
||||
// IsDuplicate — проверяет, было ли сообщение с таким deduplicationId уже
|
||||
// добавлено в очередь в пределах deduplication-окна.
|
||||
//
|
||||
// Логическая схема: дубликаты отслеживаются ТОЛЬКО для FIFO-очередей с
|
||||
// включённой дупликацией (EnableDuplicates) и непустым deduplicationId.
|
||||
// Для остальных случаев дубликаты не отслеживаются (AWS-семантика).
|
||||
// добавлено в очередь в пределах deduplication-окна (5 минут).
|
||||
// Дедупликация — НЕОТЪЕМЛЕМОЕ свойство FIFO-очереди (AWS): раньше она зависела
|
||||
// от env-флага EnableDuplicates=false по умолчанию, и FIFO-очереди не
|
||||
// дедуплицировали вообще. Теперь — только по IsFIFO и непустому id.
|
||||
func (q *Queue) IsDuplicate(deduplicationId string) bool {
|
||||
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
|
||||
if !q.IsFIFO || deduplicationId == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -138,11 +179,296 @@ func (q *Queue) IsDuplicate(deduplicationId string) bool {
|
||||
// фоновой задачей PeriodicTasks (deduplication period expired).
|
||||
// Для не-FIFO очередей или пустого deduplicationId — no-op.
|
||||
func (q *Queue) InitDuplicatation(deduplicationId string) {
|
||||
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
|
||||
if !q.IsFIFO || deduplicationId == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Ленивая инициализация: очередь, собранная литералом или загруженная из
|
||||
// Redis до появления поля, может иметь nil-map — иначе запись паникует.
|
||||
if q.Duplicates == nil {
|
||||
q.Duplicates = make(map[string]time.Time)
|
||||
}
|
||||
if _, ok := q.Duplicates[deduplicationId]; !ok {
|
||||
q.Duplicates[deduplicationId] = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── P1.2: индексация сообщений (tombstone + индексы) ──────────────────────
|
||||
//
|
||||
// Все методы ниже мутируют состояние очереди → вызывать ТОЛЬКО под
|
||||
// per-queue write-блокировкой (queue.LockMu). Читающие методы (LiveCount,
|
||||
// IsDead) безопасны под RLock.
|
||||
|
||||
// compactDeadThreshold — минимальное число tombstone-слотов, после которого
|
||||
// компакция при удалении разрешена. Защищает от O(N)-чистки на мелких очередях:
|
||||
// при удалении слот подрезается с хвоста, остальная чистка — в processQueueTick.
|
||||
const compactDeadThreshold = 1024
|
||||
|
||||
// ensureIndexes — ленивая инициализация/починка индексов.
|
||||
// При корректном состоянии — две дешёвые проверки; O(N) — только если состояние
|
||||
// рассинхронизировалось (прямая замена Messages вне методов, загрузка из Redis,
|
||||
// старый JSON-формат). ВАЖНО (инвариант): оценка «в порядке» проверяет лишь
|
||||
// согласованность чисел и не перестраивает уже корректные map-индексы — поэтому
|
||||
// мутировать Messages ВНЕ методов (AppendMessage/RemoveIndex/Compact/Reset),
|
||||
// например менять ReceiptHandle на месте, НЕЛЬЗЯ.
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) ensureIndexes() {
|
||||
if len(q.deadIdx) == len(q.Messages) &&
|
||||
q.receiptIdx != nil && q.uuidIdx != nil &&
|
||||
q.liveCount == len(q.Messages)-q.deadCount {
|
||||
return
|
||||
}
|
||||
q.RebuildIndexes()
|
||||
}
|
||||
|
||||
// RebuildIndexes — перестраивает deadIdx/receiptIdx/uuidIdx/liveCount/deadCount
|
||||
// по текущему Messages. Существующие tombstone СОХРАНЯЮТСЯ по позициям (если
|
||||
// deadIdx длиннее Messages — обрезается, короче — добивается false).
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) RebuildIndexes() {
|
||||
if len(q.deadIdx) > len(q.Messages) {
|
||||
q.deadIdx = q.deadIdx[:len(q.Messages)]
|
||||
}
|
||||
for len(q.deadIdx) < len(q.Messages) {
|
||||
q.deadIdx = append(q.deadIdx, false)
|
||||
}
|
||||
q.receiptIdx = make(map[string]int, len(q.Messages))
|
||||
q.uuidIdx = make(map[string]int, len(q.Messages))
|
||||
q.liveCount = 0
|
||||
q.deadCount = 0
|
||||
for i := range q.Messages {
|
||||
if q.deadIdx[i] {
|
||||
q.deadCount++
|
||||
continue
|
||||
}
|
||||
q.liveCount++
|
||||
if u := q.Messages[i].Uuid; u != "" {
|
||||
q.uuidIdx[u] = i
|
||||
}
|
||||
if rh := q.Messages[i].ReceiptHandle; rh != "" {
|
||||
q.receiptIdx[rh] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AppendMessage — добавляет сообщение в конец очереди, поддерживая индексы.
|
||||
// Возвращает индекс добавленного сообщения. Вызывать под write-блокировкой.
|
||||
func (q *Queue) AppendMessage(m SqsMessage) int {
|
||||
q.ensureIndexes()
|
||||
q.Messages = append(q.Messages, m)
|
||||
q.deadIdx = append(q.deadIdx, false)
|
||||
idx := len(q.Messages) - 1
|
||||
q.liveCount++
|
||||
if m.Uuid != "" {
|
||||
q.uuidIdx[m.Uuid] = idx
|
||||
}
|
||||
if m.ReceiptHandle != "" {
|
||||
q.receiptIdx[m.ReceiptHandle] = idx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// RemoveIndex — логически удаляет сообщение по индексу (tombstone) за O(1).
|
||||
// ВАЖНО: длина q.Messages НЕ меняется (слот остаётся tombstone) — поэтому
|
||||
// индексы, взятые до вызова, остаются валидными, а итерации по срезу
|
||||
// безопасны. Физическая чистка — Compact/CompactIfNeeded (в processQueueTick).
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) RemoveIndex(i int) {
|
||||
q.ensureIndexes()
|
||||
if i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
|
||||
return
|
||||
}
|
||||
msg := &q.Messages[i]
|
||||
q.deadIdx[i] = true
|
||||
q.deadCount++
|
||||
if q.liveCount > 0 {
|
||||
q.liveCount--
|
||||
}
|
||||
if msg.Uuid != "" {
|
||||
if idx, ok := q.uuidIdx[msg.Uuid]; ok && idx == i {
|
||||
delete(q.uuidIdx, msg.Uuid)
|
||||
}
|
||||
}
|
||||
if rh := msg.ReceiptHandle; rh != "" {
|
||||
if idx, ok := q.receiptIdx[rh]; ok && idx == i {
|
||||
delete(q.receiptIdx, rh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CompactIfNeeded — компакция при заметной доле tombstone-слотов.
|
||||
// Амортизированно: копия среза делается не на каждое удаление, а когда число
|
||||
// «мёртвых» слотов становится заметным (≥ 1024 или ≥ половины среза).
|
||||
// Вызывать под write-блокировкой ВНЕ итераций по Messages (меняет индексы).
|
||||
func (q *Queue) CompactIfNeeded() {
|
||||
if q.deadCount == 0 {
|
||||
return
|
||||
}
|
||||
if q.deadCount >= compactDeadThreshold || q.deadCount*2 >= len(q.Messages) {
|
||||
q.Compact()
|
||||
}
|
||||
}
|
||||
|
||||
// Compact — физически удаляет tombstone-слоты, сохраняя порядок живых, и
|
||||
// перестраивает индексы. O(N). Вызывать под write-блокировкой.
|
||||
func (q *Queue) Compact() {
|
||||
// ensureIndexes гарантирует len(deadIdx)==len(Messages) (иначе слоты разъедутся)
|
||||
// и корректность deadCount. При согласованном состоянии — две дешёвые проверки.
|
||||
q.ensureIndexes()
|
||||
if q.deadCount == 0 {
|
||||
return
|
||||
}
|
||||
n := 0
|
||||
for i := range q.Messages {
|
||||
if q.deadIdx[i] {
|
||||
continue
|
||||
}
|
||||
if n != i {
|
||||
q.Messages[n] = q.Messages[i]
|
||||
}
|
||||
n++
|
||||
}
|
||||
// Обнуляем «хвост», чтобы не держать ссылки на удалённые сообщения (GC).
|
||||
tail := q.Messages[n:]
|
||||
for i := range tail {
|
||||
tail[i] = SqsMessage{}
|
||||
}
|
||||
q.Messages = q.Messages[:n]
|
||||
// После чистки tombstone нет: обнуляем флаги и перестраиваем индексы по живым
|
||||
// (RebuildIndexes заново посчитает liveCount/deadCount и заполнит receipt/uuid).
|
||||
q.deadIdx = q.deadIdx[:0]
|
||||
q.RebuildIndexes()
|
||||
}
|
||||
|
||||
// Reset — полностью очищает сообщения и индексы (purge).
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) Reset() {
|
||||
q.Messages = nil
|
||||
q.deadIdx = nil
|
||||
q.receiptIdx = make(map[string]int)
|
||||
q.uuidIdx = make(map[string]int)
|
||||
q.liveCount = 0
|
||||
q.deadCount = 0
|
||||
// Чистим FIFO-состояние: иначе блокировки групп и порядковые номера остались
|
||||
// бы, и сообщения этих групп не выдавались после purge (ревью 2026-10-11).
|
||||
q.FIFOMessages = make(map[string]int)
|
||||
q.FIFOSequenceNumbers = make(map[string]int)
|
||||
}
|
||||
|
||||
// EnsureIndexes — экспортируемая обёртка над ensureIndexes: строит индексы,
|
||||
// если очередь была собрана в обход методов (литералом, из Redis, тестами).
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) EnsureIndexes() {
|
||||
q.ensureIndexes()
|
||||
}
|
||||
|
||||
// IndexOfReceipt — индекс сообщения по ReceiptHandle (только in-flight) за O(1).
|
||||
// Вызывать под write-блокировкой (ensureIndexes может перестроить индексы).
|
||||
func (q *Queue) IndexOfReceipt(receiptHandle string) (int, bool) {
|
||||
q.ensureIndexes()
|
||||
i, ok := q.receiptIdx[receiptHandle]
|
||||
if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// IndexOfUUID — индекс живого сообщения по Uuid за O(1).
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) IndexOfUUID(uuid string) (int, bool) {
|
||||
q.ensureIndexes()
|
||||
i, ok := q.uuidIdx[uuid]
|
||||
if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// MarkInFlight — регистрирует ReceiptHandle → индекс (для tick по in-flight).
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) MarkInFlight(i int, receiptHandle string) {
|
||||
q.ensureIndexes()
|
||||
if receiptHandle != "" {
|
||||
q.receiptIdx[receiptHandle] = i
|
||||
}
|
||||
}
|
||||
|
||||
// ClearInFlight — снимает регистрацию ReceiptHandle (возврат видимости).
|
||||
// Вызывать под write-блокировкой.
|
||||
func (q *Queue) ClearInFlight(i int, receiptHandle string) {
|
||||
if receiptHandle == "" {
|
||||
return
|
||||
}
|
||||
if idx, ok := q.receiptIdx[receiptHandle]; ok && idx == i {
|
||||
delete(q.receiptIdx, receiptHandle)
|
||||
}
|
||||
}
|
||||
|
||||
// ResetVisibilityAndTryDLQ — сбрасывает видимость сообщения по индексу и, если
|
||||
// исчерпан MaxReceiveCount при настроенной DLQ, переносит его в DLQ и удаляет
|
||||
// из исходной очереди (tombstone). Возвращает false, если сообщение на самом
|
||||
// деле перенесено в DLQ (удалено из очереди).
|
||||
//
|
||||
// Единый путь для одиночной и батчевой ChangeMessageVisibility: раньше логика
|
||||
// DLQ была только в одиночной версии, а батч лишь инкрементировал Retry — из-за
|
||||
// этого retry-счётчик и DLQ-перенос могли расходиться.
|
||||
// Вызывать под write-блокировкой q. Перенос в DLQ выполняется под её блокировкой.
|
||||
// Возвращает: msgUuid, movedToDLQ.
|
||||
// ResetVisibilityAndTryDLQ — сбрасывает видимость сообщения по индексу и, если
|
||||
// исчерпан MaxReceiveCount, переносит его в DLQ. При переносе удаляет сообщение
|
||||
// из исходной очереди (tombstone).
|
||||
//
|
||||
// Единый путь для одиночной и батчевой ChangeMessageVisibility: раньше логика
|
||||
// DLQ была только в одиночной версии, а батч лишь инкрементировал Retry — из-за
|
||||
// этого retry-счётчик и DLQ-перенос могли расходиться.
|
||||
// Вызывать под write-блокировкой q. Перенос в DLQ выполняется под её блокировкой.
|
||||
// Возвращает: msgUuid, movedToDLQ, dlqMsg (копия перенесённого сообщения, чтобы
|
||||
// вызывающий мог персистить его в DLQ; nil если переноса не было).
|
||||
func (q *Queue) ResetVisibilityAndTryDLQ(i int, receiptHandle string) (msgUuid string, movedToDLQ bool, dlqMsg *SqsMessage) {
|
||||
q.ensureIndexes()
|
||||
q.ClearInFlight(i, receiptHandle)
|
||||
msg := &q.Messages[i]
|
||||
msg.ReceiptTime = time.Now().UTC()
|
||||
msg.ReceiptHandle = ""
|
||||
msg.VisibilityTimeout = time.Now().Add(time.Duration(q.VisibilityTimeout) * time.Second)
|
||||
msg.Retry++
|
||||
msgUuid = msg.Uuid
|
||||
if q.MaxReceiveCount > 0 && q.DeadLetterQueue != nil && msg.Retry >= q.MaxReceiveCount {
|
||||
dlqMsg := *msg
|
||||
q.DeadLetterQueue.LockMu()
|
||||
q.DeadLetterQueue.AppendMessage(dlqMsg)
|
||||
q.DeadLetterQueue.UnlockMu()
|
||||
q.RemoveIndex(i)
|
||||
return msgUuid, true, &dlqMsg
|
||||
}
|
||||
return msgUuid, false, nil
|
||||
}
|
||||
|
||||
// InFlightIndexes — снимок индексов in-flight сообщений (для visibility-тика).
|
||||
// Возвращает срез индексов в Messages. Вызывать под write-блокировкой.
|
||||
//
|
||||
// ВАЖНО: индексы — снимок на момент вызова. Некоторые могут стать неактуальными
|
||||
// (handle переехал/сообщение удалено), поэтому потребитель ОБЯЗАН перепроверять
|
||||
// каждое сообщение по индексу (живость: в границах среза, не tombstone, handle
|
||||
// не пуст) — как это делает processQueueTick.
|
||||
func (q *Queue) InFlightIndexes() []int {
|
||||
idx := make([]int, 0, len(q.receiptIdx))
|
||||
for _, i := range q.receiptIdx {
|
||||
idx = append(idx, i)
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// IsDead — tombstone-ли слот. Безопасно под RLock (не мутирует состояние).
|
||||
func (q *Queue) IsDead(i int) bool {
|
||||
if i < 0 || i >= len(q.deadIdx) {
|
||||
return false
|
||||
}
|
||||
return q.deadIdx[i]
|
||||
}
|
||||
|
||||
// LiveCount — число живых (не tombstone) сообщений.
|
||||
// Безопасно под RLock. Эквивалент прежнего len(queue.Messages).
|
||||
func (q *Queue) LiveCount() int {
|
||||
return len(q.Messages) - q.deadCount
|
||||
}
|
||||
|
||||
+56
-2
@@ -113,6 +113,20 @@ func (r *CreateQueueRequest) SetAttributesFromForm(values url.Values) {
|
||||
continue
|
||||
}
|
||||
r.Attributes.RedriveAllowPolicy = tmp
|
||||
case "FifoQueue":
|
||||
tmp, err := strconv.ParseBool(attrValue)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||
continue
|
||||
}
|
||||
r.Attributes.FifoQueue = StringToBool(tmp)
|
||||
case "ContentBasedDeduplication":
|
||||
tmp, err := strconv.ParseBool(attrValue)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||
continue
|
||||
}
|
||||
r.Attributes.ContentBasedDeduplication = StringToBool(tmp)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -381,6 +395,20 @@ func (r *SetQueueAttributesRequest) SetAttributesFromForm(values url.Values) {
|
||||
continue
|
||||
}
|
||||
r.Attributes.RedriveAllowPolicy = tmp
|
||||
case "FifoQueue":
|
||||
tmp, err := strconv.ParseBool(attrValue)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||
continue
|
||||
}
|
||||
r.Attributes.FifoQueue = StringToBool(tmp)
|
||||
case "ContentBasedDeduplication":
|
||||
tmp, err := strconv.ParseBool(attrValue)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||
continue
|
||||
}
|
||||
r.Attributes.ContentBasedDeduplication = StringToBool(tmp)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -393,12 +421,17 @@ type QueueAttributes struct {
|
||||
DelaySeconds StringToInt `json:"DelaySeconds"`
|
||||
MaximumMessageSize StringToInt `json:"MaximumMessageSize"`
|
||||
MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented
|
||||
Policy map[string]interface{} `json:"Policy"` // NOTE: not implemented
|
||||
Policy map[string]interface{} `json:"Policy"` // хранится, не исполняется
|
||||
ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"`
|
||||
VisibilityTimeout StringToInt `json:"VisibilityTimeout"`
|
||||
// Dead Letter Queues Only
|
||||
RedrivePolicy RedrivePolicy `json:"RedrivePolicy"`
|
||||
RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented
|
||||
// FIFO-атрибуты. FifoQueue у нас также выводится из суффикса .fifo имени,
|
||||
// но значение от клиента нужно СОХРАНИТЬ и вернуть в GetQueueAttributes —
|
||||
// иначе Terraform (aws_sqs_queue) видел бы постоянный drift.
|
||||
FifoQueue StringToBool `json:"FifoQueue"`
|
||||
ContentBasedDeduplication StringToBool `json:"ContentBasedDeduplication"`
|
||||
}
|
||||
|
||||
// AttrNameWhitelist — имена атрибутов, допустимые в SetQueueAttributes/CreateQueue.
|
||||
@@ -444,8 +477,29 @@ func (q *QueueAttributes) UnmarshalJSON(data []byte) error {
|
||||
return fmt.Errorf("invalid attribute name: %s", name)
|
||||
}
|
||||
}
|
||||
// Policy/RedriveAllowPolicy в SQS API — СТРОКА с JSON внутри, а поля у нас —
|
||||
// map. JSON-протокол присылает их именно строкой, и unmarshal в map падал бы
|
||||
// (SetQueueAttributes → InvalidParameterValue). Разворачиваем строку в объект.
|
||||
// Также принимаем вариант, где уже пришёл объект — он и так валиден.
|
||||
for _, name := range []string{"Policy", "RedriveAllowPolicy"} {
|
||||
rv, ok := raw[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(rv, &s); err == nil {
|
||||
if !json.Valid([]byte(s)) {
|
||||
return fmt.Errorf("invalid JSON in attribute %s", name)
|
||||
}
|
||||
raw[name] = json.RawMessage(s)
|
||||
}
|
||||
}
|
||||
normalized, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type plain QueueAttributes
|
||||
return json.Unmarshal(data, (*plain)(q))
|
||||
return json.Unmarshal(normalized, (*plain)(q))
|
||||
}
|
||||
|
||||
type RedrivePolicy struct {
|
||||
|
||||
+414
-72
@@ -18,7 +18,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -29,17 +31,33 @@ import (
|
||||
|
||||
// Client — глобальный Redis клиент.
|
||||
// nil означает режим "только память" — все функции тихо no-op.
|
||||
var Client *redis.Client
|
||||
// atomic.Pointer: reconnect() пишет новый клиент из health-check горутины,
|
||||
// а остальные горутины читают — без атомарности это data race.
|
||||
var Client atomic.Pointer[redis.Client]
|
||||
|
||||
// Параметры подключения — сохраняются для переподключения после обрыва.
|
||||
var (
|
||||
redisAddr string
|
||||
redisUsername string
|
||||
redisPassword string
|
||||
)
|
||||
|
||||
var (
|
||||
seqMu sync.Mutex
|
||||
queueWriteSeq = map[string]uint64{}
|
||||
tenantWriteSeq = map[string]uint64{}
|
||||
// msgWriteSeq — порядковая нумерация async-записей ОТДЕЛЬНЫХ сообщений
|
||||
// (ключ "{queueKey}|{uuid}"). Без неё два SaveMessage одного сообщения из
|
||||
// разных горутин могли завершиться вразнобой: старое состояние перезаписало
|
||||
// бы новое. Записи удаляются при удалении сообщения (DeleteMessagePersist и др.).
|
||||
msgWriteSeq = map[string]uint64{}
|
||||
)
|
||||
|
||||
const (
|
||||
// redisHashTenants — HASH: tenantID → JSON тенанта
|
||||
redisHashTenants = "ssq:tenants"
|
||||
// redisHashTenantAK — HASH: accessKey → JSON тенанта (индекс read-through в auth)
|
||||
redisHashTenantAK = "ssq:tenant_ak"
|
||||
// redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2)
|
||||
redisHashQueues = "ssq:queues"
|
||||
// redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage)
|
||||
@@ -53,6 +71,10 @@ func Connect(addr, username, password string) error {
|
||||
log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме")
|
||||
return nil
|
||||
}
|
||||
redisAddr = addr
|
||||
redisUsername = username
|
||||
redisPassword = password
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: username,
|
||||
@@ -64,8 +86,57 @@ func Connect(addr, username, password string) error {
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("redis ping %s: %w", addr, err)
|
||||
}
|
||||
Client = rdb
|
||||
Client.Store(rdb)
|
||||
log.Infof("persistence: подключились к Redis %s", addr)
|
||||
startRedisHealthCheck()
|
||||
return nil
|
||||
}
|
||||
|
||||
// startRedisHealthCheck — фоновая проверка соединения и переподключение.
|
||||
// После обрыва Redis-клиент остаётся не-nil, но записи молча падают. Здесь мы
|
||||
// периодически пингуем Redis и при сбое создаём новый клиент (reconnect), чтобы
|
||||
// персистентность восстановилась автоматически.
|
||||
func startRedisHealthCheck() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if Client.Load() == nil {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err := Client.Load().Ping(ctx).Err()
|
||||
cancel()
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
log.Warnf("persistence: Redis недоступен (%v), пробуем переподключиться", err)
|
||||
if reErr := reconnect(); reErr != nil {
|
||||
log.Errorf("persistence: переподключение не удалось: %v", reErr)
|
||||
} else {
|
||||
log.Infof("persistence: переподключились к Redis %s", redisAddr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reconnect — создаёт новый Redis-клиент с сохранёнными параметрами подключения.
|
||||
func reconnect() error {
|
||||
if redisAddr == "" {
|
||||
return fmt.Errorf("redis addr пуст")
|
||||
}
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Username: redisUsername,
|
||||
Password: redisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
Client.Store(rdb)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -82,23 +153,76 @@ func asyncWrite(fn func()) {
|
||||
}()
|
||||
}
|
||||
|
||||
// marshalQueueMeta — сериализует очередь БЕЗ Messages (и без Messages в DLQ).
|
||||
// DeadLetterQueue сохраняется как ссылка (Name/URL/Arn), но без сообщений.
|
||||
// ВАЖНО: вызывать под SyncQueues.Lock() — временно nil'ит Messages.
|
||||
// queueMetaDTO — сериализуемая копия очереди БЕЗ Messages (и без сообщений DLQ).
|
||||
// Отдельный тип, т.к. models.Queue содержит sync.RWMutex и его нельзя копировать/
|
||||
// сериализовать целиком, а временное зануление Messages мутировало бы живую очередь.
|
||||
// Имена полей совпадают с models.Queue → json совместим при загрузке.
|
||||
type queueMetaDTO struct {
|
||||
Name string
|
||||
URL string
|
||||
Arn string
|
||||
VisibilityTimeout int
|
||||
ReceiveMessageWaitTimeSeconds int
|
||||
DelaySeconds int
|
||||
MaximumMessageSize int
|
||||
MessageRetentionPeriod int
|
||||
MaxReceiveCount int
|
||||
IsFIFO bool
|
||||
FIFOMessages map[string]int
|
||||
FIFOSequenceNumbers map[string]int
|
||||
EnableDuplicates bool
|
||||
Duplicates map[string]time.Time
|
||||
Tags map[string]string
|
||||
LastPurgeTime time.Time
|
||||
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
|
||||
// Создание/изменение + совместимые атрибуты (GetQueueAttributes/Terraform).
|
||||
CreatedAt time.Time
|
||||
LastModifiedAt time.Time
|
||||
Policy string
|
||||
ContentBasedDeduplication bool
|
||||
}
|
||||
|
||||
// queueToMetaDTO — строит снимок очереди без сообщений, НЕ мутируя исходную очередь.
|
||||
// DLQ сохраняется как ИМЯ (не вложенный объект): иначе — detached-копия после
|
||||
// загрузки и неограниченная рекурсия при циклической DLQ.
|
||||
func queueToMetaDTO(q *models.Queue) *queueMetaDTO {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
dto := &queueMetaDTO{
|
||||
Name: q.Name,
|
||||
URL: q.URL,
|
||||
Arn: q.Arn,
|
||||
VisibilityTimeout: q.VisibilityTimeout,
|
||||
ReceiveMessageWaitTimeSeconds: q.ReceiveMessageWaitTimeSeconds,
|
||||
DelaySeconds: q.DelaySeconds,
|
||||
MaximumMessageSize: q.MaximumMessageSize,
|
||||
MessageRetentionPeriod: q.MessageRetentionPeriod,
|
||||
MaxReceiveCount: q.MaxReceiveCount,
|
||||
IsFIFO: q.IsFIFO,
|
||||
FIFOMessages: q.FIFOMessages,
|
||||
FIFOSequenceNumbers: q.FIFOSequenceNumbers,
|
||||
EnableDuplicates: q.EnableDuplicates,
|
||||
Duplicates: q.Duplicates,
|
||||
Tags: q.Tags,
|
||||
LastPurgeTime: q.LastPurgeTime,
|
||||
DeadLetterQueueName: q.DeadLetterQueueName,
|
||||
CreatedAt: q.CreatedAt,
|
||||
LastModifiedAt: q.LastModifiedAt,
|
||||
Policy: q.Policy,
|
||||
ContentBasedDeduplication: q.ContentBasedDeduplication,
|
||||
}
|
||||
if q.DeadLetterQueue != nil {
|
||||
dto.DeadLetterQueueName = q.DeadLetterQueue.Name
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// marshalQueueMeta — сериализует метаданные очереди БЕЗ сообщений,
|
||||
// НЕ мутируя исходную очередь (см. queueMetaDTO). Безопасно вызывать
|
||||
// под per-queue блокировкой только этой очереди.
|
||||
func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
|
||||
savedMsgs := queue.Messages
|
||||
queue.Messages = nil
|
||||
var savedDLQMsgs []models.SqsMessage
|
||||
if queue.DeadLetterQueue != nil {
|
||||
savedDLQMsgs = queue.DeadLetterQueue.Messages
|
||||
queue.DeadLetterQueue.Messages = nil
|
||||
}
|
||||
data, err := json.Marshal(queue)
|
||||
queue.Messages = savedMsgs
|
||||
if queue.DeadLetterQueue != nil {
|
||||
queue.DeadLetterQueue.Messages = savedDLQMsgs
|
||||
}
|
||||
return data, err
|
||||
return json.Marshal(queueToMetaDTO(queue))
|
||||
}
|
||||
|
||||
// SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно.
|
||||
@@ -106,7 +230,7 @@ func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
|
||||
// создаёт консистентный снапшот. Горутина только делает сетевой вызов.
|
||||
// Для сохранения сообщений используй SaveMessage/SaveMessages.
|
||||
func SaveQueue(key string, queue *models.Queue) {
|
||||
if Client == nil {
|
||||
if Client.Load() == nil {
|
||||
return
|
||||
}
|
||||
// Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот
|
||||
@@ -122,7 +246,7 @@ func SaveQueue(key string, queue *models.Queue) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
|
||||
if err := Client.Load().HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
|
||||
log.Errorf("persistence: HSet queue meta %q: %v", key, err)
|
||||
}
|
||||
})
|
||||
@@ -132,7 +256,7 @@ func SaveQueue(key string, queue *models.Queue) {
|
||||
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
|
||||
// Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения.
|
||||
func SaveMessage(queueKey string, msg *models.SqsMessage) {
|
||||
if Client == nil {
|
||||
if Client.Load() == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
@@ -141,19 +265,47 @@ func SaveMessage(queueKey string, msg *models.SqsMessage) {
|
||||
return
|
||||
}
|
||||
hashKey := redisMsgHashPrefix + queueKey
|
||||
msgKey := queueKey + "|" + msg.Uuid
|
||||
writeSeq := nextMsgWriteSeq(msgKey)
|
||||
asyncWrite(func() {
|
||||
if !isLatestMsgWriteSeq(msgKey, writeSeq) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
|
||||
if err := Client.Load().HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
|
||||
log.Errorf("persistence: HSet message %s/%s: %v", queueKey, msg.Uuid, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SaveMessageSync — сохраняет ОДНО сообщение в Redis СИНХРОННО и возвращает ошибку.
|
||||
// Используется на пути SendMessage: ответ 200 клиенту должен выдаваться ТОЛЬКО
|
||||
// после фактической записи сообщения в Redis, иначе краш пода в окне между
|
||||
// ответом и async-записью приведёт к потере подтверждённого сообщения.
|
||||
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
|
||||
// Если Client == nil (memory-only режим) — возвращает nil (персистить некуда).
|
||||
func SaveMessageSync(queueKey string, msg *models.SqsMessage) error {
|
||||
if Client.Load() == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msg.Uuid, err)
|
||||
}
|
||||
hashKey := redisMsgHashPrefix + queueKey
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.Load().HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
|
||||
return fmt.Errorf("HSet message %s/%s: %w", queueKey, msg.Uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveMessages — сохраняет несколько сообщений в Redis одним pipeline.
|
||||
// Вызывать под Lock.
|
||||
func SaveMessages(queueKey string, msgs []models.SqsMessage) {
|
||||
if Client == nil || len(msgs) == 0 {
|
||||
if Client.Load() == nil || len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
// Сериализуем все сообщения синхронно под Lock
|
||||
@@ -178,85 +330,116 @@ func SaveMessages(queueKey string, msgs []models.SqsMessage) {
|
||||
for k, v := range fields {
|
||||
args = append(args, k, v)
|
||||
}
|
||||
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||
log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMessagePersist — удаляет одно сообщение из Redis асинхронно.
|
||||
// Вызывать при DeleteMessage (после удаления из in-memory).
|
||||
func DeleteMessagePersist(queueKey string, msgUuid string) {
|
||||
if Client == nil || msgUuid == "" {
|
||||
return
|
||||
// SaveMessagesSync — сохраняет несколько сообщений в Redis СИНХРОННО (pipeline).
|
||||
// Используется на пути SendMessageBatch: ответ 200 выдаётся только после записи
|
||||
// всех сообщений (как в одиночном SendMessage). Возвращает ошибку для отката.
|
||||
func SaveMessagesSync(queueKey string, msgs []models.SqsMessage) error {
|
||||
if Client.Load() == nil || len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := make(map[string]string, len(msgs))
|
||||
for i := range msgs {
|
||||
data, err := json.Marshal(&msgs[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msgs[i].Uuid, err)
|
||||
}
|
||||
fields[msgs[i].Uuid] = string(data)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
hashKey := redisMsgHashPrefix + queueKey
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
args := make([]interface{}, 0, len(fields)*2)
|
||||
for k, v := range fields {
|
||||
args = append(args, k, v)
|
||||
}
|
||||
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||
return fmt.Errorf("HSet messages %s (%d msgs): %w", queueKey, len(fields), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessagePersist — удаляет одно сообщение из Redis СИНХРОННО.
|
||||
// Синхронность обязательна: если удаление потеряется при рестарте пода,
|
||||
// сообщение «воскреснет» из Redis (факт: удалённые очереди возвращались
|
||||
// после рестарта, когда удаления были асинхронными).
|
||||
func DeleteMessagePersist(queueKey string, msgUuid string) error {
|
||||
if Client.Load() == nil || msgUuid == "" {
|
||||
return nil
|
||||
}
|
||||
hashKey := redisMsgHashPrefix + queueKey
|
||||
asyncWrite(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil {
|
||||
log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err)
|
||||
if err := Client.Load().HDel(ctx, hashKey, msgUuid).Err(); err != nil {
|
||||
return fmt.Errorf("HDel message %s/%s: %w", queueKey, msgUuid, err)
|
||||
}
|
||||
})
|
||||
clearMsgWriteSeq(queueKey, msgUuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessagesPersist — удаляет несколько сообщений из Redis.
|
||||
// Вызывать при DeleteMessageBatch.
|
||||
func DeleteMessagesPersist(queueKey string, uuids []string) {
|
||||
if Client == nil || len(uuids) == 0 {
|
||||
return
|
||||
// DeleteMessagesPersist — удаляет несколько сообщений из Redis СИНХРОННО
|
||||
// (иначе удалённые сообщения воскреснут после рестарта пода).
|
||||
func DeleteMessagesPersist(queueKey string, uuids []string) error {
|
||||
if Client.Load() == nil || len(uuids) == 0 {
|
||||
return nil
|
||||
}
|
||||
hashKey := redisMsgHashPrefix + queueKey
|
||||
// Копируем uuids — вызывающий может переиспользовать slice
|
||||
ids := make([]string, len(uuids))
|
||||
copy(ids, uuids)
|
||||
asyncWrite(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.HDel(ctx, hashKey, ids...).Err(); err != nil {
|
||||
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(ids), err)
|
||||
if err := Client.Load().HDel(ctx, hashKey, uuids...).Err(); err != nil {
|
||||
return fmt.Errorf("HDel messages %s (%d): %w", queueKey, len(uuids), err)
|
||||
}
|
||||
})
|
||||
clearMsgWriteSeq(queueKey, uuids...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis (для PurgeQueue).
|
||||
// Удаляет весь HASH ssq:msg:{queueKey}.
|
||||
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis СИНХРОННО.
|
||||
// Синхронность обязательна: при рестарте пода удалённые сообщения не должны
|
||||
// воскреснуть (факт: асинхронное удаление очередей приводило к их возврату).
|
||||
func PurgeMessagesPersist(queueKey string) {
|
||||
if Client == nil {
|
||||
if Client.Load() == nil {
|
||||
return
|
||||
}
|
||||
hashKey := redisMsgHashPrefix + queueKey
|
||||
asyncWrite(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.Del(ctx, hashKey).Err(); err != nil {
|
||||
if err := Client.Load().Del(ctx, hashKey).Err(); err != nil {
|
||||
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
|
||||
}
|
||||
})
|
||||
clearAllMsgWriteSeq(queueKey)
|
||||
}
|
||||
|
||||
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
|
||||
func DeleteQueue(key string) {
|
||||
if Client == nil {
|
||||
if Client.Load() == nil {
|
||||
return
|
||||
}
|
||||
writeSeq := nextQueueWriteSeq(key)
|
||||
msgHashKey := redisMsgHashPrefix + key
|
||||
asyncWrite(func() {
|
||||
// СИНХРОННО: удаление очереди обязано попасть в Redis до ответа клиенту.
|
||||
// Асинхронное удаление приводило к «воскрешению» удалённых очередей после
|
||||
// рестарта пода (факт 2026-08-15: 50 мусорных очередей вернулись из Redis).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if !isLatestQueueWriteSeq(key, writeSeq) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
// Удаляем метаданные из общего HASH
|
||||
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
|
||||
if err := Client.Load().HDel(ctx, redisHashQueues, key).Err(); err != nil {
|
||||
log.Errorf("persistence: HDel queue meta %q: %v", key, err)
|
||||
}
|
||||
// Удаляем весь HASH с сообщениями
|
||||
if err := Client.Del(ctx, msgHashKey).Err(); err != nil {
|
||||
if err := Client.Load().Del(ctx, msgHashKey).Err(); err != nil {
|
||||
log.Errorf("persistence: DEL messages hash %q: %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
|
||||
@@ -264,14 +447,14 @@ func DeleteQueue(key string) {
|
||||
// Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages,
|
||||
// они извлекаются в отдельный HASH и метаданные пересохраняются без Messages.
|
||||
func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||
if Client == nil {
|
||||
if Client.Load() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. Загружаем метаданные очередей
|
||||
raw, err := Client.HGetAll(ctx, redisHashQueues).Result()
|
||||
raw, err := Client.Load().HGetAll(ctx, redisHashQueues).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis HGetAll queues: %w", err)
|
||||
}
|
||||
@@ -296,6 +479,16 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||
q.FIFOSequenceNumbers = make(map[string]int)
|
||||
}
|
||||
|
||||
// Бэкфилл timestamp-ов: у очередей, созданных до появления полей, они нулевые.
|
||||
// Без этого Terraform видел бы CreatedTimestamp=0 и постоянный drift.
|
||||
// Заполняем сейчас и сохраним при ближайшем SaveQueue.
|
||||
if q.CreatedAt.IsZero() {
|
||||
q.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if q.LastModifiedAt.IsZero() {
|
||||
q.LastModifiedAt = q.CreatedAt
|
||||
}
|
||||
|
||||
// Миграция v1→v2: если в JSON есть Messages — это старый формат
|
||||
if len(q.Messages) > 0 {
|
||||
migrateKeys = append(migrateKeys, k)
|
||||
@@ -321,7 +514,7 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||
args = append(args, q.Messages[i].Uuid, string(data))
|
||||
}
|
||||
if len(args) > 0 {
|
||||
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||
log.Errorf("persistence: migrate HSet messages %q: %v", k, err)
|
||||
}
|
||||
}
|
||||
@@ -329,7 +522,7 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||
// Пересохраняем метаданные без Messages
|
||||
metaData, err := marshalQueueMeta(q)
|
||||
if err == nil {
|
||||
if err := Client.HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil {
|
||||
if err := Client.Load().HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil {
|
||||
log.Errorf("persistence: migrate HSet meta %q: %v", k, err)
|
||||
}
|
||||
}
|
||||
@@ -339,10 +532,11 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||
// 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди
|
||||
for k, q := range queues {
|
||||
hashKey := redisMsgHashPrefix + k
|
||||
msgRaw, err := Client.HGetAll(ctx, hashKey).Result()
|
||||
msgRaw, err := Client.Load().HGetAll(ctx, hashKey).Result()
|
||||
if err != nil {
|
||||
log.Errorf("persistence: HGetAll messages %q: %v", k, err)
|
||||
continue
|
||||
// Fail-fast: скрытая пустая очередь при недоступных сообщениях
|
||||
// хуже явной ошибки — сообщения остались бы невидимыми в Redis.
|
||||
return nil, fmt.Errorf("redis HGetAll messages %q: %w", k, err)
|
||||
}
|
||||
if len(msgRaw) > 0 {
|
||||
msgs := make([]models.SqsMessage, 0, len(msgRaw))
|
||||
@@ -359,6 +553,27 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||
// Пустая очередь — Messages уже nil, оставляем
|
||||
q.Messages = nil
|
||||
}
|
||||
// Строим индексы (P1.2): receiptIdx/uuidIdx/deadIdx по загруженным
|
||||
// сообщениям. Без этого первый же receive/delete перестроил бы их лениво.
|
||||
q.RebuildIndexes()
|
||||
}
|
||||
|
||||
// 4. Релинк DLQ: в метаданных хранится только имя DLQ — связываем указатель
|
||||
// с реальным объектом из map. Иначе DeadLetterQueue оказался бы detached-копией
|
||||
// (переносы в DLQ писали бы в неё, а не в живую очередь; мьютекс — чужой).
|
||||
for k, q := range queues {
|
||||
if q.DeadLetterQueueName == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(k, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
if dlq, ok := queues[k[:idx+1]+q.DeadLetterQueueName]; ok {
|
||||
q.DeadLetterQueue = dlq
|
||||
} else {
|
||||
log.Warnf("persistence: DLQ %q для очереди %q не найдена при загрузке", q.DeadLetterQueueName, k)
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("persistence: загружено %d очередей из Redis (миграций: %d)", len(queues), len(migrateKeys))
|
||||
@@ -369,12 +584,17 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||
// Принимает []byte чтобы избежать циклического импорта с пакетом tenant.
|
||||
// Сериализацию делает вызывающий (tenant_store.go).
|
||||
func SaveTenantRaw(id string, jsonData []byte) {
|
||||
if Client == nil {
|
||||
if Client.Load() == nil {
|
||||
return
|
||||
}
|
||||
// Копируем bytes — вызывающий может переиспользовать буфер
|
||||
dataCopy := make([]byte, len(jsonData))
|
||||
copy(dataCopy, jsonData)
|
||||
// Извлекаем accessKey для индекса read-through (без импорта пакета tenant)
|
||||
var meta struct {
|
||||
AccessKey string
|
||||
}
|
||||
_ = json.Unmarshal(dataCopy, &meta)
|
||||
writeSeq := nextTenantWriteSeq(id)
|
||||
asyncWrite(func() {
|
||||
if !isLatestTenantWriteSeq(id, writeSeq) {
|
||||
@@ -382,15 +602,37 @@ func SaveTenantRaw(id string, jsonData []byte) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil {
|
||||
if err := Client.Load().HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil {
|
||||
log.Errorf("persistence: HSet tenant %q: %v", id, err)
|
||||
}
|
||||
if meta.AccessKey != "" {
|
||||
if err := Client.Load().HSet(ctx, redisHashTenantAK, meta.AccessKey, string(dataCopy)).Err(); err != nil {
|
||||
log.Errorf("persistence: HSet tenant_ak %q: %v", meta.AccessKey, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// LoadTenantByAccessKey — read-through тенанта по accessKey (для auth-middleware).
|
||||
// Возвращает сырой JSON; десериализацию делает вызывающий.
|
||||
func LoadTenantByAccessKey(accessKey string) ([]byte, bool) {
|
||||
if Client.Load() == nil || accessKey == "" {
|
||||
return nil, false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
v, err := Client.Load().HGet(ctx, redisHashTenantAK, accessKey).Result()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return []byte(v), true
|
||||
}
|
||||
|
||||
// DeleteTenant — удаляет тенанта из Redis асинхронно.
|
||||
func DeleteTenant(id string) {
|
||||
if Client == nil {
|
||||
// Удаляет из ОБОИХ индексов: ssq:tenants (по id) и ssq:tenant_ak (по accessKey),
|
||||
// иначе LoadTenantByAccessKey вернёт удалённого тенанта и отзыв ключей не сработает.
|
||||
func DeleteTenant(id, accessKey string) {
|
||||
if Client.Load() == nil {
|
||||
return
|
||||
}
|
||||
writeSeq := nextTenantWriteSeq(id)
|
||||
@@ -400,9 +642,14 @@ func DeleteTenant(id string) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := Client.HDel(ctx, redisHashTenants, id).Err(); err != nil {
|
||||
if err := Client.Load().HDel(ctx, redisHashTenants, id).Err(); err != nil {
|
||||
log.Errorf("persistence: HDel tenant %q: %v", id, err)
|
||||
}
|
||||
if accessKey != "" {
|
||||
if err := Client.Load().HDel(ctx, redisHashTenantAK, accessKey).Err(); err != nil {
|
||||
log.Errorf("persistence: HDel tenant_ak %q: %v", accessKey, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -419,6 +666,41 @@ func isLatestQueueWriteSeq(key string, seq uint64) bool {
|
||||
return queueWriteSeq[key] == seq
|
||||
}
|
||||
|
||||
func nextMsgWriteSeq(key string) uint64 {
|
||||
seqMu.Lock()
|
||||
defer seqMu.Unlock()
|
||||
msgWriteSeq[key]++
|
||||
return msgWriteSeq[key]
|
||||
}
|
||||
|
||||
func isLatestMsgWriteSeq(key string, seq uint64) bool {
|
||||
seqMu.Lock()
|
||||
defer seqMu.Unlock()
|
||||
return msgWriteSeq[key] == seq
|
||||
}
|
||||
|
||||
// clearMsgWriteSeq — удаляет записи нумерации для удалённых сообщений
|
||||
// (иначе msgWriteSeq рос бы бесконечно).
|
||||
func clearMsgWriteSeq(queueKey string, uuids ...string) {
|
||||
seqMu.Lock()
|
||||
defer seqMu.Unlock()
|
||||
for _, u := range uuids {
|
||||
delete(msgWriteSeq, queueKey+"|"+u)
|
||||
}
|
||||
}
|
||||
|
||||
// clearAllMsgWriteSeq — удаляет ВСЕ записи нумерации для очереди (purge).
|
||||
func clearAllMsgWriteSeq(queueKey string) {
|
||||
prefix := queueKey + "|"
|
||||
seqMu.Lock()
|
||||
defer seqMu.Unlock()
|
||||
for k := range msgWriteSeq {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(msgWriteSeq, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextTenantWriteSeq(id string) uint64 {
|
||||
seqMu.Lock()
|
||||
defer seqMu.Unlock()
|
||||
@@ -435,12 +717,12 @@ func isLatestTenantWriteSeq(id string, seq uint64) bool {
|
||||
// LoadAllTenantsRaw — загружает всех тенантов из Redis при старте.
|
||||
// Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go.
|
||||
func LoadAllTenantsRaw() (map[string][]byte, error) {
|
||||
if Client == nil {
|
||||
if Client.Load() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
raw, err := Client.HGetAll(ctx, redisHashTenants).Result()
|
||||
raw, err := Client.Load().HGetAll(ctx, redisHashTenants).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis HGetAll tenants: %w", err)
|
||||
}
|
||||
@@ -451,3 +733,63 @@ func LoadAllTenantsRaw() (map[string][]byte, error) {
|
||||
log.Infof("persistence: загружено %d тенантов из Redis", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---- Leader election (active/passive) ----
|
||||
//
|
||||
// Простейший HA-механизм (вариант C): один инстанс — активный (держит лок),
|
||||
// остальные — пассивные. При падении активного лок истекает по TTL и пассивный
|
||||
// его перехватывает (см. app/leader). Инстанс сообщает готовность через /ready.
|
||||
//
|
||||
// ВАЖНО: при Client == nil (memory-only) — считаем себя лидером (мы единственные).
|
||||
|
||||
// TryAcquireLock — пытается взять лок (SETNX с TTL). true — лок наш.
|
||||
func TryAcquireLock(key, value string, ttl time.Duration) (bool, error) {
|
||||
if Client.Load() == nil {
|
||||
return true, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return Client.Load().SetNX(ctx, key, value, ttl).Result()
|
||||
}
|
||||
|
||||
// refreshLockScript — продлить TTL, ТОЛЬКО если лок всё ещё наш (атомарно).
|
||||
var refreshLockScript = redis.NewScript(`
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||||
else
|
||||
return 0
|
||||
end`)
|
||||
|
||||
// releaseLockScript — удалить лок, ТОЛЬКО если он наш.
|
||||
var releaseLockScript = redis.NewScript(`
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("DEL", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end`)
|
||||
|
||||
// RefreshLock — продлевает TTL лока, если он всё ещё наш. false — лок потерян.
|
||||
func RefreshLock(key, value string, ttl time.Duration) (bool, error) {
|
||||
if Client.Load() == nil {
|
||||
return true, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
n, err := refreshLockScript.Run(ctx, Client.Load(), []string{key}, value, ttl.Milliseconds()).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// ReleaseLock — освобождает лок (если он наш). Вызывается при graceful shutdown.
|
||||
func ReleaseLock(key, value string) {
|
||||
if Client.Load() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if _, err := releaseLockScript.Run(ctx, Client.Load(), []string{key}, value).Int(); err != nil {
|
||||
log.Errorf("persistence: release lock %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
+131
-28
@@ -4,6 +4,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -27,8 +28,13 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// New — создаёт HTTP router с tenant auth и admin API
|
||||
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||
// NewDataPlane — публичный SQS API (data-plane): SQS actions, /health, статика, описание.
|
||||
// НЕ содержит админ-консоль, /metrics и /ui — они в control-plane (NewControlPlane).
|
||||
//
|
||||
// Разбиение data-plane/control-plane (Шаг 2): публично доступен только этот роутер.
|
||||
// Пока — два листенера в одном процессе (общее in-memory состояние); отдельные процессы
|
||||
// станут возможны после выноса состояния в Redis (Шаг 3).
|
||||
func NewDataPlane(tenantStore *tenant.TenantStore) http.Handler {
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB
|
||||
@@ -38,38 +44,17 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||
// /health — публичный, без auth
|
||||
r.HandleFunc("/health", health).Methods("GET")
|
||||
|
||||
// /ready — готовность инстанса (для failover/балансировщика)
|
||||
r.HandleFunc("/ready", readyHandler).Methods("GET")
|
||||
|
||||
// /static — логотип, favicon для публичных страниц
|
||||
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
|
||||
|
||||
// /metrics — Prometheus endpoint для Victoria Metrics scrape
|
||||
r.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
||||
|
||||
// Страница примеров команд — публично
|
||||
r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) {
|
||||
ui.ExamplesHandler(models.Version).ServeHTTP(w, req)
|
||||
}).Methods("GET")
|
||||
|
||||
// Админ-консоль — статика. Регистрируем ДО admin subrouter,
|
||||
// иначе PathPrefix("/admin") с bearer-auth перехватит GET /admin.
|
||||
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
|
||||
ui.AdminHandler(models.Version).ServeHTTP(w, req)
|
||||
}).Methods("GET")
|
||||
r.HandleFunc("/admin/", func(w http.ResponseWriter, req *http.Request) {
|
||||
ui.AdminHandler(models.Version).ServeHTTP(w, req)
|
||||
}).Methods("GET")
|
||||
|
||||
// Admin API — Bearer token auth, регистрируется через AdminHandler
|
||||
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
||||
adminHandler.RegisterRoutes(r)
|
||||
|
||||
// UI public API — JWT auth, для встроенной console
|
||||
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
|
||||
adminHandler.RegisterPublicRoutes(r)
|
||||
|
||||
// UI console — встроенный SPA, публичный доступ
|
||||
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
|
||||
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
||||
|
||||
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
|
||||
// r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
|
||||
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
|
||||
@@ -91,6 +76,51 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
// NewControlPlane — админка, метрики и UI (control-plane).
|
||||
// ВАЖНО: этот роутер должен слушать ТОЛЬКО localhost (не проксируется наружу).
|
||||
func NewControlPlane(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||
r := mux.NewRouter()
|
||||
|
||||
r.Use(requestBodyLimitMiddleware)
|
||||
|
||||
// /health — для проверки control-plane локально
|
||||
r.HandleFunc("/health", health).Methods("GET")
|
||||
|
||||
// /ready — готовность (лидер ли)
|
||||
r.HandleFunc("/ready", readyHandler).Methods("GET")
|
||||
|
||||
// /static — нужен UI-консоли (логотип/favicon)
|
||||
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
|
||||
|
||||
// /metrics — Prometheus endpoint для Victoria Metrics scrape.
|
||||
// Защищён Bearer admin-токеном: метрики содержат per-tenant данные
|
||||
// (accessKey/число очередей и сообщений), которые нельзя светить наружу.
|
||||
r.Handle("/metrics", metricsAuth(adminToken, promhttp.Handler())).Methods("GET")
|
||||
|
||||
// Админ-консоль — статика. Регистрируем ДО admin subrouter,
|
||||
// иначе PathPrefix("/admin") с bearer-auth перехватит GET /admin.
|
||||
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
|
||||
ui.AdminHandler(models.Version).ServeHTTP(w, req)
|
||||
}).Methods("GET")
|
||||
r.HandleFunc("/admin/", func(w http.ResponseWriter, req *http.Request) {
|
||||
ui.AdminHandler(models.Version).ServeHTTP(w, req)
|
||||
}).Methods("GET")
|
||||
|
||||
// Admin API — Bearer token auth, регистрируется через AdminHandler
|
||||
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
||||
adminHandler.RegisterRoutes(r)
|
||||
|
||||
// UI public API — JWT auth, для встроенной console
|
||||
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
|
||||
adminHandler.RegisterPublicRoutes(r)
|
||||
|
||||
// UI console — встроенный SPA
|
||||
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
|
||||
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// requestBodyLimitMiddleware — ограничивает размер тела HTTP запроса до 3MB.
|
||||
// Защита от OOM при отправке гигантских messages/batch (Fix #3.5).
|
||||
func requestBodyLimitMiddleware(next http.Handler) http.Handler {
|
||||
@@ -181,6 +211,71 @@ func health(w http.ResponseWriter, req *http.Request) {
|
||||
fmt.Fprintf(w, `{"status":"ok","version":%q}`, models.Version)
|
||||
}
|
||||
|
||||
// ReadyFunc — если задана, /ready отдаёт 200 при true и 503 иначе (для failover/LB).
|
||||
// Задаётся в main при включённом leader-election. nil → всегда готовы.
|
||||
var ReadyFunc func() bool
|
||||
|
||||
// readyHandler — готовность инстанса обслуживать трафик (для балансировщика/failover).
|
||||
// Пассивный инстанс отдаёт 503, чтобы LB не направлял на него трафик.
|
||||
func readyHandler(w http.ResponseWriter, req *http.Request) {
|
||||
if ReadyFunc != nil && !ReadyFunc() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ready"}`))
|
||||
}
|
||||
|
||||
// metricsAuth — оборачивает /metrics в проверку Bearer admin-токена.
|
||||
// В отличие от публичного /health, метрики содержат per-tenant данные
|
||||
// (accessKey, число очередей/сообщений) и не должны быть доступны без auth.
|
||||
func metricsAuth(adminToken string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
expected := "Bearer " + adminToken
|
||||
if len(authHeader) != len(expected) ||
|
||||
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// countBatchEntries — подсчитывает число записей в batch-запросе SQS.
|
||||
// Для query-протокола записи идут как SendMessageBatchRequestEntry.N.Id (N=1..).
|
||||
// Для JSON-протокола считаем по количеству записей в массиве Entries (requestBody).
|
||||
// Возвращает 0 если определить не удалось (тогда биллинг запишет msg_count=1).
|
||||
func countBatchEntries(req *http.Request, action string) int {
|
||||
// Query-формат: считаем уникальные N в SendMessageBatchRequestEntry.N.Id и т.п.
|
||||
prefix := "SendMessageBatchRequestEntry."
|
||||
if action == "DeleteMessageBatch" {
|
||||
prefix = "DeleteMessageBatchRequestEntry."
|
||||
} else if action == "ChangeMessageVisibilityBatch" {
|
||||
prefix = "ChangeMessageVisibilityBatchRequestEntry."
|
||||
}
|
||||
// Разбираем form, если ещё не разобран (иначе PostForm пуст для lazy-запросов).
|
||||
_ = req.ParseForm()
|
||||
|
||||
if req.PostForm != nil || req.Form != nil {
|
||||
form := req.Form
|
||||
if form == nil {
|
||||
form = req.PostForm
|
||||
}
|
||||
count := 0
|
||||
for key := range form {
|
||||
if strings.HasPrefix(key, prefix) && strings.HasSuffix(key, ".Id") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func actionHandler(w http.ResponseWriter, req *http.Request) {
|
||||
action := extractAction(req)
|
||||
start := time.Now()
|
||||
@@ -208,8 +303,16 @@ func actionHandler(w http.ResponseWriter, req *http.Request) {
|
||||
if queueName == "" {
|
||||
queueName = req.FormValue("QueueName")
|
||||
}
|
||||
// Billing: запись в PostgreSQL (async, no-op если billing выключен)
|
||||
billing.RecordUsage(t.ID, action, queueName, 1, msgBytes)
|
||||
// Billing: запись в PostgreSQL (async, no-op если billing выключен).
|
||||
// msgCount для batch-операций — число сообщений в батче; для прочих — 1.
|
||||
msgCount := 1
|
||||
if action == "SendMessageBatch" || action == "DeleteMessageBatch" ||
|
||||
action == "ChangeMessageVisibilityBatch" {
|
||||
if n := countBatchEntries(req, action); n > 0 {
|
||||
msgCount = n
|
||||
}
|
||||
}
|
||||
billing.RecordUsage(t.ID, action, queueName, msgCount, msgBytes)
|
||||
// Prometheus counters: requests + bytes
|
||||
metrics.RequestsTotal.WithLabelValues(t.ID, action).Inc()
|
||||
metrics.RequestBytesTotal.WithLabelValues(t.ID, action).Add(float64(msgBytes))
|
||||
|
||||
+57
-48
@@ -4,7 +4,6 @@ package tenant
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -26,7 +25,7 @@ const DefaultTenantMaxQueues = 50
|
||||
// Tenant — модель тенанта shared-sqs.
|
||||
// AccessKey используется как идентификатор в AWS Authorization header.
|
||||
type Tenant struct {
|
||||
ID string // уникальный идентификатор тенанта (t-<hex>: детерминированный из email для JWT-тенантов, случайный для manual)
|
||||
ID string // уникальный идентификатор тенанта (t-<hex>: случайный, генерируется через crypto/rand)
|
||||
Name string // человекочитаемое имя
|
||||
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
||||
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
||||
@@ -88,6 +87,12 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
// Повторная проверка лимита ПОД Lock: ранняя проверка выше была под RLock,
|
||||
// между ней и взятием Lock другой поток мог создать тенанта (TOCTOU).
|
||||
if len(s.byID) >= MaxTenantsGlobal {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
|
||||
}
|
||||
s.byID[t.ID] = t
|
||||
s.byAccessKey[t.AccessKey] = t
|
||||
if t.Email != "" {
|
||||
@@ -105,7 +110,23 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
||||
|
||||
// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo).
|
||||
// Используется только при инициализации демо-данных; не вызывается из user-facing API.
|
||||
// CreateFixedAutoID — создаёт тенанта с заранее известными креды (BYOC),
|
||||
// но СЛУЧАЙНЫМ tenantID (генерируется через crypto/rand). Используется Admin API
|
||||
// при задании произвольных access_key/secret_key без явного tenantID.
|
||||
func (s *TenantStore) CreateFixedAutoID(name string, maxQueues int, accessKey, secretKey string) (*Tenant, error) {
|
||||
id, err := generateTenantID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||
}
|
||||
return s.CreateFixed(name, maxQueues, id, accessKey, secretKey)
|
||||
}
|
||||
|
||||
func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) {
|
||||
// access_key используется как префикс ключа "{accessKey}:{queueName}" —
|
||||
// ':' в нём ломает изоляцию тенантов (ListQueues/коллизии ключей).
|
||||
if strings.Contains(accessKey, ":") {
|
||||
return nil, fmt.Errorf("access key must not contain ':'")
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, exists := s.byID[tenantID]; exists {
|
||||
s.mu.Unlock()
|
||||
@@ -168,8 +189,8 @@ func (s *TenantStore) Delete(id string) bool {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Удаляем из Redis асинхронно
|
||||
persistence.DeleteTenant(id)
|
||||
// Удаляем из Redis асинхронно (оба индекса: по id и по accessKey)
|
||||
persistence.DeleteTenant(id, t.AccessKey)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -196,54 +217,63 @@ func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) {
|
||||
}
|
||||
|
||||
// CreateFromJWT — auto-provisioning тенанта из JWT claims.
|
||||
// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email.
|
||||
// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле.
|
||||
// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается:
|
||||
// старые индексы и Redis-запись удаляются, создаётся детерминированная запись.
|
||||
// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются
|
||||
// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей).
|
||||
// Идентичность — email: тенант ищется/создаётся по email (индекс byEmail).
|
||||
// Креды (ID/AccessKey/SecretKey) генерируются СЛУЧАЙНО через crypto/rand и
|
||||
// НЕ выводятся из email — иначе креды можно было бы вычислить по публичному email
|
||||
// (SHA256(email+соль) при известной соли = полный доступ к чужим очередям).
|
||||
// Тот же email при повторном логине → тот же тенант и те же креды (из индекса/Redis).
|
||||
func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) {
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("JWT email is required for tenant provisioning")
|
||||
}
|
||||
tenantID := tenantIDFromEmail(email)
|
||||
accessKey := accessKeyFromEmail(email)
|
||||
secretKey := secretKeyFromEmail(email)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Идемпотентность: тенант с таким email уже есть
|
||||
// Идемпотентность: тенант с таким email уже есть — возвращаем как есть,
|
||||
// креды НЕ пересчитываем и НЕ меняем (они случайны и неизменны).
|
||||
if existing, ok := s.byEmail[email]; ok {
|
||||
// Полное совпадение — возвращаем как есть
|
||||
if existing.ID == tenantID && existing.AccessKey == accessKey {
|
||||
if sub != "" {
|
||||
// Персистим только если что-то реально изменилось: иначе лишние записи
|
||||
// в Redis на каждый логин, а обновлённый NubesSub раньше вообще терялся
|
||||
// (сохранялся только внутри ветки смены MaxQueues).
|
||||
changed := false
|
||||
if sub != "" && existing.NubesSub != sub {
|
||||
existing.NubesSub = sub
|
||||
changed = true
|
||||
}
|
||||
// Обновляем лимит очередей на актуальное значение из кода при каждом
|
||||
// логине и персистим в Redis — иначе после рестарта пода из Redis
|
||||
// восстановится старый лимит (было: тенант навсегда оставался с лимитом,
|
||||
// заданным при первом логине).
|
||||
// логине — иначе после рестарта пода из Redis восстановится старый лимит.
|
||||
if existing.MaxQueues != maxQueues {
|
||||
existing.MaxQueues = maxQueues
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if data, err := json.Marshal(existing); err == nil {
|
||||
persistence.SaveTenantRaw(existing.ID, data)
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
// Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую
|
||||
delete(s.byID, existing.ID)
|
||||
delete(s.byAccessKey, existing.AccessKey)
|
||||
delete(s.byEmail, existing.Email)
|
||||
persistence.DeleteTenant(existing.ID)
|
||||
}
|
||||
|
||||
if len(s.byID) >= MaxTenantsGlobal {
|
||||
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
|
||||
}
|
||||
|
||||
id, err := generateTenantID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||
}
|
||||
accessKey, err := generateAccessKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access key: %w", err)
|
||||
}
|
||||
secretKey, err := generateSecretKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate secret key: %w", err)
|
||||
}
|
||||
|
||||
t := &Tenant{
|
||||
ID: tenantID,
|
||||
ID: id,
|
||||
Name: email,
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
@@ -266,27 +296,6 @@ func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant,
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// tenantIDFromEmail — детерминированный ID тенанта из email: "t-{16 hex}".
|
||||
func tenantIDFromEmail(email string) string {
|
||||
hash := sha256.Sum256([]byte(strings.ToLower(email)))
|
||||
return "t-" + hex.EncodeToString(hash[:8])
|
||||
}
|
||||
|
||||
// accessKeyFromEmail — детерминированный AccessKey: "SSAK-{24 hex}".
|
||||
func accessKeyFromEmail(email string) string {
|
||||
hash := sha256.Sum256([]byte(strings.ToLower(email)))
|
||||
return "SSAK-" + hex.EncodeToString(hash[:12])
|
||||
}
|
||||
|
||||
// secretKeySalt — соль для детерминированного SecretKey email-тенантов.
|
||||
const secretKeySalt = "shared-sqs:secret-key:v1"
|
||||
|
||||
// secretKeyFromEmail — детерминированный SecretKey: 64 hex = SHA256(email + соль).
|
||||
func secretKeyFromEmail(email string) string {
|
||||
h := sha256.Sum256([]byte(strings.ToLower(email) + ":" + secretKeySalt))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// List — список всех тенантов (для admin GET /tenants).
|
||||
func (s *TenantStore) List() []*Tenant {
|
||||
s.mu.RLock()
|
||||
|
||||
+13
-1
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
@@ -82,7 +83,11 @@ func ExtractQueueAttributes(u url.Values) map[string]string {
|
||||
// CreateErrorResponseV1 — формирует SQS error response по ключу ошибки.
|
||||
// Параметр isSqs оставлен для обратной совместимости (всегда true).
|
||||
func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) {
|
||||
err := models.SqsErrors[errKey]
|
||||
err, ok := models.SqsErrors[errKey]
|
||||
if !ok {
|
||||
// Неизвестный ключ → InternalError вместо nil-deref (ревью 2026-10-11).
|
||||
err = models.SqsErrors["InternalError"]
|
||||
}
|
||||
|
||||
respStruct := models.ErrorResponse{
|
||||
Result: err.Response(),
|
||||
@@ -97,6 +102,13 @@ func GetMD5Hash(text string) string {
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
// GetSHA256Hash — SHA-256 (hex) от значения. Используется для
|
||||
// ContentBasedDeduplication: AWS сам вычисляет MessageDeduplicationId из тела.
|
||||
func GetSHA256Hash(text string) string {
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func HashAttributes(attributes map[string]models.MessageAttribute) string {
|
||||
hasher := md5.New()
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ REST API (iot-operator:9090) → Пользователь (UI/Terraform)
|
||||
```
|
||||
|
||||
### Ключевые компоненты k8s
|
||||
- **CRD IoTDevice** (`iot.kube5s.ru/v1alpha1`) — регистрация устройств
|
||||
- **CRD IoTDevice** (`iot.nubes.ru/v1alpha1`) — регистрация устройств
|
||||
- **k8s Secret** `iot-{deviceId}` — хранит MQTT пароль (64 hex, crypto/rand)
|
||||
- **OwnerReference** — каскадное удаление Secret при удалении IoTDevice
|
||||
- **RBAC** — ClusterRole для чтения/записи IoTDevice + Secrets
|
||||
@@ -161,9 +161,9 @@ Managed PostgreSQL — IoT данные
|
||||
|
||||
### Что сейчас
|
||||
- SQS сервис живёт в `namespace: shared-sqs` в кластере `iot-naeel`
|
||||
- Endpoint: `https://qu.kube5s.ru`
|
||||
- Endpoint: `https://sqs.containerk8s.dev.nubes.ru`
|
||||
- Multi-tenant: tenant `iot-service` (id: `t-96afe7e9f781f6ca`), очередь `iot-telemetry`
|
||||
- IoT использует: `SQS_ENDPOINT=https://qu.kube5s.ru`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`
|
||||
- IoT использует: `SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`
|
||||
- Протокол: AWS SQS-совместимый (SendMessage, ReceiveMessage, DeleteMessage, GetQueueUrl, GetQueueAttributes)
|
||||
|
||||
### Что нужно от нового SQS сервиса
|
||||
@@ -180,7 +180,7 @@ Managed PostgreSQL — IoT данные
|
||||
|
||||
### Env vars для IoT → SQS
|
||||
```
|
||||
SQS_ENDPOINT=https://qu.kube5s.ru (поменяется на новый managed URL)
|
||||
SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru (поменяется на новый managed URL)
|
||||
SQS_ACCESS_KEY=...
|
||||
SQS_SECRET_KEY=...
|
||||
SQS_QUEUE_NAME=iot-telemetry (default)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Архитектурный анализ размещения и разбиения shared-sqs (2026-10-08)
|
||||
|
||||
**Роль:** архитектор. **Статус:** решения согласованы с владельцем, записаны для исполнения.
|
||||
|
||||
---
|
||||
|
||||
## 1. Исходные требования (согласовано)
|
||||
|
||||
1. **SQS должен быть полноценным публичным сервисом** — как AWS SQS и Yandex YMQ.
|
||||
Клиенты ходят к нему снаружи по HTTPS через AWS SDK. Внешний доступ — обязателен,
|
||||
а не опция.
|
||||
2. **Без монолитов** — но с уточнением (раздел 3): не «микросервисы ради микросервисов»,
|
||||
а «разные процессы там, где разные требования».
|
||||
3. **Девопсов не дёргать сейчас** — у них и так заботы.
|
||||
|
||||
## 2. Сетевая стратегия (две беды платформы, обход)
|
||||
|
||||
См. `nubes-large-body-workaround-findings-2026-10-08.md` и
|
||||
`nubes-workaround-applicability-to-sqs-2026-10-08.md`.
|
||||
|
||||
**Две независимые беды managed-шлюза:**
|
||||
- A. Большие пакеты/тела не проходят (MSS 1448 при underlay 1450, PMTUD мёртв) — ~51 с;
|
||||
- B. Таймауты/RST на ЛЮБЫХ телах из-за таймеров шлюза (период 31–33 с; keep-alive 10 в drhider).
|
||||
|
||||
Обе — на стороне платформы, тикет не решает (доказано опытом drhider).
|
||||
|
||||
**Принятая стратегия:**
|
||||
1. Сейчас — публичная точка входа на **ВМ** (наш nginx, TLS, MTU, keep-alive), минуя
|
||||
managed-шлюз. Полный контроль, девопсы не нужны.
|
||||
2. Когда сервис отлажен и появились реальные клиенты — к девопсам с накопленными
|
||||
доказательствами (tcpdump, период 31–33 с, MSS 1448, опыт drhider), просить чинить
|
||||
managed-шлюз уже предметно.
|
||||
|
||||
**Уточнение про «в кубере та же хуйня»:** она есть только на пути через managed
|
||||
ingress извне. Внутри кластера сеть чистая (6–8 мс, без таймаутов).
|
||||
|
||||
## 3. Разбиение: «модульный монолит», а не микросервисы
|
||||
|
||||
Вывод обсуждения: монолит плох там, где смешивает разное; микросервисы — плата
|
||||
(сеть, распределённые транзакции, оркестрация, дебаг), которая для стадии
|
||||
«никто не использует, один человек» преждевременна.
|
||||
|
||||
**Критерии, по которым решаем, выносить ли в отдельный процесс:**
|
||||
1. разный профиль нагрузки/масштабирования;
|
||||
2. разный жизненный цикл / частота релизов;
|
||||
3. stateful vs stateless.
|
||||
|
||||
**Целевое разбиение SQS:**
|
||||
|
||||
| Компонент | Отдельный процесс? | Обоснование |
|
||||
|---|---|---|
|
||||
| SQS **data-plane** (очереди, сообщения, SigV4) | **да** | горячий путь, stateful, свой профиль |
|
||||
| **control-plane** (тенанты, креды, очереди, админ) | **да** | редкие операции, другой жизненный цикл и периметр |
|
||||
| биллинг | модуль внутри | выносить = сеть ради одной горутины, пишущей в PG |
|
||||
| метрики (Prometheus) | модуль внутри | это эндпоинт, не сервис |
|
||||
| UI | статика | не сервис |
|
||||
|
||||
## 4. Ограничение, которое разбиение НЕ снимает
|
||||
|
||||
Ядро SQS держит очередь в памяти одного пода (`globals.go` `SyncQueues`,
|
||||
`redis.go` `LoadAllQueues`). «Не монолит» ≠ «масштабируется»: пока хранилище не
|
||||
вынесено наружу (чтение тоже из Redis, либо иной сторедж, либо шардирование),
|
||||
data-plane всё равно одиночка. Это отдельный, более важный вопрос — решать до
|
||||
набора тенантов.
|
||||
|
||||
## 5. Размещение IoT (для полноты картины)
|
||||
|
||||
- К IoT внешний доступ нужен по определению (устройства). Managed-шлюз в цепочке
|
||||
IoT неизбежен.
|
||||
- Но: телеметрия — мелкие MQTT-сообщения, для них критичен только разрыв wss ~150 с
|
||||
(лечится переподключением — уже реализовано в IoT).
|
||||
- Большие payload'ы от устройств через MQTT-шлюз **не пускать**: лимит размера,
|
||||
дробление на уровне протокола, либо приём крупных данных на ВМ с вытягиванием
|
||||
кластером (схема drhider).
|
||||
- IoT переделывается с нуля — не монолитом: bridge (MQTT), consumer (→storage),
|
||||
API отдельными сервисами.
|
||||
|
||||
## 6. Открытые вопросы (ждут решений)
|
||||
|
||||
1. Домен и сертификат для публичной точки SQS на ВМ.
|
||||
2. Есть ли клиенты без явного `endpoint_url` (влияет на внешние `QueueUrl`).
|
||||
3. Вынос хранилища data-plane (главный стратегический вопрос) — когда начинаем.
|
||||
4. Крупные тела у IoT-устройств нужны или только мелкая телеметрия.
|
||||
|
||||
## 7. Следующий шаг
|
||||
|
||||
Подготовить развёртывание SQS на ВМ: разбиение на data-plane и control-plane,
|
||||
nginx с правильным MTU/keep-alive/TLS, внешние `QueueUrl`. Старт — по команде
|
||||
владельца.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Критический анализ отчёта Opus (мой разбор, 2026-10-05)
|
||||
|
||||
> Кем: я (DeepSeek). Что: критический разбор отчёта Opus
|
||||
> (`opus-report-2026-10-05.md`) по архитектуре shared-sqs.
|
||||
> Метод: ключевые утверждения Опуса перепроверялись по коду напрямую, а не
|
||||
> принимались на веру. Источники — «файл:строка».
|
||||
|
||||
---
|
||||
|
||||
## Что я перепроверил по коду (факты)
|
||||
|
||||
| Утверждение Опуса | Вердикт | Источник в коде |
|
||||
|---|---|---|
|
||||
| P0.1: SecretKey детерминирован из email + соль в исходнике | ✅ подтверждено | `tenant_store.go:289-292` `secretKeyFromEmail` = `SHA256(lower(email)+":shared-sqs:secret-key:v1")`; соль — константа `secretKeySalt` (`tenant_store.go:286`); `accessKeyFromEmail` = `SHA256(email)[:12]` |
|
||||
| P0.2: JWT без проверки подписи, доверие на PingNubesAPI | ✅ подтверждено | `jwt.go` — парсинг структуры + `exp`; `PingNubesAPI` принимает, если хоть один стенд не 401/403 |
|
||||
| Кэш: отозванный токен живёт до ~7 мин | ✅ подтверждено | `cache.go`: `cacheTTL=5m`, `cacheGraceTTL=2m` |
|
||||
| Глобальный мьютекс на весь сервис | ✅ подтверждено | `globals.go:20-23` — один `SyncQueues.RWMutex` + `Queues map[string]*Queue` |
|
||||
| Сиротство очередей при пересчёте legacy-тенанта | ✅ подтверждено | `tenant_store.go:202-205` — комментарий в коде сам это описывает |
|
||||
| MessageRetentionPeriod не реализован | ✅ подтверждено | `models.go` — `// TODO - not used` |
|
||||
|
||||
Ложных утверждений не найдено.
|
||||
|
||||
---
|
||||
|
||||
## Сильные стороны отчёта
|
||||
|
||||
1. **P0.1 — самая ценная находка.** Детерминированный вывод SecretKey из
|
||||
публичного email — реальный, доказуемый по коду дефект. Указан не только
|
||||
механизм, но и точные функции. Именно это реально блокирует «готовность SQS».
|
||||
2. **Верно разделены SigV4 и JWT.** SigV4 проверяется полноценно
|
||||
(`auth_middleware.go`: canonical request, `subtle.ConstantTimeCompare`,
|
||||
clock skew, presigned); JWT — нет. Большинство поверхностных ревью их путают.
|
||||
3. **Классификация P0/P1/P2 адекватна** — безопасность+durability отделены от
|
||||
масштабируемости и гигиены.
|
||||
4. **Пункты 3–7** (глобальный лок, SPOF, async-Redis, нет RetentionPeriod,
|
||||
плоский слайс) — архитектурно точны.
|
||||
5. **Раздел «противоречия док↔код» конкретен** (особенно №1: док про
|
||||
`sub`/`bySub`/`sless-`, код про `email`/`byEmail`/`t-`).
|
||||
|
||||
---
|
||||
|
||||
## Слабые места и оговорки
|
||||
|
||||
1. **P0.2 переоценён как самостоятельный «критичный».** Вектор «подмена email
|
||||
→ доступ» опасен **только в связке с P0.1**: неверный email даёт чужие креды,
|
||||
потому что креды детерминированы из email. Исправь P0.1 (случайные креды в
|
||||
Redis) — и неверный email из JWT уже не даст чужих ключей, только
|
||||
провиженинг под чужим именем (менее критично). Опус не связал P0.1 и P0.2
|
||||
явно, хотя атака работает попарно. Стоит оформить как один инцидент.
|
||||
|
||||
2. **П.4 «SPOF/нет горизонтального масштабирования»** — справедливо
|
||||
архитектурно, но сервис и проектировался single-instance managed
|
||||
HTTP-контейнером (README). Это осознанное ограничение, а не баг. В разряд
|
||||
«высокого риска сейчас» ставить преждевременно — это долгосрочная
|
||||
архитектурная развилка, а не текущий блокер.
|
||||
|
||||
3. **П.5 (async-Redis = потеря подтверждённых сообщений) — полуверно.**
|
||||
Сообщение в памяти уже есть (клиент получил 200 = сообщение в памяти,
|
||||
источнике правды). Теряется только при краше пода **до** async-`HSet`, и
|
||||
только это узкое окно. Окно реально, но «нарушение durability» подано
|
||||
сильнее, чем есть: теряются не все сообщения, а только при краше в окне.
|
||||
|
||||
4. **П.10 (timing side-channel admin-токена)** — технически верно, но admin-токен
|
||||
приходит как высокоэнтропийный bearer; практическая эксплуатируемость timing-атаки
|
||||
через сеть у одного короткого сравнения ничтожна. Формально в P2 и есть, но
|
||||
вес в подаче чуть завышен.
|
||||
|
||||
5. **П.14 (`/metrics` публичен)** — стоит оговорка: лейбл `tenant` = tenantID
|
||||
(`t-...`), а НЕ SecretKey. Утечка реальна (число очередей/сообщений по
|
||||
тенанту), но это не утечка кредов. Формулировка корректна, но без оговорки
|
||||
читается страшнее.
|
||||
|
||||
6. **Пробел контекста (не ошибка Опуса, а следствие моего allowlist-запрета):**
|
||||
отчёт не отделяет «код-блокировку» от «платформенной блокировки» — периодические
|
||||
таймауты ~31–33с и MSS-баг для тел >~1400 байт (разобрано в
|
||||
`doc/thinking/nubes-ticket.md`). Даже идеальный код их не уберёт. Итоговый
|
||||
вывод о «готовности SQS» надо читать с этой поправкой.
|
||||
|
||||
---
|
||||
|
||||
## Итоговая оценка
|
||||
|
||||
Отчёт — **добротный, по фактам точный**. Из 16 пунктов рисков ложных нет;
|
||||
спорные — только **вес/приоритет**:
|
||||
- P0.2 связать с P0.1 (один инцидент);
|
||||
- п.4 (SPOF) и п.10 (timing) слегка переоценены для текущей single-instance роли.
|
||||
|
||||
Главная ценность: **P0.1 (детерминированный SecretKey)** — подтверждённый по
|
||||
коду блокер готовности, который до этого так жёстко не формулировался.
|
||||
Единственный реальный пробел — платформенный контекст (таймауты/MSS), но он
|
||||
возник из моего же allowlist и устраняется дополнением, не исправлением.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Находки: как та же сетевая болезнь платформы уже решена в drhider (буфер на ВМ)
|
||||
|
||||
**Дата:** 2026-10-08
|
||||
**Источник находок:** репозиторий `~/nubes/drhider` (+ `~/nubes/drhider/upload-platform`)
|
||||
**Зачем:** у shared-sqs та же проблема (таймауты внешнего пути, большие тела), и
|
||||
наивный путь «отправить тикет в Nubes» оказался тупиком — явление признано
|
||||
болезнью платформы. В drhider ту же болезнь **обошли архитектурно**, а не починили.
|
||||
|
||||
> ## ⚠️ ГЛАВНЫЙ ВЫВОД
|
||||
> **Ingress managed-кластера обрывает ВХОДЯЩИЕ тела больше порога, а ИСХОДЯЩИЙ
|
||||
> трафик (egress) из кластера не ограничен.** Отсюда рабочий приём: не «толкать»
|
||||
> большие данные в кластер (push), а **«вытягивать» их извне (pull)** — данные
|
||||
> кладутся на внешний хост-буфер, а приложение в кластере забирает их исходящим
|
||||
> запросом. Тикет в поддержку проблему не решает.
|
||||
|
||||
---
|
||||
|
||||
## 1. Диагноз проблемы (цитаты из drhider)
|
||||
|
||||
`upload-platform/HISTORY/2026-09-06-layer2-architecture-and-api-boundaries.md:18`:
|
||||
> «**Причина существования**: Ingress-контроллер k8s-кластера (`pythonk8s`)
|
||||
> обрывает входящие запросы с телом более 64 КБ. Прямой `POST` больших файлов в
|
||||
> кластер невозможен; **исходящий трафик (egress) из кластера не ограничен**.»
|
||||
|
||||
`PLAN-upload-layers.md:27` (контекст, «что уже проверено»):
|
||||
> «Паттерн загрузки (**шлюз managed-кластера рвёт тела >64КБ, egress не
|
||||
> ограничен**): браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` →
|
||||
> egress `GET` (pull) → сессия.»
|
||||
|
||||
Это ровно тот же класс, что явление A у shared-sqs (большие POST), только у
|
||||
drhider порог «тело >64 КБ» на ingress, а у SQS — MSS/MTU-порог ~1.4 КБ на
|
||||
внешнем шлюзе (`doc/thinking/nubes-ticket.md`, явление A). Причина на стороне
|
||||
платформы в обоих случаях.
|
||||
|
||||
## 2. Схема обхода, применённая в drhider (пошагово)
|
||||
|
||||
`upload-platform/HISTORY/2026-09-06-layer2-architecture-and-api-boundaries.md:19-27`
|
||||
— «Пофайловый транзит (Per-File Transit)»:
|
||||
|
||||
1. Файл №k браузер отправляет методом `PUT` **в буфер на ВМ** (вне кластера).
|
||||
2. Браузер сразу шлёт в приложение (Flask в кластере) `POST /api/upload_refs` —
|
||||
тело запроса маленькое (только ссылка на файл), поэтому ingress его пропускает.
|
||||
3. Приложение **само забирает** файл исходящим потоковым `GET` прямо в память
|
||||
сессии — egress не режется.
|
||||
4. Приложение немедленно отправляет `DELETE` на ВМ — буфер очищен.
|
||||
5. Браузер переходит к файлу №k+1. **В буфере на ВМ в любой момент максимум один файл.**
|
||||
|
||||
Дополнительные принципы оттуда же:
|
||||
- **RAM-only**: без постоянной записи на диск ни в кластере, ни на ВМ.
|
||||
- **Zero hardcode**: адрес буфера и префикс — параметры конфигурации
|
||||
(`vmUploadUrl` на фронте, `vmUploadPrefix` на бэке для обязательной SSRF-проверки).
|
||||
- **Автономный mock-буфер** (in-memory WebDAV) для тестов без внешней сети.
|
||||
|
||||
`upload/README.md:13` подтверждает ту же механику:
|
||||
> «Пофайловый стриминг: для каждого файла выполняется `PUT` в буфер на ВМ,
|
||||
> немедленный `POST /api/upload_refs` в бэкенд, и после подтверждения приёма
|
||||
> буфер на ВМ очищается.»
|
||||
|
||||
## 3. Что нельзя потерять при переносе (накоплено в drhider)
|
||||
|
||||
`PLAN-upload-layers.md` (раздел «Требования/что НЕЛЬЗЯ потерять»):
|
||||
|
||||
- дедупликация «имя+размер» (иначе суффикс `_2`);
|
||||
- раскрытие ZIP с путями и фильтр расширений (`.pdf .doc .docx .txt .md`);
|
||||
- рекурсивный обход папок (`webkitdirectory`);
|
||||
- **SSRF-валидация URL** (`url.startswith(VM_UPLOAD_PREFIX)`);
|
||||
- `_safe_name` — защита от path traversal с сохранением подпапок;
|
||||
- **повторы pull** (3 попытки, пауза 2 с);
|
||||
- лимиты **50 МБ на файл / 500 МБ на сессию**;
|
||||
- различение «сессия не найдена» и «превышен лимит»;
|
||||
- статусы «пропущен (лимит)» / «не извлечён»;
|
||||
- экранирование имён для защиты от self-XSS.
|
||||
|
||||
## 4. Фиксы платформы, которые уже применили (и их границы)
|
||||
|
||||
`PROBLEM-AND-SOLUTION.md` (drhider, v0.0.31, 2026-07-16):
|
||||
|
||||
**Проблема 1 — пауза 51 с (MTU).** Underlay MTU 1450, Cilium Geneve добавляет
|
||||
50 байт, дефолт пода 1500 → пакет 1550 → фрагментация → дроп → ядро ждёт
|
||||
повторной передачи 51 с. **Решение:** `cilium-config` → `mtu: 1400`
|
||||
(1400+50 = 1450). Задокументировано 2026-07-13, применено 2026-07-14.
|
||||
|
||||
**Проблема 2 — `ERR_CONNECTION_RESET` в браузере.** Истинная причина —
|
||||
`keep-alive: 10` вместо дефолта 75: пока пользователь выбирал файлы (>10 с),
|
||||
nginx закрывал idle-соединение, браузер не замечал FIN и слал POST в закрытый
|
||||
сокет → RST. **Решение** (в платформе Штурвала, 2026-07-15) — через
|
||||
`ShturvalServiceConfig`, а не через kubectl:
|
||||
|
||||
```yaml
|
||||
controller:
|
||||
config:
|
||||
keep-alive: "75"
|
||||
proxy-body-size: "1024m"
|
||||
client-body-timeout: "60"
|
||||
client-header-timeout: "30"
|
||||
hostPort:
|
||||
enabled: true
|
||||
replicaCount: 2
|
||||
service:
|
||||
type: LoadBalancer
|
||||
```
|
||||
> Важно: правки ingress на платформе делаются через `ShturvalServiceConfig` —
|
||||
> **через kubectl нельзя, ArgoCD откатывает**.
|
||||
|
||||
**Ложные следы, которые опровергли** (та же таблица): kube-vip ARP flapping,
|
||||
hostPort на 2 из 4 нод, upstream keepalive, conntrack, MTU (для проблемы 2),
|
||||
HTTP/2.
|
||||
|
||||
**Результаты после фиксов (v0.0.29, 2026-07-15):** 10× POST 63 КБ — 10/10 OK;
|
||||
полный UI-цикл (5 файлов + SSE + ZIP + CSV) — OK; 5× POST с интервалом 75 с —
|
||||
5/5 OK; PDF 5 МБ через curl — 200 OK за 0.62 с. **Итого 25/25, 0 RST.**
|
||||
|
||||
⚠️ Но это вылечило ingress-таймер и MTU-фрагментацию, **а не порог размера
|
||||
тела**: обходной слой через ВМ-буфер остался нужен и является рабочим решением.
|
||||
|
||||
## 5. Обходы «не по сети» — тот же принцип pull/вынос
|
||||
|
||||
`PROBLEM-AND-SOLUTION.md:71`: `doc_to_markdown()` переписали — вместо локального
|
||||
`soffice` (LibreOffice на ВМ, тяжёлый и не тянул) **HTTP-вызов внешнего сервиса**
|
||||
`liberta.containerk8s.dev.nubes.ru/convert` через `httpx`. То есть тяжёлую/медленную
|
||||
работу тоже вынесли наружу и ходят к ней по HTTP.
|
||||
|
||||
Смежное (из кода drhider): конвертация `.doc` через `liberta` выполняется
|
||||
**последовательно** (`_LLM_CONCURRENCY = 1`) — сервис не выдерживает
|
||||
конкурентность (`drhider/extractor.py:160-194`, `drhider/scanner.py:122`).
|
||||
|
||||
## 6. Готовый переиспользуемый модуль (для справки)
|
||||
|
||||
`PLAN-upload-layers.md` — слои выделены так, чтобы их можно было перенести в
|
||||
любой проект копированием (без общей репы):
|
||||
|
||||
| Слой | Где | Ответственность |
|
||||
|---|---|---|
|
||||
| 0. Конфиг | общий | адрес ВМ-буфера, расширения, лимиты |
|
||||
| 1. Выбор файлов | фронт | таблица, дедуп, ZIP, дерево папок, статусы |
|
||||
| 2. Закачка через ВМ | фронт + бэк | `PUT` на ВМ → `upload_refs` pull → сессия |
|
||||
| 3. Логика приложения | бэк | обработка (у каждого своя) — **не выносится** |
|
||||
|
||||
Реализация-эталон: `~/nubes/drhider/upload-platform/` (Слой 1 v0.1.13 готов,
|
||||
Слой 2 в работе на 2026-09-06), а также рабочий образец `~/nubes/drhider/upload/`.
|
||||
|
||||
## 7. Инфраструктурные факты
|
||||
|
||||
- **ВМ-буфер:** `5.172.178.213` (вне кластера). Nginx WebDAV, каталоги
|
||||
`/var/www/drhider-upload/`, `/var/www/contracts-upload/`, cron-очистка каждые
|
||||
5 минут (`find … -mmin +30 -delete`). Для целевого прода — перевод в
|
||||
RAM/in-memory буфер.
|
||||
- **SSH к ВМ:** `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
|
||||
(`README.md:141`).
|
||||
- **Кластеры/неймспейсы:** `pythonk8s` (ingress режет >64 КБ); сервисы drhider,
|
||||
contractor, uploader-dev.
|
||||
|
||||
## 8. Выводы (что берём из чужого опыта)
|
||||
|
||||
1. **Тикет в Nubes — не решение.** Это болезнь платформы; в drhider её не «починили»,
|
||||
а обошли. Тикет (`doc/thinking/nubes-ticket.md`) имеет смысл только как
|
||||
фиксация для истории, не как способ получить рабочий сервис.
|
||||
2. **Рабочий приём — pull вместо push:** внешний буфер + исходящий `GET` из
|
||||
кластера. Egress свободен, ingress режется.
|
||||
3. **Правки ingress на платформе** — только через `ShturvalServiceConfig`
|
||||
(kubectl откатывается ArgoCD).
|
||||
4. **MTU-фикс (`cilium mtu: 1400`)** нужен как гигиена, но порога больших тел не
|
||||
снимает.
|
||||
5. **Тяжёлые/медленные операции** тоже выносятся наружу и вызываются по HTTP
|
||||
(пример: `liberta`).
|
||||
|
||||
## Источники (файл:строка)
|
||||
|
||||
| Источник | Что |
|
||||
|---|---|
|
||||
| `~/nubes/drhider/PROBLEM-AND-SOLUTION.md` | MTU-фикс, keep-alive 10→75, ложные следы, тесты 25/25 |
|
||||
| `~/nubes/drhider/PLAN-upload-layers.md:27` | паттерн «шлюз рвёт >64 КБ, egress свободен» |
|
||||
| `~/nubes/drhider/upload/README.md:13` | пофайловый стриминг через ВМ-буфер |
|
||||
| `~/nubes/drhider/upload-platform/HISTORY/2026-09-06-layer2-architecture-and-api-boundaries.md:18-27` | диагноз + пошаговый пофайловый транзит, RAM-only, mock-буфер |
|
||||
| `~/nubes/drhider/docs/MSS-CLAMPING-PLAN.md`, `docs/about1500.md`, `docs/INGRESS-SOLUTIONS.md` | сопутствующие материалы по MTU/MSS/ingress |
|
||||
| `~/nubes/drhider/drhider/extractor.py:160-194`, `drhider/scanner.py:122` | вынос конвертации в сервис `liberta`, последовательные вызовы |
|
||||
@@ -0,0 +1,137 @@
|
||||
# Стратегия доказательства сетевого бага Nubes — v2
|
||||
|
||||
> ⚠️ **ПОМЕТКА: анализ от DeepSeek V4 Pro (основная модель) в НОВОМ чате — НЕ Соннет.**
|
||||
> Пользователь задал тот же вопрос в новом чате DeepSeek V4 Pro.
|
||||
> Сохранено 2026-08-15. Сравнение с планом DeepSeek V4 Flash — в HISTORY
|
||||
> и в конце этого файла.
|
||||
|
||||
## Стратегия доказательства
|
||||
|
||||
Доказывается изоляцией слоёв. Каждый тест исключает один слой пути:
|
||||
|
||||
```
|
||||
клиент (локальная машина) → интернет → шлюз платформы (VIP 185.247.187.151)
|
||||
→ kube-vip → NodePort 32391 → kube-proxy → nginx-ingress → Cilium → под shared-sqs
|
||||
```
|
||||
|
||||
«Виновата платформа» доказано, если ВСЕ три условия:
|
||||
1. Таймауты воспроизводятся на пути БЕЗ нашего кода (сырой TCP, эхо) с тем же паттерном.
|
||||
2. tcpdump фиксирует потерю сегмента клиент↔кластер при том, что под/nginx
|
||||
ответил за миллисекунды (по логам).
|
||||
3. Потери коррелируют с MTU-порогом (~1448) или платформенным таймером
|
||||
(conntrack/RST), а не с логикой приложения.
|
||||
|
||||
## 1. Эхо или сырые TCP
|
||||
|
||||
Делать ОБА, в порядке: сырые TCP, потом эхо.
|
||||
|
||||
**Сырые TCP на 185.247.187.151:32391** — бесплатный первый тест. Покрывает
|
||||
путь до nginx-пода без HTTP и без нашего кода. Отправлять сегменты 512, 1380,
|
||||
1448, 1488+ байт. Критерий доставки — TCP ACK, не HTTP-ответ (nginx на мусор
|
||||
ответит 400/RST — нормально).
|
||||
|
||||
**Эхо-сервис** — второй шаг, ~20 строк Go, Deployment + Service с отдельным
|
||||
NodePort (без ingress). Параметризуемый размер тела ответа (`?bytes=512|1400`) —
|
||||
большие POST тестируют прямой путь, большие ОТВЕТЫ — обратный. Гонять тем же
|
||||
клиентом. Таймауты воспроизвелись → nginx-ingress и наше приложение исключены.
|
||||
|
||||
## 2. Как поймать таймаут
|
||||
|
||||
tcpdump на клиенте: `tcpdump -i any -s 0 -w /tmp/cap.pcap host 185.247.187.151 and tcp port 32391`
|
||||
|
||||
| Симптом | Что смотреть |
|
||||
|---|---|
|
||||
| 51-с зависание большого POST | ACK на сегмент с полным MSS? ретрасмиты и интервалы? чем сессия завершилась (RST/FIN, кто первый) |
|
||||
| 30-с ReadTimeout на 512 байт | запрос отправлен полностью? ACK получен? ответ не пришёл? RST после idle (conntrack)? keep-alive пул? |
|
||||
| MTU-гипотеза | ICMP type 3 code 4 (Fragmentation Needed)? Не приходят → PMTUD сломан → механизм зависаний |
|
||||
|
||||
Замечание: 51 с не похоже на TCP-backoff Linux (tcp_retries2=15 → сотни секунд).
|
||||
Скорее прикладной таймер (~50–60 с клиента/nginx/шлюза). Точный интервал в pcap
|
||||
покажет, чей таймер.
|
||||
|
||||
Серверный захват — только через kubectl: `kubectl debug -it <pod> --image=<tcpdump-образ>`
|
||||
(нужны NET_RAW/NET_ADMIN — на managed может быть запрещено). Если запрещено —
|
||||
роль серверного захвата выполняют nginx access log и эхо-А/Б.
|
||||
|
||||
Ключевой приём: двусторонний захват, корреляция по TCP seq/ack (не по времени):
|
||||
- запрос ушёл с клиента, не появился у пода → потеря на прямом пути;
|
||||
- ответ ушёл из пода, клиент не получил → потеря на обратном;
|
||||
- в обоих случаях приложение исключено.
|
||||
|
||||
## 3. kube-vip / NodePort / nginx через kubectl
|
||||
|
||||
nginx access log + `$request_time $upstream_response_time $status $bytes_sent`:
|
||||
|
||||
| В логе | Вывод |
|
||||
|---|---|
|
||||
| 499 + большой request_time | клиент ушёл до ответа |
|
||||
| 200 + малый upstream, клиент без ответа | потеря между nginx и клиентом |
|
||||
| записи нет | потеря до nginx (kube-vip/NodePort/шлюз) |
|
||||
|
||||
kube-vip: `kubectl get pods -A | grep -i vip`, логи (ARP/BGP-анонсы, флап VIP),
|
||||
сопоставить ноду-держателя VIP с реально принимающей нодой.
|
||||
|
||||
kube-proxy/NodePort: `kubectl get ds -n kube-system kube-proxy`, логи;
|
||||
`externalTrafficPolicy: Cluster` → лишний хоп + SNAT; iptables/conntrack ноды
|
||||
не видны (нет доступа к нодам) — debug-под с hostNetwork+privileged+nodeSelector
|
||||
или тикет.
|
||||
|
||||
Cilium: `kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`
|
||||
(в cilium-под, НЕ в operator). Дроп с причиной — прямое доказательство.
|
||||
|
||||
Ограничение: внешний шлюз (VIP, MSS=1448) из kubectl не виден. Если кластерные
|
||||
точки чистые, а снаружи потери есть — виноват шлюз/underlay, оформляется тикет.
|
||||
|
||||
## 4. Пошаговый план: дешевле → дороже
|
||||
|
||||
1. Инвентаризация логов: nginx access/error за тестовый период (499, request_time
|
||||
30+/51), логи приложения (фактическое время обработки). Параллельно открыть
|
||||
тикет Nubes.
|
||||
2. PMTU-зонд с клиента: `ping -M do -s 1372/1422/1460/1472` до VIP (пороги MTU
|
||||
1400/1450/1488/1500); при запрете ICMP — `mtr --tcp -P 443`, tcptraceroute.
|
||||
MSS=1448 → сегменты 1488 при underlay 1450 — шлюз анонсирует неверный MSS.
|
||||
3. Второй клиент из другого сегмента (VPS/другой ISP): тот же скрипт.
|
||||
Воспроизвелось → исключены ISP и локальная сеть пользователя.
|
||||
4. Сырые TCP на 32391 с обеих точек: потери на больших сегментах без HTTP →
|
||||
исключены HTTP и приложение.
|
||||
5. kubectl port-forward с ВМ на shared-sqs (localhost:4100): чистый тест →
|
||||
приложение и под исправны (в обход шлюза через API-туннель).
|
||||
6. tcpdump на клиенте + сверка с nginx access log: классификация таймаутов.
|
||||
7. Эхо-сервис NodePort без ingress: А/Б 512/1400 тем же клиентом. Остались
|
||||
таймауты → nginx и приложение исключены; исчезли → завести эхо через ingress
|
||||
и повторить (изоляция nginx).
|
||||
8. Захват в кластере: debug-контейнеры tcpdump, cilium monitor drop, логи
|
||||
kube-vip/kube-proxy; двусторонняя корреляция по seq.
|
||||
9. Матрица размеров (опционально): 100–1500 байт на эхо и на 32391. Резкий
|
||||
порог 1380–1448 → MTU; плавный рост → шлюз/перегрузка.
|
||||
|
||||
## Итог: что считается доказательством
|
||||
|
||||
| Наблюдение | Вывод |
|
||||
|---|---|
|
||||
| Таймауты на эхо-NodePort (без ingress) с тем же паттерном | исключены nginx и наш код — виноват kube-vip/NodePort/шлюз/underlay |
|
||||
| nginx: 200 + upstream <100 мс, клиент без ответа | потеря между nginx и клиентом |
|
||||
| нет записи в access log | потеря до nginx |
|
||||
| pcap: сегмент не дошёл до пода / ответ не дошёл до клиента | потеря в сетевом пути |
|
||||
| ping -M do: резкий порог размера | MTU-дефект пути |
|
||||
| cilium monitor показывает drop | внутрикластерная потеря |
|
||||
| port-forward чистый, внешний IP таймаутит | виноват шлюз/NodePort/kube-vip |
|
||||
|
||||
Итоговая цепочка для тикета: pcap с клиента + access log nginx (200/upstream
|
||||
малый) + воспроизведение на эхо без нашего кода + PMTU-порог ≈1448.
|
||||
|
||||
---
|
||||
|
||||
## Сравнение с планом DeepSeek V4 Flash (основной агент)
|
||||
|
||||
Flash: силён декомпозицией по nginx-логам, но с ошибкой (cilium monitor через
|
||||
cilium-operator), переоценён echo, нет PMTU-зонда, нет порога ping -M do, нет
|
||||
port-forward, нет второго ISP.
|
||||
|
||||
Pro (этот план): сильнее методологически — сырые TCP с критерием ACK,
|
||||
PMTU-зонд ping -M do, второй клиент, port-forward, таблица «что считается
|
||||
доказательством». Грубых ошибок нет.
|
||||
|
||||
Синтез (рекомендация): дешёвые тесты обоих планов + финал echo.
|
||||
Порядок: nginx-логи → PMTU-зонд → сырые TCP → port-forward → tcpdump клиента →
|
||||
второй ISP (если есть) → cilium monitor → echo → тикет.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Plan: Доказательство сетевого бага Nubes — v3 (СОННЕТ)
|
||||
|
||||
> ✅ **ПОМЕТКА: это план от Соннета** (пользователь передал ответ Соннета).
|
||||
> Сохранено 2026-08-15. Сравнение с v1 (DeepSeek Flash) и v2 (DeepSeek Pro) —
|
||||
> в конце файла и в HISTORY.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Два РАЗНЫХ явления с разными причинами — нельзя объяснять одной гипотезой и
|
||||
нельзя ловить одним методом. Цепочка: nginx-логи → port-forward (бесплатное
|
||||
доказательство невиновности приложения) → PMTUD-зонд → tcpdump → cilium
|
||||
monitor → echo-сервис (только если предыдущих данных не хватает).
|
||||
|
||||
## Разделение двух явлений (критически важно)
|
||||
|
||||
**Явление A — большие POST (~51с).** Причина: MSS=1448, underlay MTU=1450,
|
||||
сегменты 1488 → дроп. PMTUD сломан (ICMP Type 3 Code 4 заблокирован шлюзом) →
|
||||
TCP ретрасмитит по экспоненте: 1с,2с,4с,8с,16с ≈ 31с, ещё попытка → ~51с.
|
||||
Задача: подтвердить ping-зондом и зафиксировать в тикете.
|
||||
|
||||
**Явление B — малые POST 512 байт (~1/37с).** MTU-моделью НЕ объясняется.
|
||||
Ключевая улика: РЕГУЛЯРНОСТЬ 1/37с — это таймер, не случайные потери.
|
||||
Кандидаты: сессионный таймер gateway на VIP 185.247.187.151 (вероятнее всего),
|
||||
kube-vip ARP keepalive цикл (флап при смене лидера), conntrack GC с агрессивным
|
||||
idle-таймером.
|
||||
|
||||
## 1. Echo vs сырые TCP
|
||||
|
||||
Сырые TCP на 32391 — недостаточны (проверяют только фазу установления; проблемы
|
||||
в фазе ДАННЫХ; nginx ответит RST/400 на мусор — неинформативно).
|
||||
|
||||
Правильный порядок:
|
||||
1. kubectl port-forward (бесплатно, 5 минут) — чище и быстрее echo.
|
||||
2. Echo NodePort без ingress — если port-forward и логи не дали ответа.
|
||||
|
||||
Echo: принимает POST, возвращает тело; `?bytes=N` для размера ответа
|
||||
(обратный путь отдельно от прямого). Деплой БЕЗ ingress, прямой NodePort.
|
||||
|
||||
## 2. Как поймать таймаут: tcpdump
|
||||
|
||||
Клиент (запустить ДО теста, параллельно):
|
||||
`tcpdump -i <iface> -s 0 -w /tmp/sqs.pcap 'host 185.247.187.151 and port 32391'`
|
||||
|
||||
Явление A (51с, большой POST): ICMP Type 3 Code 4 от 185.x → PMTUD работает;
|
||||
нет ICMP, ретрасмиты 1/2/4/8/16с → PMTUD сломан (основная гипотеза); кто шлёт
|
||||
RST в конце и через сколько — чей таймаут (клиент vs nginx/шлюз).
|
||||
|
||||
Явление B (30с, 512 байт): ACK на запрос получен → запрос дошёл, ответ потерян
|
||||
на обратном пути; ACK нет → потеря до nginx (kube-vip/NodePort/gateway);
|
||||
точный интервал между таймаутами (~37с ± 1с → таймер подтверждён); RST от
|
||||
сервера — чей (nginx idle keepalive или gateway).
|
||||
|
||||
Ключевой приём: корреляция по TCP seq/ack, не по времени.
|
||||
|
||||
## 3. kube-vip / NodePort / nginx — только kubectl
|
||||
|
||||
nginx access log: `kubectl logs -n ingress <nginx-pod> --since=35m | grep -E " (499|5[0-9][0-9]) "`.
|
||||
Нужны поля `$request_time $upstream_response_time`. Если нет — проверить
|
||||
ConfigMap nginx.
|
||||
|
||||
| Запись в nginx log | Вывод |
|
||||
|---|---|
|
||||
| 499 + большой request_time | клиент ушёл до ответа (наш ReadTimeout) |
|
||||
| 200 + upstream <100мс, клиент получил таймаут | ответ потерян между nginx и клиентом |
|
||||
| записи нет | потеря ДО nginx: kube-vip/NodePort/gateway |
|
||||
|
||||
kube-vip: `kubectl get pods -A | grep -i vip`, логи (leader/ARP/flap/error).
|
||||
ARP-флап во время теста → кратковременная потеря VIP → дропы.
|
||||
|
||||
Cilium (на ПОДЕ, не на operator): `kubectl get pods -n kube-system -l k8s-app=cilium -o wide`;
|
||||
`kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`
|
||||
параллельно с тестом.
|
||||
|
||||
NodePort/SNAT: `externalTrafficPolicy` — Cluster → любая нода + SNAT (лишний хоп,
|
||||
conntrack); Local → только нода-держатель VIP (миграция VIP → brief outage).
|
||||
|
||||
port-forward: `kubectl port-forward -n <ns> svc/<sqs-svc> 14100:4100` — тот же
|
||||
скрипт на localhost:14100. 0 таймаутов → приложение невиновно.
|
||||
|
||||
## 4. Пошаговый план: дешевле → дороже
|
||||
|
||||
**Фаза 0 — бесплатно, уже есть данные:**
|
||||
1. nginx access log за период прошлого теста (499, request_time/upstream_response_time).
|
||||
2. kube-vip логи: флапы в тот же промежуток.
|
||||
3. Тикет Nubes: два явления отдельно — (A) MTU/PMTUD порог 1448, (B) таймер ~37с.
|
||||
|
||||
**Фаза 1 — клиентские тесты, 30 минут:**
|
||||
4. PMTUD-зонд: `ping -M do -s N 185.247.187.151` для N=1300,1400,1420,1422,
|
||||
1448,1450,1460,1472 — точный порог. Нет ответа ≥1448 → ICMP заблокирован →
|
||||
тикет с числами.
|
||||
5. tcpdump + тот же 30-мин тест. Wireshark: `tcp.analysis.retransmission || icmp`.
|
||||
Точный интервал между таймаутами.
|
||||
|
||||
**Фаза 2 — kubectl, без деплоя:**
|
||||
6. port-forward тест ПЕРВЫМ: 15-мин прогон через туннель. 0 таймаутов →
|
||||
приложение доказанно невиновно.
|
||||
7. cilium monitor параллельно со свежим тестом против внешнего IP.
|
||||
8. Если нет upstream_response_time в формате — обновить ConfigMap, повторить.
|
||||
|
||||
**Фаза 3 — echo (только если 0–2 не дали ответа):**
|
||||
9. Minimal Go echo (NodePort, без ingress), тот же тест. Воспроизвелось →
|
||||
nginx и приложение исключены → kube-vip/NodePort/gateway. Нет → echo через
|
||||
ingress → изоляция nginx.
|
||||
10. Матрица размеров 512/1000/1400/1448/1460/1500 × 10 мин, порог бинарным поиском.
|
||||
|
||||
**Фаза 4 — финальная корреляция для тикета:**
|
||||
11. Одновременно tcpdump + nginx access log + cilium monitor. Корреляция по
|
||||
seq: «запрос ушёл, ACK получен, ответа нет» = потеря между nginx и клиентом.
|
||||
Артефакты: pcap + логи + ping-зонд → тикет.
|
||||
|
||||
**Решения о приоритетах:**
|
||||
- port-forward — самый дешёвый и чистый способ изолировать приложение. ДО echo.
|
||||
- Сырые TCP на 32391 — пропустить.
|
||||
- Echo — только если port-forward + nginx-логи не дали ответа.
|
||||
- Явления A и B документировать в тикете РАЗДЕЛЬНО.
|
||||
|
||||
---
|
||||
|
||||
## Поправки основного агента (GitHub Copilot)
|
||||
|
||||
1. **ICMP может быть закрыт на шлюзе** — тогда ping-зонд не даст порога.
|
||||
Нужен fallback: mtr --tcp / tcptraceroute или TCP-сегменты переменного
|
||||
размера (то, что Соннет отбросил как «сырые TCP» — но для определения
|
||||
ПОРОГА размера они валидны).
|
||||
2. **Второй клиент с другого ISP** (был у Pro) — отсутствует. Дешёво исключает
|
||||
локальную сеть пользователя. Добавить в фазу 1.
|
||||
3. **Параллельный контрольный канал YMQ** — стоит сохранить во всех тестах:
|
||||
отделяет «плохой интернет вообще» от «плох путь до Nubes».
|
||||
4. **port-forward идёт через API-сервер** — не тот сетевой путь; чистый
|
||||
результат исключает приложение, но НЕ доказывает шлюз. Формулировка Соннета
|
||||
«доказательство невиновности приложения» — корректна, использовать её.
|
||||
5. **Точный интервал таймаутов** надо измерять новым тестом с timestamp на
|
||||
каждый таймаут (в прошлом тесте тайминги не логировались поштучно).
|
||||
6. Смена nginx log format (ConfigMap) на managed может требовать релода/помощи
|
||||
платформы — держать как запасной шаг.
|
||||
|
||||
## Сравнение трёх планов
|
||||
|
||||
| | Flash (v1) | Pro (v2) | Соннет (v3) |
|
||||
|---|---|---|---|
|
||||
| Разделение явлений A/B | нет | частично | **явно, с причиной B=таймер** |
|
||||
| Ошибки команд | cilium через operator | нет | нет |
|
||||
| PMTU-зонд | нет | есть | есть, точный список размеров |
|
||||
| port-forward | нет | есть | **первым kubectl-тестом** |
|
||||
| Сырые TCP | недооценены | да | отброшены (спорно для порога) |
|
||||
| Второй ISP | нет | есть | нет |
|
||||
| Критерии доказательства | слабые | таблица | таблицы + фазы |
|
||||
| Объяснение 51с | нет | «прикладной таймер» | **экспонента RTO 1-2-4-8-16** |
|
||||
|
||||
Вердикт: план Соннета — лучший из трёх. Принять за основу с поправками 1–6.
|
||||
@@ -0,0 +1,132 @@
|
||||
# План: доказать, что таймауты — в пути/шлюзе Nubes — v4 (ОПУС 4.8)
|
||||
|
||||
> ✅ **ПОМЕТКА: план от Claude Opus 4.8** (пользователь передал ответ Опуса,
|
||||
> который изучил репозиторий и три предыдущих плана).
|
||||
> Сохранено 2026-08-15. Ниже — план Опуса + ответы основного агента на его
|
||||
> уточняющие вопросы.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Доказательство — изоляцией слоёв. «Виновата платформа» = таймауты
|
||||
воспроизводятся БЕЗ нашего кода (port-forward чист, echo/сырой TCP таймаутит)
|
||||
+ tcpdump с клиента ловит потерю сегмента, когда nginx/под ответили за
|
||||
миллисекунды.
|
||||
|
||||
**Два РАЗНЫХ явления — раздельно:**
|
||||
- **A. Большие POST ~51с** — локализовано: MSS=1448 → пакет 1488 > underlay
|
||||
1450 → дроп при DF, PMTUD мёртв. Осталось зафиксировать порог ping-зондом.
|
||||
- **B. Малые тела 512б, ~1 таймаут/37с** — MTU не объясняет. Регулярность =
|
||||
таймер (шлюз VIP / ARP-флап kube-vip / conntrack GC). Главная нераскрытая часть.
|
||||
|
||||
## Ответы Опуса на вопросы
|
||||
|
||||
1. **Echo vs сырой TCP.** Сырой TCP недостаточен (только SYN/ACK; потери в
|
||||
фазе данных). Самое дешёвое доказательство невиновности приложения —
|
||||
`kubectl port-forward` мимо шлюза, а не echo. Echo — позже, только если
|
||||
port-forward+логи не закрыли вопрос (он внутри кластера, а подозрение на шлюзе).
|
||||
2. **Поймать таймаут.** tcpdump на клиенте, корреляция по TCP seq/ack: ретрансмиты
|
||||
SYN → потеря на установлении; тишина после ACK → потеря после nginx; ICMP
|
||||
frag-needed → PMTUD; кто и через сколько шлёт RST → чей таймер; интервал
|
||||
между таймаутами → подтвердить ~37с.
|
||||
3. **Через kubectl.** nginx access-log request_time/upstream_response_time:
|
||||
499+большой request_time = клиент ушёл; 200+малый upstream без ответа клиенту
|
||||
= потеря nginx↔клиент; записи нет = потеря до nginx. cilium monitor --type
|
||||
drop — на cilium-ПОДЕ нужной ноды (не operator). externalTrafficPolicy
|
||||
Cluster/Local.
|
||||
|
||||
## Шаги (дешевле → дороже)
|
||||
|
||||
- **Фаза 0 (бесплатно):** nginx access-log за прошлый тест → логи kube-vip
|
||||
(флапы) → тикет Nubes (A и B раздельно).
|
||||
- **Фаза 1 (клиент, ~30 мин):** PMTUD-зонд ping -M do (fallback mtr --tcp) →
|
||||
tcpdump + повтор теста через long_compare_local.py с логом timestamp каждого
|
||||
таймаута → контрольный канал YMQ во всех прогонах.
|
||||
- **Фаза 2 (kubectl, без деплоя):** port-forward ПЕРВЫМ (невиновность приложения)
|
||||
→ cilium monitor drop параллельно → проверка externalTrafficPolicy/nodePort.
|
||||
- **Фаза 3 (echo, если 0–2 не хватило):** Go echo без ingress на NodePort →
|
||||
таймаутит → nginx и код исключены → потом echo через ingress (изоляция nginx).
|
||||
- **Фаза 4 (тикет):** одновременный tcpdump + nginx-log + cilium monitor,
|
||||
корреляция по seq → pcap+логи+ping в тикет.
|
||||
|
||||
**Что считается доказательством:** echo-NodePort таймаутит тем же паттерном →
|
||||
nginx+код исключены; nginx 200/upstream<100мс без ответа клиенту → потеря
|
||||
nginx↔клиент; pcap показывает недоставленный сегмент; ping-порог → MTU;
|
||||
cilium drop → внутри кластера; port-forward чист + внешний IP таймаутит →
|
||||
приложение невиновно; стабильный интервал ~37с → таймер шлюза.
|
||||
|
||||
---
|
||||
|
||||
## Уточнения Опуса и ответы основного агента
|
||||
|
||||
1. **Второй клиент с другого ISP?** НЕТ. В распоряжении только ВМ
|
||||
(5.172.178.213), но она рядом с платформой — с неё таймаутов нет, как
|
||||
«второй клиент из другого сегмента» не подходит. Частичная замена уже есть:
|
||||
контрольный канал YMQ с той же локали — 1 ошибка на 959 при 49 таймаутах на
|
||||
Nubes (виноват локальный интернет → YMQ страдал бы так же). Полагаемся на
|
||||
YMQ-канал; второй ISP — если появится доступ к VPS.
|
||||
2. **Debug-под с NET_RAW?** Не проверяли; платформа managed, ноды недоступны,
|
||||
привилегии урезаны — с высокой вероятностью запрещено. Под — alpine без
|
||||
NET_RAW, серверный tcpdump не получится. Ограничиваемся nginx access-log +
|
||||
cilium monitor + echo А/Б. Возможность kubectl debug проверяется одной
|
||||
попыткой.
|
||||
3. **Echo сразу?** НЕТ — только если фазы 0–2 не дадут однозначного ответа.
|
||||
Доп. риск: новый NodePort для echo на managed может быть недоступен.
|
||||
|
||||
## Поправка основного агента
|
||||
|
||||
port-forward идёт через API-сервер (не тот сетевой путь) — он доказывает
|
||||
только невиновность приложения, а не вину шлюза. Использовать формулировку
|
||||
Опуса из таблицы доказательств (она корректна).
|
||||
|
||||
---
|
||||
|
||||
## ФИНАЛ (обновление Опуса с зафиксированными решениями, 2026-08-15)
|
||||
|
||||
**TL;DR.** Изоляция слоёв. «Виновата платформа» = два несмешиваемых вывода:
|
||||
(1) приложение невиновно — port-forward через API-сервер чист (иной путь,
|
||||
шлюз этим НЕ обвиняется); (2) виноват путь/шлюз — echo/сырой TCP таймаутит тем
|
||||
же паттерном И tcpdump с клиента ловит потерю сегмента, когда nginx/под
|
||||
ответили за миллисекунды.
|
||||
|
||||
**Зафиксированные решения:**
|
||||
1. Второго ISP нет; ВМ рядом с Nubes → не «другой сегмент». Опора — контрольный
|
||||
канал YMQ (49 таймаутов Nubes vs 1/959 YMQ уже исключают локальный интернет).
|
||||
2. Серверный tcpdump практически недоступен (managed, exec только в alpine-под
|
||||
без NET_RAW). kubectl debug с NET_RAW — одна проверочная попытка. Замена:
|
||||
nginx access-log + cilium monitor + echo А/Б.
|
||||
3. Echo — не сразу, только если фазы 0–2 неоднозначны; доп. риск — новый
|
||||
NodePort для echo может быть закрыт на managed.
|
||||
4. port-forward доказывает ТОЛЬКО невиновность приложения, НЕ вину шлюза.
|
||||
|
||||
**Шаги (дешевле → дороже):**
|
||||
- Фаза 0 (бесплатно): nginx access-log за прошлый тест → логи kube-vip →
|
||||
тикет Nubes (A и B раздельно).
|
||||
- Фаза 1 (клиент, ~30 мин): PMTUD-зонд ping -M do (fallback mtr --tcp) →
|
||||
tcpdump + long_compare_local.py с timestamp каждого таймаута → YMQ-канал
|
||||
во всех прогонах.
|
||||
- Фаза 2 (kubectl): port-forward первым → cilium monitor drop параллельно →
|
||||
externalTrafficPolicy/nodePort. Одна попытка kubectl debug NET_RAW.
|
||||
- Фаза 3 (echo, если 0–2 не хватило): Go echo без ingress на NodePort →
|
||||
затем через ingress (изоляция nginx).
|
||||
- Фаза 4 (тикет): tcpdump клиента + nginx-log + cilium monitor, корреляция
|
||||
по seq → pcap+логи+ping.
|
||||
|
||||
---
|
||||
|
||||
## Сравнение ответов ЧЕТЫРЁХ агентов на один промпт (основной агент)
|
||||
|
||||
Условия НЕ равны: Соннет читал планы Flash и Pro; Опус читал все три плана и
|
||||
весь репозиторий. Это «накопленный контекст», а не чистый тест моделей.
|
||||
|
||||
| | Flash | Pro | Sonnet | Opus |
|
||||
|---|---|---|---|---|
|
||||
| Вклад | первым заметил: 512б-таймауты не объясняются MTU | инструменты изоляции: PMTU-зонд, второй ISP, port-forward, таблица доказательств | концептуальная структура: A/B, 51с=экспонента RTO, 1/37с=таймер, port-forward первым | интеграция: синтез всего + факты репозитория + фиксация решений |
|
||||
| Ошибки | cilium через operator; echo переоценён | «51с = прикладной таймер» (хуже экспоненты RTO) | нет | нет |
|
||||
| Итог | черновик | сильный методолог | лучший аналитик | лучший интегратор |
|
||||
|
||||
Вывод основного агента: ключевые идеи распределились по всем четырём —
|
||||
даже слабый Flash увидел главное (второе явление), Pro дал инструменты,
|
||||
Sonnet построил структуру, Opus всё собрал. Конвергенция четырёх моделей на
|
||||
одной цепочке (nginx-логи → PMTU → tcpdump → port-forward → cilium → echo) —
|
||||
признак устойчивости плана. Плюс поправки основного агента приняты Опуса
|
||||
в финал (port-forward). Итоговый план — продукт всех участников, исполнять.
|
||||
@@ -0,0 +1,106 @@
|
||||
# План сетевых экспериментов: shared-sqs на Nubes
|
||||
|
||||
> ⚠️ **ПОМЕТКА: анализ от СЛАБОГО агента — DeepSeek V4 Flash (не Соннет).**
|
||||
> Пользователь по ошибке задал вопрос DeepSeek V4 Flash вместо Соннета.
|
||||
> План сохранён как рабочий черновик; критику основного агента см. в HISTORY
|
||||
> (2026-08-15) и в конце беседы. Технические неточности Flash см. ниже в разделах
|
||||
> «Поправки основного агента».
|
||||
|
||||
## Контекст
|
||||
|
||||
Платформа Nubes (managed Kubernetes). Сервис shared-sqs (SQS-очередь, Go,
|
||||
HTTP 4100). С ВМ внутри сети p50 = 6–8 мс, таймаутов нет. С интернета —
|
||||
проблемы.
|
||||
|
||||
**Два независимых явления:**
|
||||
1. Большие POST (>~1400 байт) виснут ~51 с — объясняется MTU/MSS 1448
|
||||
(пакеты 1488 > underlay 1450, дроп).
|
||||
2. Малые тела (512 байт): 49 из 888 запросов ReadTimeout 30 с, равномерно
|
||||
~1 в 37 с — MTU-моделью НЕ объясняется. Второй независимый источник потерь.
|
||||
|
||||
Топология: клиент → 185.247.187.151:32391 (kube-vip NodePort) →
|
||||
shturval-ingress-controller (nginx, ns ingress) → Cilium (pod mtu=1400) → под.
|
||||
|
||||
---
|
||||
|
||||
## 1. Echo-сервис или сырые TCP?
|
||||
|
||||
Сырых TCP недостаточно (проверяют только установление). Echo нужен, но:
|
||||
1. Тот же путь: тот же кластер, тот же ingress (отдельный host/path), тот же Cilium.
|
||||
2. Echo должен возвращать **тело запроса** (не «pong»), чтобы гонять тела
|
||||
1400/1500/2000 байт в обе стороны.
|
||||
3. Тот же network policy/namespace.
|
||||
|
||||
## 2. Как поймать момент таймаута
|
||||
|
||||
tcpdump с локали (нет доступа к нодам):
|
||||
- захват: `tcpdump -i any -nn -s 0 'host 185.247.187.151 and port 32391' -w /tmp/cap.pcap`
|
||||
- смотреть: SYN/ретрасмиты/RST/DUP ACK/сегменты >1450 с DF.
|
||||
- `ss -ti` на установленном соединении → реальный MSS.
|
||||
|
||||
Признаки:
|
||||
| Признак | Значение |
|
||||
|---|---|
|
||||
| SYN→SYN-ACK, потом тишина на данных | потери после установления (nginx/Cilium/под) |
|
||||
| ретрасмиты SYN | потери на установлении (kube-vip/NodePort/шлюз) |
|
||||
| ретрасмиты с растущим RTO (1,2,4,8,16,30+) | потеря пакета |
|
||||
| сегменты >1450 с DF без ответа | MTU-гипотеза |
|
||||
| RST | смотреть кто шлёт (платформа vs приложение) |
|
||||
|
||||
Вместо tcpdump на нодах — kubectl:
|
||||
- **cilium monitor --type drop** — дропы с причинами (MTU/policy/conntrack).
|
||||
- Логи nginx-ingress.
|
||||
|
||||
## 3. kube-vip / NodePort / nginx — проверка через kubectl
|
||||
|
||||
Декомпозиция по access-log nginx:
|
||||
- запись есть → дошло до nginx → проблема после nginx.
|
||||
- записи нет → потеря до nginx (kube-vip/NodePort/шлюз).
|
||||
|
||||
Метрики: `request_time` vs `upstream_response_time`:
|
||||
- request_time≈30с, upstream мал → задержка между nginx и клиентом (обратный путь/шлюз).
|
||||
- upstream≈30с → задержка nginx→под (Cilium/наш под).
|
||||
|
||||
Проверки:
|
||||
- `kubectl -n kube-system get pods -o wide | grep kube-vip` + логи.
|
||||
- `kubectl -n ingress get svc -o yaml` — nodePort 32391, externalTrafficPolicy
|
||||
(Local → обслуживает только нода с VIP; Cluster → SNAT через любую ноду).
|
||||
- access-log nginx с таймингами.
|
||||
|
||||
Дешёвый тест без приложения: GET на несуществующий путь через тот же NodePort
|
||||
(ответит nginx default backend) — если GET без тела стабилен, а POST теряется —
|
||||
потери в передаче данных.
|
||||
|
||||
## 4. План «дешевле → дороже»
|
||||
|
||||
1. Анализ access-log nginx за 30-мин тест (бесплатно).
|
||||
2. Connect-тест 32391: 1000+ соединений, мерить connect (бесплатно).
|
||||
3. GET без тела, 1000+ запросов (бесплатно).
|
||||
4. tcpdump с локали + стрельба POST 512 и 1500 байт, поймать 2–3 таймаута в pcap.
|
||||
5. Echo-сервис на ту же платформу (средняя стоимость).
|
||||
6. cilium monitor параллельно со стрельбой.
|
||||
7. Матрица размеров тел 512/1000/1400/1450/1500/2000 × 15+ мин через echo
|
||||
+ контрольный канал к YMQ.
|
||||
8. Тикет платформе с пачкой доказательств.
|
||||
|
||||
Критический вопрос: малые тела теряются ~1 раз в 37 с. Кандидаты: потери на
|
||||
шлюзе, ARP/leader kube-vip, conntrack GC Cilium. Шаги 4–6 должны их разделить.
|
||||
|
||||
---
|
||||
|
||||
## Поправки основного агента (GitHub Copilot)
|
||||
|
||||
1. **Ошибка Flash**: `kubectl exec -n kube-system deploy/cilium-operator -- cilium monitor`
|
||||
— неверно. cilium monitor запускается на **cilium-поде (DaemonSet)**:
|
||||
`kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`.
|
||||
И только на ноде, где живёт интересующий под.
|
||||
2. **Приоритеты**: echo-сервис — НИЖЕ по ценности, чем кажется. Уже известно,
|
||||
что с ВМ таймаутов нет → проблема между интернетом и внутренней сетью
|
||||
(kube-vip/NodePort/шлюз), а echo сидит внутри. Сначала: nginx-логи (ш.1),
|
||||
tcpdump (ш.4), cilium monitor (ш.6) — они дешёвые и локализуют лучше.
|
||||
3. **WSL-нюанс**: tcpdump на локальной WSL-машине может требовать sudo и
|
||||
конкретного интерфейса (`-i any` работает не всегда).
|
||||
4. **Шаг 7 дорогой**: 7 размеров × 15 мин ≈ 2 часа. Достаточно 512/1400/1450/
|
||||
1500/2000 × 10 мин, порог искать бинарно.
|
||||
5. **Шаг 3 неточен**: GET на несуществующий путь того же host может уйти в наш
|
||||
под (его router отдаст 400/404) — «приложение не участвует» не гарантировано.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Тикет в Nubes: периодические сетевые таймауты внешнего пути (shared-sqs)
|
||||
|
||||
**Дата:** 2026-08-15
|
||||
**Сервис:** shared-sqs (SQS-очередь, Go), инстанс iot-naeel,
|
||||
ns f1ffb134-7d16-45bd-8bef-69f6ec8ab33c, deployment containerk8s.
|
||||
**Внешний адрес:** 185.247.187.151:443 (домен sqs.containerk8s.dev.nubes.ru,
|
||||
NodePort 32391), kube-vip, ingress shturval-ingress-controller.
|
||||
|
||||
## Явление A: большие POST (~51с зависания)
|
||||
|
||||
- MSS, анонсируемый шлюзом = 1448 (проверено на живом соединении), MTU пода
|
||||
1400, underlay MTU 1450. Сегменты 1488 байт превышают underlay → дроп при DF.
|
||||
- PMTUD не работает: ICMP Type 3 Code 4 до клиента не доходит.
|
||||
- Воспроизводится POST с телом >~1.4KB: зависание ~51с (экспонента ретрасмитов
|
||||
1-2-4-8-16с).
|
||||
- Просьба: исправить MSS анонса на шлюзе (или включить MSS clamping /
|
||||
PMTUD на внешнем шлюзе).
|
||||
|
||||
## Явление B: периодические таймауты на МАЛЫХ телах (512 байт) — ГЛАВНОЕ
|
||||
|
||||
**Симптом:** с внешней машины SQS-операции с телом 512 байт получают
|
||||
ReadTimeout 30с с периодом **31.2–32.9с** (внутри прогона стабилен до 0.1с;
|
||||
18+9+8 интервалов измерены в трёх прогонах). Параллельный канал Yandex YMQ с
|
||||
той же машины — 1 ошибка на 959 запросов.
|
||||
|
||||
**Доказательства (все — в HISTORY/2026-08-14-session-log.md):**
|
||||
1. **nginx access-log** за 30-мин тест: 862×200, **max request_time 68мс,
|
||||
499=0**. nginx все запросы обрабатывает за миллисекунды.
|
||||
2. **Серверный tcpdump** (ephemeral-контейнер в поде shared-sqs, eth0:4100):
|
||||
в момент каждого таймаута — «дыра» 29.5с БЕЗ единого пакета: ни SYN, ни
|
||||
данных, ни ретрансмитов. Зависший запрос **не доходит до пода** (иначе был
|
||||
бы виден). 8 дыр на 8 сбоев, период 31.2с.
|
||||
3. **cilium monitor --type drop** на ноде пода во время теста — **0 дропов**.
|
||||
4. **kubectl port-forward** (в обход шлюза) — тот же клиент, та же нагрузка:
|
||||
**24977 раундов, 0 сбоев**. Приложение невиновно.
|
||||
5. С ВМ (внутренняя сеть): таймаутов нет вообще (p50 6–8мс).
|
||||
6. **kube-vip в ошибках**: все 4 vip-пода каждую ~1с не могут создать leader
|
||||
election record (namespaces "0a42bef3-7ce1-42b7-aa80-4274c4d9568f" not found),
|
||||
provider — «failed to ensure load balancer: no address pools could be found»
|
||||
(ретраи с backoff до 5 мин). vipHost закреплён за control-plane нодой.
|
||||
|
||||
**Вывод:** запросы периодически теряются на участке внешний клиент → ingress
|
||||
(шлюз/VIP/kube-vip/NodePort платформы). Внутри кластера потерь нет. Период
|
||||
31–33с указывает на платформенный таймер/цикл (сессионный таймер шлюза,
|
||||
kube-vip announce, conntrack GC).
|
||||
|
||||
**Просьба:**
|
||||
1. Проверить конфигурацию kube-vip (leader election в несуществующем
|
||||
namespace, address pools) и внешний шлюз 185.247.187.151.
|
||||
2. Объяснить/устранить периодический сбой каждые ~31с.
|
||||
3. Исправить MSS анонс (1448 → ≤1410) для явления A.
|
||||
|
||||
**Дополнение (полный read-only обзор кластера 2026-08-15):**
|
||||
- Внутри кластера цикла с периодом 31–33с НЕТ нигде: kube-vip пишет ошибки
|
||||
каждые 1с, provider/services-controller — каждые 5 мин. Таймер 31с — на
|
||||
внешнем шлюзе (вне кластера), что совпадает с потерей на участке
|
||||
клиент→ingress.
|
||||
- kube-vip поды 34 дня без рестарта: leader election бьётся в namespace
|
||||
удалённого инстанса (0a42bef3-...) — вероятно, кэш удалённого LB-сервиса;
|
||||
перезапуск DaemonSet убрал бы этот шум.
|
||||
- ConfigMap kubevip в ns kube-vip отсутствует полностью — пулы LB никогда не
|
||||
были настроены; VIP держится только аннотацией vipHost.
|
||||
|
||||
**Как воспроизвести:** приложить tests/ (long_compare_local.py, tcp_mtu_probe.py)
|
||||
или: цикл send/receive/delete 512б с retries=0 и read_timeout=30 с внешней
|
||||
машины → сбой каждые ~31с.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Применимость обхода из drhider к shared-sqs (разбор)
|
||||
|
||||
**Дата:** 2026-10-08
|
||||
**Основание:** `nubes-large-body-workaround-findings-2026-10-08.md` (находки из `~/nubes/drhider`).
|
||||
**Статус:** анализ. **Ничего ещё не внедрено** — сначала замеры (см. раздел 5).
|
||||
|
||||
---
|
||||
|
||||
## 1. Почему нельзя скопировать схему 1:1
|
||||
|
||||
В drhider обе стороны протокола — свои (браузер + своё приложение на Flask),
|
||||
поэтому туда встроили двухшаговый протокол: `PUT` файла на ВМ-буфер → `POST`
|
||||
ссылки в кластер → исходящий `GET` (pull).
|
||||
|
||||
В shared-sqs **обе стороны чужие по протоколу**: клиенты — сторонние приложения
|
||||
с AWS SDK (подпись SigV4), протокол SQS синхронный (запрос → ответ). Вставить
|
||||
«положите тело на буфер, пришлите ссылку» нельзя без изменения клиентов и потери
|
||||
совместимости с AWS SDK.
|
||||
|
||||
→ Прямой аналог буфера в SQS — **прозрачный обратный прокси на ВМ**, а не буфер
|
||||
и не хранилище. Данные идут потоком, протокол не меняется.
|
||||
|
||||
## 2. Что именно болит у shared-sqs (наши факты)
|
||||
|
||||
| Явление | Причина по нашим документам | Источник |
|
||||
|---|---|---|
|
||||
| A. Большие тела (>~1.4 КБ) виснут ~51 с | MSS, анонсируемый шлюзом = 1448; MTU пода 1400, underlay 1450 → сегменты 1488 дропаются при DF; PMTUD мёртв (ICMP не доходит) | `nubes-ticket.md:11-13` |
|
||||
| B. Малые тела (512 Б) — `ReadTimeout` 30 с с периодом 31–33 с | таймер на ВНЕШНЕМ шлюзе; доказано tcpdump: запросы не доходят до пода, nginx чист, Cilium 0 дропов | `nubes-ticket.md` (явление B), HANDOFF |
|
||||
|
||||
Что уже проверено ранее:
|
||||
- **внутри кластера** — проблем нет; **с ВМ (5.172.178.213) p50 = 6–8 мс, таймаутов нет**;
|
||||
- **MTU пода уже 1400** — то есть тот фикс, который помог drhider (1500→1400), у нас
|
||||
**уже применён**, а явление A осталось: причина в MSS-анонсе шлюза, а это вне
|
||||
нашего контроля (`nubes-ticket.md:16-17`);
|
||||
- лимит SQS — тело ≤ 262144 Б (256 КБ), т.е. большие тела легальны и попадают
|
||||
ровно в проблемную зону.
|
||||
|
||||
## 3. Варианты, применимые к SQS
|
||||
|
||||
### Вариант 1 (основной) — публичная точка входа на ВМ-прокси
|
||||
|
||||
Схема: `клиент (AWS SDK) → HTTPS на ВМ → nginx (ВМ) → под shared-sqs в кластере`.
|
||||
|
||||
Убираем из пути внешний шлюз платформы. Основания:
|
||||
- путь «интернет → ВМ» в drhider держал тела 63 КБ и 5 МБ **успешно**
|
||||
(`PROBLEM-AND-SOLUTION.md`), то есть у ВМ нет той патологии, что у шлюза кластера;
|
||||
- путь «ВМ → кластер» у нас измерен: 6–8 мс, таймаутов нет.
|
||||
|
||||
**Технические условия (важные, иначе не заработает):**
|
||||
|
||||
1. **SigV4 подписывает заголовок `Host`.** nginx обязан передавать внешний хост:
|
||||
`proxy_set_header Host $host;`. Сервис берёт хост именно из запроса —
|
||||
`auth_middleware.go:315-317` (`canonicalHeaderValue`: `name == "host"` → `r.Host`).
|
||||
Подменять Host на внутренний (например, на служебное имя сервиса) **нельзя** —
|
||||
подпись перестанет сходиться, клиент получит `SignatureDoesNotMatch`.
|
||||
2. **URL очередей сейчас жёстко внутренние и без TLS.** `tenantQueueURL`
|
||||
(`tenant_helpers.go:29-37`) формирует `http://{region}.{host}:{port}/{tenantID}/{queue}`.
|
||||
Хост и порт читаются **только из `goaws.yaml`** (`conf/config.go:87-90`; в
|
||||
`applyEnvironmentDefaults` env-переменных для Host нет). Следствия:
|
||||
- клиенты, которые сами задают `endpoint_url` (все наши тесты), берут из
|
||||
`QueueUrl` только путь — они не пострадают;
|
||||
- клиенты **без** `endpoint_url` пойдут на хост из `QueueUrl` → обязаны получить
|
||||
внешний адрес. Значит: либо (а) подставляем внешний хост/схему в конфиг, либо
|
||||
(б) nginx переписывает `QueueUrl` в ответах (хрупко), либо (в) требуем от
|
||||
клиентов явный `endpoint_url`.
|
||||
3. **Транспорт.** Схема `http://` в URL — при переносе на ВМ либо оставляем
|
||||
`http` (нежелательно для интернета), либо переводим на `https` и правим
|
||||
формирование URL. Для TLS нужен сертификат на ВМ-домен.
|
||||
4. **Доступ ВМ → кластер.** ВМ вне кластера; понадобится NodePort/VIP (или
|
||||
опубликованный внутренний адрес). Путь ВМ→кластер нужно замерить (см. раздел 5).
|
||||
5. **Проксирование тел** — без буферизации на диск: `proxy_request_buffering off`,
|
||||
`client_max_body_size` ≥ 256 КБ (+ запас).
|
||||
|
||||
### Вариант 2 — настройки ingress через `ShturvalServiceConfig`
|
||||
|
||||
В drhider этим вылечили RST (`keep-alive: 10` → `75`), подняли
|
||||
`proxy-body-size: 1024m`, выключили HTTP/2, подняли `client-body-timeout: 60`.
|
||||
Правки делаются **только через `ShturvalServiceConfig`** — kubectl откатывает ArgoCD
|
||||
(`PROBLEM-AND-SOLUTION.md`).
|
||||
|
||||
Оценка для нас: **скорее всего не поможет**. Наш таймер (явление B) доказанно
|
||||
находится **вне кластера** (tcpdump: запросы не доходят до пода; nginx чист), а
|
||||
MSS-анонс делает **внешний шлюз**. Но правка дешёвая и безопасная → имеет смысл
|
||||
проверить замером «до/после», прежде чем строить прокси.
|
||||
|
||||
### Вариант 3 — MTU-фикс
|
||||
|
||||
**Уже сделан** (MTU пода 1400). Явление A не убрал — так как причина в шлюзе.
|
||||
Для SQS этот путь закрыт.
|
||||
|
||||
### Вариант 4 — клиентские ретраи (+ идемпотентность)
|
||||
|
||||
Маскирует явление B, **не решает** явление A (51 с ожидания при большом теле).
|
||||
Для standard-очередей ретрай без идемпотентности даёт дубли (FIFO-дедуп по
|
||||
`MessageDeduplicationId` у нас есть, для standard — нет). Годится как временная
|
||||
мера, не как решение.
|
||||
|
||||
## 4. Сводка: что какой вариант лечит
|
||||
|
||||
| Вариант | Явление A (большие тела) | Явление B (таймер) | Стоимость/риск |
|
||||
|---|---|---|---|
|
||||
| 1. Прокси на ВМ | да (по аналогии с drhider) | да (шлюз вне пути) | TLS, Host, внешние URL, доступ ВМ→кластер |
|
||||
| 2. Настройки ingress | вероятно нет | вероятно нет | низкая; нужен доступ к платформенному конфигу |
|
||||
| 3. MTU | нет (уже 1400) | нет | — |
|
||||
| 4. Ретраи клиентов | нет | частично | дубли для standard |
|
||||
|
||||
## 5. Что обязательно замерить ДО решения
|
||||
|
||||
Правило проекта: рекомендация без замера — догадка. Замеры на том же хосте и тем
|
||||
же инструментом:
|
||||
|
||||
1. **С ВМ → внешний endpoint кластера**: тела 1 КБ / 10 КБ / 100 КБ / 256 КБ —
|
||||
есть ли ~51 с? (Малые тела с ВМ проверены: 6–8 мс, таймаутов нет; большие, судя
|
||||
по всему, отдельно не мерили.)
|
||||
2. **ВМ → кластер (NodePort/VIP)**: те же размеры — сколько занимает.
|
||||
3. **Явление B с ВМ**: сохраняется ли период 31–33 с.
|
||||
4. **После варианта 2** (если включаем): те же замеры, сравнение «до/после».
|
||||
|
||||
Инструменты уже есть в репозитории: `tests/tcp_mtu_probe.py`,
|
||||
`tests/long_compare_local.py`, `tests/compare_ymq_vs_shared.py`,
|
||||
`tests/platform_probe.py`, `tests/reboot_probe.py`.
|
||||
|
||||
## 6. Вывод
|
||||
|
||||
- Тикет в поддержку — **не решение** (болезнь платформы), оставляем только как
|
||||
историю; в drhider её тоже не «починили», а обошли.
|
||||
- Архитектурный аналог решения drhider для SQS — **прозрачный прокси на ВМ**
|
||||
(вариант 1). Ключевые камни: сохранение `Host` для SigV4, внешние `QueueUrl`
|
||||
(сейчас `http://{region}.{host}:{port}` из `goaws.yaml`), TLS, доступ ВМ→кластер.
|
||||
- Перед стройкой — замеры из раздела 5; возможно, вариант 2 закроет часть проблемы
|
||||
за 10 минут вместо новой инфраструктуры.
|
||||
|
||||
## 7. Открытые вопросы (нужны твои решения)
|
||||
|
||||
1. Внешний домен и сертификат для ВМ-прокси — какой брать?
|
||||
2. Клиенты без явного `endpoint_url` — есть ли такие (иначе вопрос №3 в разделе 3
|
||||
отпадает)?
|
||||
3. Делать ли сначала замеры (раздел 5) — я готов.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Отчёт Opus — КРАКО и план доработок shared-sqs (2026-10-05)
|
||||
|
||||
> Полный текст ответа Opus (без сокращений) на промпт
|
||||
> `opus-review-prompt-2026-10-05.md`. Сохранён для истории и критического
|
||||
> анализа.
|
||||
|
||||
---
|
||||
|
||||
# 1. КРАКО — критический разбор архитектуры
|
||||
|
||||
## Что сделано хорошо
|
||||
|
||||
- **Изоляция тенантов проста и надёжна в ядре.** Ключ очереди `{AccessKey}:{queueName}` (`tenant_helpers.go`) делает кросс-тенантный доступ через SQS физически невозможным: тенант берётся из контекста, ключ строится из его AccessKey. Простое и верное решение.
|
||||
- **SigV4 реально проверяется** (вопреки формулировке «подпись не проверяется» — это про JWT, не про SQS). В `auth_middleware.go` полноценный canonical request, `subtle.ConstantTimeCompare`, проверка clock skew, поддержка presigned. Это сильная сторона.
|
||||
- **Аккуратно закрыты классические гонки.** Атомарный receive под одним `Lock` (устранён TOCTOU «потери» сообщений, `receive_message.go`), double-check под Lock в `send_message.go`, пер-очередной Lock в `PeriodicTasks` вместо глобального на весь обход (`gosqs.go`).
|
||||
- **OOM-защита по нескольким фронтам:** лимит тела 3 MB, `MaxHeaderBytes`, пер-очередной лимит сообщений, глобальный лимит тенантов.
|
||||
- **Продуманный graceful shutdown и HTTP-таймауты** с обоснованием под long-poll (`goaws.go`).
|
||||
- **IDOR-защита в UI** и урезанные права UI-тенанта (не может создавать/удалять тенантов, видит только себя) — `admin.go`.
|
||||
- **Персистентность с write-sequence guard** (last-write-wins по ключу) и синхронным удалением, чтобы удалённые очереди/сообщения не «воскресали» из Redis.
|
||||
|
||||
## Главные слабости и риски (по коду)
|
||||
|
||||
### 🔴 Критичные (безопасность)
|
||||
|
||||
1. **SecretKey детерминированно выводится из email + захардкоженная соль в исходнике.**
|
||||
`tenant_store.go`: `secretKey = SHA256(lower(email) + ":" + "shared-sqs:secret-key:v1")`, `accessKey = SHA256(email)[:12]`. Email — не секрет. Кто знает email уже залогинившегося пользователя (тенант существует) и соль (она в репозитории форка), тот вычисляет AccessKey+SecretKey и получает **полный доступ к чужим очередям напрямую через SQS-эндпоинт** (JWT там не нужен — только SigV4). Это нарушает базовый принцип: секрет обязан быть случайным, а не функцией публичного идентификатора.
|
||||
|
||||
2. **Идентичность тенанта берётся из НЕподписанного JWT (claim `email`), а из email выводятся креды.**
|
||||
`jwt.go` подпись не проверяет; доверие целиком на `PingNubesAPI`, который принимает токен, если **хоть один** стенд вернул не 401/403 (`jwt.go`). Если шлюз при GET `/svc` не связывает жёстко токен ↔ email, появляется вектор подмены email → провиженинг/доступ под чужой личностью. Плюс кэш валидации даёт отозванному токену жить до 5–7 мин (`cache.go`).
|
||||
|
||||
### 🟠 Высокие (production-readiness)
|
||||
|
||||
3. **Один глобальный мьютекс `SyncQueues` на весь сервис.** Любой send/receive/tick берёт единственный `Lock` (`globals.go`). `receiveMessagesUnderLock` держит глобальный Lock на время O(N)-скана сообщений, а long-poll переобходит его каждые 100 мс. Это главный архитектурный боттлнек — ровно он давал всплески latency, упомянутые в комментариях. Не масштабируется по числу очередей/тенантов.
|
||||
|
||||
4. **Невозможна горизонтальная масштабируемость → один под = SPOF.** Память — источник правды, Redis — write-through-кэш одной реплики. Две реплики разъедутся (каждая со своей `map`, last-write-wins затирает). Значит сервис принципиально single-instance: нет HA, рестарт = простой + окно потери данных.
|
||||
|
||||
5. **Асинхронная запись в Redis = окно потери подтверждённых сообщений.** `SendMessage` отвечает клиенту 200, а `HSet` уходит в горутину позже (`redis.go`). Краш пода между append-в-память и async-записью → подтверждённое сообщение потеряно. Для очереди это нарушение durability (AWS SQS персистит до ack).
|
||||
|
||||
6. **`MessageRetentionPeriod` не реализован** (`// TODO - not used`, `models.go`). Сообщения не истекают по времени, растут до пер-очередного лимита, после чего новые `SendMessage` отклоняются `OverLimit`. Нет TTL-эвикции — скрытая деградация.
|
||||
|
||||
7. **Сообщения хранятся плоским слайсом `[]SqsMessage`.** Receive/Delete/visibility-tick — O(N) на операцию; при лимите 120k сообщений и глобальном локе это дорого и усугубляет п.3. Нет индексов по ReceiptHandle/состоянию.
|
||||
|
||||
### 🟡 Средние
|
||||
|
||||
8. **Пересчёт legacy-тенанта в `CreateFromJWT` сиротит очереди** (`tenant_store.go`): очереди остаются под старым AccessKey, новый тенант видит 0 очередей, данные зависают в памяти+Redis навсегда (утечка + «пропажа» данных для пользователя).
|
||||
|
||||
9. **Повсеместные O(total) скан-по-префиксу** под глобальным локом: `countTenantQueues`, `listTenantQueues`, `createTenantQueue` (лимит), `deleteTenant`, gauge-updater. Квадратичная тенденция при росте тенант×очередь.
|
||||
|
||||
10. **Admin-токен сравнивается небезопасно по времени** — `authHeader != expected` (`admin.go`), в отличие от UI-demo-токена (там `ConstantTimeCompare`). Timing-side-channel.
|
||||
|
||||
11. **Хардкод demo-кредов и публичного UI-bypass-токена в исходнике** (`seed.go`, `admin.go`). Сами же комментарии требуют удалить это перед продом. Включается env-флагами, но риск реальный.
|
||||
|
||||
12. **Два расходящихся пути записи/создания.** `admin.createTenantQueue`/`sendMessageToQueue` (`admin.go`) создают очередь и кладут сообщение в обход gosqs-логики: без FIFO-детекта, без проверки размера, без MD5/дедупликации, с другими дефолтами. Поведение UI ≠ поведение SQS API.
|
||||
|
||||
13. **Биллинг: горутина на каждую операцию + пул 5 коннектов** (`billing.go`). Под нагрузкой — навал горутин, ждущих коннект. Плюс `msg_count` всегда 1, даже для `SendMessageBatch` → неточный учёт.
|
||||
|
||||
14. **`/metrics` публичен** и отдаёт per-tenant данные (ID тенантов, число очередей/сообщений) — утечка бизнес-метрик наружу.
|
||||
|
||||
15. **Хрупкое определение протокола** — строго по `Content-Type == application/x-amz-json-1.0` (`router.go`); иные версии JSON-протокола не распознаются.
|
||||
|
||||
16. **Легаси-остатки GoAWS/SNS**: Topics/Subscriptions в `goaws.yaml`, SNS-поля в моделях, `config.go` кладёт yaml-очереди под голым именем `queue.Name` (без tenant-префикса → такие очереди недостижимы tenant-хендлерами; сейчас спасает `Queues: []`).
|
||||
|
||||
---
|
||||
|
||||
# 2. Приоритизированный план доработок
|
||||
|
||||
## P0 — блокеры продакшна (безопасность и durability)
|
||||
|
||||
- **P0.1. Убрать вывод SecretKey из email.** Файлы: `tenant_store.go` (`secretKeyFromEmail`/`accessKeyFromEmail`). Генерировать AccessKey/SecretKey через `crypto/rand` при первом провиженинге и хранить в Redis; идентичность по email держать отдельно от материала ключей. Детерминированным оставить максимум tenantID (не секрет).
|
||||
- **P0.2. Усилить доверие к JWT.** Файлы: `jwt.go`. Либо проверять подпись по JWKS Nubes, либо брать email/ sub **из ответа валидирующего API** (а не из незаверенного payload), чтобы claim нельзя было подменить. Сузить «принять токен, если любой стенд не вернул 401/403» до явного 2xx от доверенного эндпоинта.
|
||||
- **P0.3. Durability для `SendMessage`.** Файлы: `redis.go`, `send_message.go`. Сделать персист сообщения синхронным до ответа 200 (как уже сделано для delete), либо явно задокументировать «best-effort» семантику и снять претензию на SQS-совместимость по надёжности.
|
||||
|
||||
## P1 — архитектурная зрелость
|
||||
|
||||
- **P1.1. Разнести блокировку.** Файлы: `globals.go`, все gosqs-хендлеры. Перейти на per-queue mutex (map под коротким RLock + своя блокировка в `Queue`). Снимает главный боттлнек и O(N)-скан под глобальным локом.
|
||||
- **P1.2. Индексировать сообщения.** Файлы: `models.go`. Хранить `map[receiptHandle]` / структуры готовности, чтобы receive/delete/visibility были не O(N).
|
||||
- **P1.3. Реализовать `MessageRetentionPeriod`.** Файлы: `gosqs.go` (`processQueueTick`). Эвиктить просроченные сообщения по retention в периодической задаче + чистить Redis.
|
||||
- **P1.4. Индекс тенанта → его очереди.** Файлы: `tenant_helpers.go`, `admin.go`. Держать `map[accessKey]set(queueName)` — убрать O(total) скан-по-префиксу в create/list/delete/gauge.
|
||||
- **P1.5. Убрать demo-bypass из прод-сборки.** Файлы: `seed.go`, `admin.go`. Вынести за build-tag или удалить ветку `authenticateUIDemoToken` и фиксированные креды.
|
||||
|
||||
## P2 — надёжность, корректность, чистка
|
||||
|
||||
- **P2.1. BYOC Admin API** (уже TODO в `byoc-credentials.md`). Добавить `access_key/secret_key` в `createTenantRequest` + ветку на `CreateFixed` в `admin.go`. `generateTenantID` экспортировать из tenant-пакета.
|
||||
- **P2.2. Чинить сиротство очередей при пересчёте legacy-тенанта** — `tenant_store.go`: либо мигрировать ключи очередей на новый AccessKey, либо запретить смену ключей.
|
||||
- **P2.3. Constant-time сравнение admin-токена** — `admin.go`.
|
||||
- **P2.4. Единый путь создания/отправки** — admin-хендлеры должны переиспользовать gosqs-логику (FIFO/размер/MD5/дедуп), а не дублировать с иными дефолтами.
|
||||
- **P2.5. Биллинг:** батч-вставки через воркер/буфер вместо горутины-на-операцию; корректный `msg_count` для batch (`billing.go`, `router.go` actionHandler).
|
||||
- **P2.6. Защитить/урезать `/metrics`** (auth или internal-only), чтобы не светить per-tenant данные.
|
||||
- **P2.7. Чистка легаси:** убрать SNS Topics/Subscriptions из `goaws.yaml`, мёртвый путь загрузки yaml-очередей без tenant-префикса в `config.go`, SNS-поля в моделях, `deployments/**`.
|
||||
- **P2.8. Redis reconnect/health** — сейчас после обрыва `Client` остаётся не-nil, записи молча падают в лог (`redis.go`).
|
||||
|
||||
---
|
||||
|
||||
# 3. Противоречия док ↔ код
|
||||
|
||||
1. **Идентичность JWT-тенанта.** `tenant-isolation.md` утверждает: тенант создаётся из claim **`sub`**, TenantID = `SHA256(sub) → "sless-{16hex}"`, индекс **`bySub`/`GetBySub`**. Код: идентичность по **`email`**, TenantID = `t-{16hex}` из email, индекс **`byEmail`/`GetByEmail`** (`tenant_store.go`). Доковое описание полностью устарело.
|
||||
2. **Генерация SecretKey.** Док: «SecretKey = 32 random bytes». Код: для email-тенантов SecretKey **детерминированный** `SHA256(email+соль)` — не случайный.
|
||||
3. **Версии (дрейф).** README deploy-примеры: `v0.1.25`; раздел «История» в README — до `v0.1.14`; `HANDOFF` — прод `v0.1.35`. Три разных значения.
|
||||
4. **`NUBES_ENDPOINT`.** README: дефолт `https://deck-api.ngcloud.ru/api/v1`. Код: стенды `https://lk-api-gateway*.ngcloud.ru/api/v1/svc` (`jwt.go`). Разные хосты/пути.
|
||||
5. **BYOC статус.** `byoc-credentials.md` честно помечает Admin API/UI как TODO — **совпадает** с кодом (в `createTenantRequest` только `Name`, `MaxQueues`). Это не противоречие, а подтверждение открытого вопроса №1.
|
||||
6. **Метрики.** `billing-and-metrics.md`: пример `http://localhost:9090/metrics`, тогда как сервис слушает `4100` и отдаёт `/metrics` на основном порту (`router.go`). Также ссылка на `deployments/k8s/vmservicescrape.yaml`, помеченный в README как легаси «не использовать».
|
||||
7. **Дерево в README** перечисляет `auth/ — AWS Signature V4 верификация`, но не упоминает JWT-путь, `billing/`, `metrics/`. Неполно относительно фактической структуры.
|
||||
8. **`/metrics → PUBLIC`** в tenant-isolation.md соответствует коду, но сам факт публичности per-tenant метрик в доке не отмечен как риск.
|
||||
|
||||
---
|
||||
|
||||
> Заключительная реплика Opus: «Если нужно — могу превратить это в файл-отчёт в
|
||||
> doc/thinking/ или углубиться в любой из пунктов (например, расписать
|
||||
> PoC-эксплуатацию P0.1/P0.2 или конкретный дизайн per-queue-локов). Код я не
|
||||
> трогал, как и требовалось.»
|
||||
@@ -0,0 +1,231 @@
|
||||
# Код-ревью shared-sqs (Opus, 2026-10-10)
|
||||
|
||||
> Ответ Opus на промпт «сделай код-ревью и посоветуй по архитектуре».
|
||||
> Дословно, без правок.
|
||||
|
||||
Все файлы из списка прочитаны. Строки взяты из листингов, часть пунктов опирается на прочитанное ранее. Пункты, которые стоит перепроверить перед правкой, помечены «проверить». Теги [Ф1–Ф6] указывают на ваши фокусные области.
|
||||
|
||||
## 🔴 Критично: потеря данных, падение процесса, обход безопасности
|
||||
|
||||
1. **[Ф1] Fatal `concurrent map iteration and map write` при повторном CreateQueue.** `create_queue.go:84` вызывает `SaveQueue` для существующей очереди под глобальным локом, но без `queue.mu`. `SaveQueue` маршалит `Duplicates`, `FIFO*` и `Tags`, а `send_message` пишет в них под `queue.mu`. Это не panic, а fatal runtime, который `recover` не ловит и который убивает весь процесс. Идемпотентный CreateQueue — частый вызов SDK. Тот же класс ошибки: `admin.go:498-535` и `seed.go`.
|
||||
2. **[Ф1] `list_queue_tags.go:49-60` отдаёт в ответ ссылку на `queue.Tags`.** `RUnlock` срабатывает по `defer` при return, а XML-маршалинг идёт позже, в router. Параллельный `TagQueue` даёт тот же fatal. Нужна копия мапы.
|
||||
3. **[Ф1] Инверсия порядка локов и дедлок через ожидающего писателя.**
|
||||
- `queue.mu` → `SyncQueues.RLock`: `set_queue_attributes.go:48,63` → `queue_attributes.go:78`.
|
||||
- `SyncQueues.RLock` → `queue.RLock`: скан в `admin.go:435-486` (подтверждено), `gauge_updater` (проверить, держит ли глобальный RLock).
|
||||
- Любой писатель в `SyncQueues` (`create_queue.go:73`, `delete_queue.go:45`, admin) блокирует новых читателей Go RWMutex. Получается цикл, и сервис встаёт целиком.
|
||||
- Отдельно: `models.go:410` — ABBA и self-дедлок на DLQ (цикл A→B→A или DLQ=self не отсекается в `queue_attributes.go:85`).
|
||||
- `conf/config.go:126-134` — `return` без `Unlock` и дедлок на старте.
|
||||
4. **[Ф1, Ф3] Redis-I/O под глобальным локом.** `delete_queue.go:45-56` выполняет синхронный `DeleteQueue` под `SyncQueues.Lock`. Комментарии «асинхронно» (`:24,55`) устарели. Redis-пауза 3–5 с останавливает все тенанты. То же в `admin.go:498` и `gosqs.go:107`.
|
||||
5. **[Ф3] Подтверждённое сообщение теряется при сбое Redis.**
|
||||
- `send_message.go:157` и `send_message_batch.go:133-139,153-158`: при ошибке `SaveMessage(s)Sync` откатывается только `RemoveIndex`.
|
||||
- `Duplicates` и `FIFOSequenceNumbers` не откатываются, `InitDuplicatation` уже сработал.
|
||||
- Ретрай клиента с тем же dedupId получает 200 без сохранения.
|
||||
- Таймаут Redis неоднозначен: запись могла пройти, и после рестарта появится фантом.
|
||||
6. **[Ф3] Receive, visibility-return и DLQ-перенос не персистятся.**
|
||||
- Затронуты `gosqs.go:124-154`, `change_message_visibility*.go`, `receive_message.go`.
|
||||
- DLQ-копия не пишется (`models.go:409-413`), а `change_message_visibility.go:72-81` делает только Delete в источнике.
|
||||
- После рестарта сообщение воскресает в источнике и пропадает из DLQ.
|
||||
- Порядок при загрузке теряется: `redis.go:504-513` итерирует HGETALL без сортировки, FIFO ломается.
|
||||
7. **[Ф1, Ф2] Зависание FIFO-групп навсегда.**
|
||||
- `ResetVisibilityAndTryDLQ` (`models.go:389-417`) не делает `UnlockGroup`, путь из `change_message_visibility.go:64` и batch-версии.
|
||||
- `purge_queue.go:63-64` сбрасывает `Duplicates`, но не `FIFOMessages`.
|
||||
- После рестарта `FIFOMessages` из Redis (`redis.go:163,448`) не согласован с in-flight, а тик снимает лок только по `receiptIdx`.
|
||||
8. **DeleteMessageBatch с пустым `ReceiptHandle` удаляет все неполученные сообщения.**
|
||||
- `delete_message_batch.go:79-97` сопоставляет handle сканом. У невыданных сообщений handle `""` и совпадает с записью `""`.
|
||||
- Query-парсер (`requests.go:568`) отсекает пустой handle, JSON-протокол — нет.
|
||||
- Одиночный delete безопасен: индекс пропускает `""`.
|
||||
9. **[Ф4] HA — это не HA.**
|
||||
- `leader.go:106-126`: ошибка Refresh не демотирует лидера, lease-проверки нет.
|
||||
- `goaws.go:171-191`: пассив выполняет `PeriodicTasks` и пишет в Redis. Handlers лидерство не проверяют, защита — только `/ready`.
|
||||
- При повышении пассива состояние из Redis не перечитывается: он работает со снимком своего старта.
|
||||
- Без Redis `TryAcquireLock` и `RefreshLock` возвращают true (`redis.go:666-715`), каждый инстанс становится «лидером».
|
||||
- Нет fencing-токена.
|
||||
- `redis.go:61-85`: при неудачном Connect клиент остаётся nil навсегда, reconnect не запускается.
|
||||
10. **[Ф3, Ф4] Старт с пустым состоянием.** `goaws.go:103-106,125-134` при недоступном Redis или ошибке `LoadAllQueues` продолжает работу. Следом `SaveQueue` перезапишет Redis пустотой. Нужен fail-fast.
|
||||
11. **[Ф6] Тело подменяется при валидной подписи.** `auth_middleware.go:166-169,234-243` берёт `X-Amz-Content-Sha256` из заголовка и не сверяет с реальным хешем тела.
|
||||
12. **[Ф6] JWT без проверки подписи.**
|
||||
- `jwt.go`: идентичность берётся из непроверенного payload, `Exp==0` пропускается.
|
||||
- Валидность подтверждает GET на любой из трёх шлюзов, включая dev и test.
|
||||
- `tenant_store.go:213-291`: тенант находится по email из такого токена, возможен захват чужого тенанта.
|
||||
- `cache.go:68-75`: при ошибке сети grace продлевает отозванный токен.
|
||||
13. **[Ф3] Удалённый тенант воскресает.** `redis.go:594-609` `DeleteTenant` не чистит `ssq:tenant_ak`, а read-through в auth (`:63-74`) его возвращает. Найденный тенант кэшируется навсегда.
|
||||
14. **[Ф5] OOM-потолок на уровне процесса.**
|
||||
- Лимит только по числу сообщений: 120k × 256 КБ = ~30 ГБ на одну очередь (`validation.go:43-44`).
|
||||
- Лимитов по байтам или памяти нет ни на очередь, ни на тенанта, ни глобально.
|
||||
- Размер атрибутов не входит в проверку (`MaxMessageAttributeSize` объявлен и не используется).
|
||||
- Один тенант валит процесс всем.
|
||||
15. **[Ф3] `redis.go:33,116-133`: `Client` глобален, `reconnect()` подменяет его без синхронизации.** Это data race, старый клиент не закрывается. go-redis и так переподключается сам, `reconnect()` не нужен.
|
||||
|
||||
## 🟡 Существенно
|
||||
|
||||
**Гонки и конкурентность**
|
||||
- **[Ф1]** `globals.go:77-100`: `GetLocked` отпускает глобальный лок до `LockMu`, и после взятия лока нет проверки, что очередь ещё в map.
|
||||
- Запись в удалённую очередь возвращает 200, а сообщение пропало.
|
||||
- `SaveMessagesSync` пишет `ssq:msg:{key}` уже после `DeleteQueue`, это осиротевшие ключи в Redis.
|
||||
- Аналог: `send_message_batch.go:46-53` → `:105`.
|
||||
- **[Ф3]** `redis.go:215-236`: seq-guard проверяется до HSET (TOCTOU). `:394-416`: проверка seq сразу после инкремента — тавтология, запущенная async-`SaveQueue` воскрешает метаданные после `HDel`. Карты seq растут вечно (`:44-46`).
|
||||
- **[Ф3]** `purge_queue.go:68`: синхронный Redis под `queue.mu`, ошибка не возвращается, память очищена, а Redis нет. Ранее запущенные async `SaveMessage` могут записать сообщение после DEL.
|
||||
|
||||
**Tombstone и индексы**
|
||||
- **[Ф2]** `models.go:201-208`: `ensureIndexes` тавтологичен. Проверка `liveCount == len-deadCount` не ловит рассинхрон map-индексов.
|
||||
- **[Ф2]** `models.go:262-283`: `RemoveIndex` не обнуляет слот, тело сообщения живёт до компакции. Порог 1024 слота или половина — до 256 МБ мусора на очередь.
|
||||
- **[Ф2]** `RebuildIndexes` на старте разреженный, после загрузки `deadCount=0`: компакции нет, но и мусора нет. Это нормально, если порядок восстановлен сортировкой (см. 🔴6).
|
||||
- `models.go:113-141`: `FIFOSequenceNumbers` и `FIFOMessages` растут без чистки.
|
||||
- `models.go:172-180`: запись в nil-map при `Duplicates==nil`.
|
||||
|
||||
**Семантика SQS**
|
||||
- Dedup:
|
||||
- `delete_message.go:59` и `delete_message_batch.go:93` делают `delete(Duplicates)` при удалении, окно 5 мин нарушено.
|
||||
- `requests.go:422-423`: `ContentBasedDeduplication` принимается и игнорируется, FIFO без dedupId не дедуплицируется.
|
||||
- Дубль возвращает новый MessageId.
|
||||
- Receive и счётчики:
|
||||
- `Retry++` на таймауте видимости (`gosqs.go:142`): `MaxReceiveCount` считается по таймаутам.
|
||||
- `receive_message.go:203,247`: `ApproximateReceiveCount` завышен на 1.
|
||||
- `ReceiptTime` перезаписывается, `FirstReceiveTimestamp` неверен.
|
||||
- `models.go:405`: `ChangeMessageVisibility(0)` скрывает сообщение ещё на `q.VisibilityTimeout`, а должно быть видно сразу (проверить по receive).
|
||||
- `change_message_visibility_batch.go:72`: нет проверки отрицательных значений.
|
||||
- `queue_attributes.go:41-70`: 0 означает «не передан», и поэтому нельзя вернуть `DelaySeconds`, `ReceiveMessageWaitTimeSeconds` или `VisibilityTimeout` в 0.
|
||||
- `requests.go:35-37,30`: невалидные значения и пустая строка молча игнорируются вместо ошибки.
|
||||
- `queue_attributes.go:73-87`:
|
||||
- нет проверки self-ссылки, цикла, совпадения FIFO-типа и диапазона `maxReceiveCount`;
|
||||
- `DeadLetterQueueName` здесь не выставляется (проверить, заполняет ли его `SaveQueue`, иначе DLQ теряется после рестарта);
|
||||
- `delete_queue.go:53` не отвязывает DLQ-ссылки других очередей.
|
||||
- `create_queue.go:73-85`: повторный CreateQueue с другими атрибутами возвращает 200 вместо `QueueNameExists`.
|
||||
- `create_queue.go:66`, `set_queue_attributes.go:59`: точное сравнение Content-Type. Для `json-1.1` или с `charset` whitelist обходится.
|
||||
- Batch:
|
||||
- `send_message_batch.go:87-103` валидирует пакет целиком, вместо per-entry `Failed`.
|
||||
- Нет суммарного лимита 256 КБ.
|
||||
- Per-message `DelaySeconds` не применяется (`:118-127`, проверить паритет с single).
|
||||
- `delete_message_batch.go:65-72`: дубль handle теряет Id из ответа.
|
||||
- `delete_message_batch.go:79` — O(N) скан вместо `IndexOfReceipt`, при том что `change_message_visibility_batch.go:84` индекс использует.
|
||||
- Код ошибки `"1"`, порядок ответа недетерминирован.
|
||||
- Query-парсеры (`requests.go:235,568,602`) молча обрезают пакет на первой пустой записи.
|
||||
- `get_queue_attributes.go:89-100`:
|
||||
- `Approximate…` включает in-flight и delayed;
|
||||
- timestamp-заглушки `"0000000000"`;
|
||||
- нет `FifoQueue`, `ContentBasedDeduplication`, `Policy`, `…Delayed`, что ломает Terraform и мониторинг.
|
||||
- `list_queues.go:34-43`: скан всех очередей сервиса под глобальным RLock при готовом tenant-индексе. Пагинации нет (`MaxResults` и `NextToken` игнорируются), порядок случайный.
|
||||
- `tag_queue.go:56-60`: нет лимитов на теги (AWS: 50 шт., ключ 128, значение 256). Теги сериализуются на каждый `SaveQueue`, это DoS-вектор.
|
||||
|
||||
**Производительность [Ф5]**
|
||||
- `receive_message.go:164-216`: два полных скана под write-локом. Long-poll опрашивает каждые 100 мс с эксклюзивным локом на каждого поллера.
|
||||
- `SaveQueue` на каждый send, delete и CMV (`send_message.go:159`, `delete_message*.go`, `change_message_visibility_batch.go:117`): O(Duplicates + FIFO + Tags) под локом.
|
||||
- `gosqs.go:50-54`: один горутин-тик на все очереди последовательно, с синхронным Redis (`:107`).
|
||||
- `redis.go:422-543`:
|
||||
- `LoadAllQueues` грузит всё последовательно, общий таймаут 30 с;
|
||||
- ошибка очереди → `continue`, очередь грузится пустой;
|
||||
- время старта и RAM пропорциональны полному состоянию.
|
||||
|
||||
**HA и жизненный цикл [Ф4]**
|
||||
- `router.go:214-228`: `/ready` не учитывает Redis, загрузку состояния и shutdown. `ReadyFunc` — глобальная переменная.
|
||||
- `goaws.go:248-268`:
|
||||
- `billing.Close()` идёт до дренажа HTTP;
|
||||
- async Redis-записи не дренируются;
|
||||
- не-ready на время дренажа нет;
|
||||
- `log.Fatalf` в `:245` не отпускает лок;
|
||||
- `leader.go:63-76`: `Stop` освобождает лок до дренажа.
|
||||
- `goaws.go:204-211`: нет `ReadTimeout` (медленное тело), лимитов соединений и rate-limit. Тело читается целиком до проверки подписи.
|
||||
|
||||
**Безопасность [Ф6]**
|
||||
- SigV4:
|
||||
- `auth_middleware.go:296-326` не требует подписи `host`, `x-amz-date`, `x-amz-target`;
|
||||
- `:158` clock skew 15 мин (в AWS — 5);
|
||||
- `:215` presigned не проверяет дату из будущего;
|
||||
- `:290` `canonicalURI` зависит от нормализации nginx и заголовка Host;
|
||||
- превышение лимита тела даёт 403 `SignatureDoesNotMatch`, а не 413 (`router.go:124-133`);
|
||||
- rate-limit по неудачным попыткам нет, ошибки раскрывают причину (`:81`).
|
||||
- `auth_middleware.go:63-74`: каждый запрос с неизвестным accessKey ходит в Redis (HGET до 3 с) без негативного кэша. `jwt.go:90-133` — до трёх последовательных запросов по 10 с на токен, без singleflight.
|
||||
- `admin.go`:
|
||||
- статичный Bearer без rate-limit;
|
||||
- `:223` `jwtMiddleware` пропускает путь по `HasSuffix("/auth")`;
|
||||
- `:263-267` IDOR-защита только если в `mux.Vars` есть id;
|
||||
- `:409-420` `deleteTenant`: in-flight handlers пишут в удалённые очереди;
|
||||
- `:194,207-212` токен и `SecretKey` отдаются в теле ответа;
|
||||
- `:640-669` `sendMessageToQueue` без лимита `MaxMessagesForQueue` и с async-сохранением.
|
||||
- `tenant_store.go`: секреты в Redis открытым текстом. Нет проверки уникальности email (`:58-108`), `MaxQueues` перезаписывается при каждом логине (`:240`), `LoadTenant` затирает свежие правки (`:194-202`).
|
||||
- `router.go:72-74`: маршруты `/{account}` и `/{account}/{queueName}` перекрывают `/admin/*` и `/health` для POST. Bypass в auth по этим путям fail-open, но nil-проверка tenant присутствует во всех 17 просмотренных handlers.
|
||||
- `goaws.go:148-150`: при `SEED_DEMO` креды демо-тенанта пишутся в лог.
|
||||
|
||||
**Прочее**
|
||||
- `router.go:250-277`: `countBatchEntries` работает только с form, для JSON-протокола возвращает 0 и биллинг батчей занижен. `:297` `msgBytes` — размер запроса, а не ответа.
|
||||
- `billing.go`:
|
||||
- канал 1024, переполнение дропает записи;
|
||||
- `db.Exec` без таймаута;
|
||||
- `Close()` обнуляет `db` без синхронизации с воркером (data race);
|
||||
- DSN собирается через Sprintf.
|
||||
- `metrics`:
|
||||
- label tenant: accessKey в gauge и `tenant.ID` в counters, accessKey утекает в метрики;
|
||||
- `Reset()` gauge даёт окно пустых значений;
|
||||
- label не чистится при удалении тенанта.
|
||||
- `conf/config.go`:
|
||||
- `:111` yaml-очереди кладутся без префикса `accessKey:` и обходят tenant-изоляцию;
|
||||
- `:189-196` при пустой RedrivePolicy `queues[deadLetterQueueName]` ищется без tenant-префикса.
|
||||
- `seed.go`: ARN собирается с `t.ID` вместо `AccountID`, очереди без FIFO/DLQ-полей, `SaveMessages` асинхронный.
|
||||
|
||||
## 🟢 Мелочи
|
||||
|
||||
- Расхождение дефолтов:
|
||||
- `build.go:19-32` (admin) задаёт `VisibilityTimeout`, `MaxSize`, `Retention` напрямую;
|
||||
- `CreateQueueV1` берёт их из `NewCreateQueueRequest` по yaml;
|
||||
- при нулях в yaml пути разойдутся.
|
||||
- Мёртвый код и дубли:
|
||||
- `validation.go`: `ClampInt`, `ValidateMessageAttributes`, `MaxRequestBodySize=1MB` (router — 3 МБ), `MaxTenantsDefault` дублирует `MaxTenantsGlobal`;
|
||||
- `AvailableQueueAttributes` и `AttrNameWhitelist` — два списка;
|
||||
- `DeduplicationPeriod` — `var`;
|
||||
- `send_message.go:73` — мёртвая ветка `mux.Vars`.
|
||||
- `BaseResponseMetadata.RequestId` — нулевой UUID во всех ответах: корреляция невозможна.
|
||||
- `models/helpers.go`: `rand.Intn(max-min)` паникует при `max<min`; `Sprintf` и `ParseDuration` на каждый вызов; `IsReadyForReceipt` пересчитывает latency на каждый вызов.
|
||||
- Устаревшие комментарии: `redis.go:1-13`, `send_message.go:38`, `goaws.yaml` (retention «not implemented»).
|
||||
- INFO-логи на каждый вызов: `delete_message.go:43` (с ReceiptHandle), `get_queue_attributes.go:64`, `list_queues.go:30`.
|
||||
- `models/errors.go`:
|
||||
- `delete_message.go:48` возвращает `MessageDoesNotExist` при отсутствии очереди;
|
||||
- `InvalidAttributeValue` и `MissingParameter` имеют жёсткий текст сообщения;
|
||||
- `QueueExists` не используется.
|
||||
- `tenant_helpers.go` и `configuration.go` не прогнаны через gofmt. Имя очереди берётся из последнего сегмента URL, `tenantID` в пути игнорируется.
|
||||
- Версия `xml.Marshal` с `http.Error` после `WriteHeader` (`router.go:175-184`).
|
||||
|
||||
## Архитектура: потолок и вынос в Redis (вариант A)
|
||||
|
||||
**Потолок сегодня.** Он определяется тремя вещами:
|
||||
- RAM процесса при отсутствии лимита по байтам;
|
||||
- время старта: полная загрузка последовательным HGETALL;
|
||||
- последовательный тик и O(N)-сканы в receive и gauge.
|
||||
|
||||
Ориентировочно потолок — десятки тысяч сообщений на горячую очередь. Измерить p99 receive при 100k in-flight и время `LoadAllQueues` на проде.
|
||||
|
||||
**Что Redis не гарантирует.** «Синхронно» в коде означает «принято Redis в память», а не fsync и не репликация. Redis на той же ВМ — общий SPOF. Для durability нужны `appendfsync always` или `everysec` с осознанной потерей до 1 с, либо `WAIT`, и отдельный реплицируемый Redis.
|
||||
|
||||
**Порядок перехода (по риску):**
|
||||
1. **Сначала дёшево, без смены архитектуры:**
|
||||
- сделать `SaveQueue` синхронным или write-behind с batch;
|
||||
- вынести FIFO-lock из сообщений в метаданные и персистить;
|
||||
- сортировать при загрузке (по seq или `SentTime`);
|
||||
- персистить receive, visibility и DLQ;
|
||||
- fail-fast при старте без Redis;
|
||||
- лимиты по байтам и по тегам.
|
||||
2. **Починить конкурентность:**
|
||||
- единый порядок локов (глобальный до per-queue, не наоборот);
|
||||
- убрать I/O под глобальным локом;
|
||||
- проверка живости очереди после `LockMu`;
|
||||
- копии при отдаче map в ответ.
|
||||
3. **Интерфейс `QueueStore`** поверх `SyncQueues`. Сейчас handlers лезут в `Messages`, индексы и `Duplicates` напрямую, поэтому без интерфейса вынос невозможен.
|
||||
4. **Redis как источник истины:**
|
||||
- Lua-скрипты (или Streams) на send, receive, delete, change-visibility, DLQ-перенос;
|
||||
- visibility через ZSET по времени;
|
||||
- dedup через ключ с TTL;
|
||||
- FIFO-групп-лок как ключ с lease.
|
||||
5. **Sweeper вместо `PeriodicTasks`**, под отдельным локом и с fencing-токеном.
|
||||
6. **Снять leader-lock.** До этого этапа HA остаётся «один активный, пассив безопасен только за `/ready`». Для безопасного active/passive нужны fencing-токен, перезагрузка состояния при повышении, запрет записи без лидерства и lease-проверка на лидере.
|
||||
|
||||
**Главный риск варианта A** — сетевой round-trip на каждую операцию и сложность FIFO и DLQ в Lua. Дешёвая промежуточная ступень — шардирование по тенантам (вариант B) с оставленным in-memory ядром. Оно не даёт HA, но снимает потолок по RAM и тику.
|
||||
|
||||
## Порядок исправления
|
||||
|
||||
1. Fatal-гонки: п. 1–2 из 🔴.
|
||||
2. Lock order и I/O под глобальным локом: п. 3–4.
|
||||
3. Откат dedup при сбое Redis: п. 5.
|
||||
4. Пустой handle в DeleteMessageBatch: п. 8.
|
||||
5. Подпись, тело и JWT: п. 11–12.
|
||||
6. Fail-fast на старте и lease у лидера: п. 9–10.
|
||||
7. Лимит по байтам: п. 14.
|
||||
8. Персист receive, visibility и DLQ вместе с порядком при загрузке: п. 6–7.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Критический разбор код-ревью Opus (2026-10-10)
|
||||
|
||||
> Дополняет `opus-report-2026-10-10.md` (дословный отчёт Opus).
|
||||
> Здесь — проверка ключевых пунктов по коду + что подтверждено/отклонено + решения, которые нужны.
|
||||
|
||||
## 1. Вердикт
|
||||
|
||||
Отчёт сильный по конкуренции и durability. Главный дефект отчёта — смешивает три категории
|
||||
без разделения: **(а)** реальные баги, **(б)** осознанный дизайн (JWT trusted-perimeter),
|
||||
**(в)** отклонения от семантики AWS (в основном косметика). Часть 🔴 надо понизить.
|
||||
|
||||
## 2. Подтверждено по коду (критично, чинится без владельца)
|
||||
|
||||
### 2.1. 🔴1 — фатал `concurrent map iteration and map write` в CreateQueue
|
||||
- `create_queue.go:73-84`: ветка «очередь уже существует» вызывает `SaveQueue(key, queue)`
|
||||
под `SyncQueues.Lock`, **без** `queue.mu`.
|
||||
- `SaveQueue` → `json.Marshal` (`redis.go:208,220`) итерирует `Duplicates`, `FIFOSequenceNumbers`,
|
||||
`FIFOMessages`, `Tags`.
|
||||
- Мутаторы этих map (send/tag/CMV) держат только `queue.mu` (`GetLocked` берёт глобальный RLock
|
||||
на миг и отпускает — `globals.go:77-88`), поэтому глобальный Lock их не блокирует.
|
||||
- Условие: идемпотентный `CreateQueue` существующей FIFO-очереди (обычный вызов SDK при старте)
|
||||
+ параллельный `SendMessage`. Вероятность растёт с размером map. Необратимый краш процесса
|
||||
(`recover` не ловит fatal).
|
||||
- Шапка `redis.go:211-213` («вызывать под `SyncQueues.Lock`») описывает неверный контракт.
|
||||
|
||||
### 2.2. 🔴5 — неполный откат dedup при сбое Redis
|
||||
- `send_message.go:151-160`: при ошибке `SaveMessageSync` откатывается только `RemoveIndex`,
|
||||
а `InitDuplicatation` выполняется всё равно → ретрай с тем же dedupId даёт 200, но сообщение
|
||||
не сохранено. Аналог в `send_message_batch.go`.
|
||||
- `FIFOSequenceNumbers` (инкремент в `models.go:117`) тоже не откатывается.
|
||||
|
||||
### 2.3. 🔴8 — пустой `ReceiptHandle` в DeleteMessageBatch (protocol-gated)
|
||||
- Срабатывает **только** при `Content-Type: application/x-amz-json-1.0`: тело декодируется
|
||||
напрямую (`utils.go:35-46`), валидация `receiptHandle == ""` (`requests.go:568`, Query-форма)
|
||||
на этом пути не вызывается.
|
||||
- `delete_message_batch.go:51-57` проверяет только уникальность `Id`, handle не валидируется.
|
||||
- `deleteMessageMap[""]` совпадает с невыданными сообщениями (у них `ReceiptHandle == ""`),
|
||||
цикл `:79-98` удаляет все такие сообщения очереди.
|
||||
- Одиночный `DeleteMessage` безопасен: пустой handle не кладётся в `receiptIdx`
|
||||
(`models.go:233,251,373`).
|
||||
|
||||
### 2.4. Дополнительно подтверждено (не тестировалось, статический разбор)
|
||||
- Инверсия порядка локов (`queue.mu` ↔ `SyncQueues.RLock`) и Redis-I/O под глобальным локом
|
||||
(`delete_queue.go:45-56`).
|
||||
- Fail-fast отсутствует: старт с недоступным Redis продолжает работу и следом `SaveQueue`
|
||||
перезаписывает Redis пустотой (`goaws.go:103-106,125-134`).
|
||||
|
||||
## 3. Решение владельца: JWT
|
||||
|
||||
`jwt.go:7-11` прямо документирует trusted-perimeter: «Подпись JWT НЕ проверяется — аналогично
|
||||
sless», валидация через `PingNubesAPI` (GET на 3 шлюза, приём по 2xx).
|
||||
|
||||
**Ключевой факт, который не проверен:** проверяет ли шлюз подпись и отдаёт 200 только для
|
||||
валидных токенов? От этого зависит классификация:
|
||||
- шлюз проверяет подпись → «доверяем шлюзу», не уязвимость;
|
||||
- нет → 🔴, чинить первым делом.
|
||||
|
||||
Независимо от ответа чинятся три вещи:
|
||||
1. `jwt.go:90-92` — три шлюза (prod/test/dev): токен тестового стенда принимается как prod.
|
||||
Оставить prod-шлюз.
|
||||
2. `cache.go:68-75` — grace при сетевой ошибке продлевается повторно → отозванный токен живёт
|
||||
бесконечно. Нужен потолок суммарного grace (например, 2 мин от последней успешной проверки).
|
||||
3. `tenant_store.go:213-291` — идентичность по email; привязать к стабильному `sub`/`iss`,
|
||||
иначе смена email на IdP отдаёт чужой тенант.
|
||||
|
||||
## 4. Решение владельца: лимит по байтам (OOM, 🔴14)
|
||||
|
||||
Сейчас лимит только по числу сообщений (`validation.go:43-44`), по байтам/памяти — нет.
|
||||
Нужны входные данные владельца: RAM ВМ и целевое число тенантов.
|
||||
|
||||
Структура (рекомендация):
|
||||
- **Cap на тенанта** (суммарные байты тел+атрибутов по всем очередям) — изоляция.
|
||||
- **Cap на очередь** — производный (доля тенантского лимита).
|
||||
- **Глобальный предохранитель** — отказ send при RSS > порога (напр. 70% RAM), единственная
|
||||
защита от суммы тенантов.
|
||||
|
||||
Реализация — счётчик байт в `Queue` (append/delete/purge/compact/restore) + тенантский
|
||||
аккумулятор → требует `QueueStore`-интерфейса (план A).
|
||||
|
||||
Минимальный шаг без решения по квотам: снизить `MaxMessagesPerQueue` + глобальный
|
||||
предохранитель по RSS.
|
||||
|
||||
## 5. Порядок исправления (после решений владельца)
|
||||
|
||||
1. 🔴1 — убрать `SaveQueue` без `queue.mu` в `create_queue.go:84` (и `admin.go`/`seed.go`).
|
||||
2. 🔴8 — валидация `ReceiptHandle != ""` в `delete_message_batch.go`.
|
||||
3. 🔴5 — полный откат dedup/FIFO-seq при сбое `SaveMessage(s)Sync`.
|
||||
4. Lock order + I/O под глобальным локом.
|
||||
5. Fail-fast на старте без Redis.
|
||||
6. JWT (после решения п.3) + лимит по байтам (после п.4).
|
||||
@@ -0,0 +1,116 @@
|
||||
# Промпт для Opus — анализ архитектуры shared-sqs (2026-10-05)
|
||||
|
||||
> Назначение: дать модели Opus контекст и точный список файлов для чтения,
|
||||
> чтобы она проанализировала архитектуру shared-sqs и выдала идеи
|
||||
> архитектуры (в т.ч. «КРАКО» — критический разбор архитектуры) без чтения
|
||||
> лишних файлов.
|
||||
|
||||
---
|
||||
|
||||
## ЗАДАЧА
|
||||
|
||||
Проанализируй архитектуру сервиса `shared-sqs` (SQS-совместимая очередь, Go,
|
||||
мультитенантная, форк GoAWS) и выдай:
|
||||
|
||||
1. **Критический разбор архитектуры (КРАКО)** — слабые и сильные места,
|
||||
риски, узкие места, что мешает назвать сервис готовым к продакшну.
|
||||
2. **Идеи по архитектуре** — как довести сервис до production-ready:
|
||||
приоритизированный список доработок (P0/P1/P2) с обоснованием.
|
||||
|
||||
Не меняй код. Только читай и анализируй.
|
||||
|
||||
## ЧТО ЧИТАТЬ (allowlist — ТОЛЬКО эти файлы, ничего больше)
|
||||
|
||||
**Точка входа и роутинг:**
|
||||
- `app/cmd/goaws.go`
|
||||
- `app/cmd/seed.go`
|
||||
- `app/router/router.go`
|
||||
|
||||
**Конфигурация:**
|
||||
- `app/conf/config.go`
|
||||
- `app/conf/goaws.yaml`
|
||||
|
||||
**SQS-реализация (ядро):**
|
||||
- `app/gosqs/gosqs.go`
|
||||
- `app/gosqs/receive_message.go`
|
||||
- `app/gosqs/send_message.go`
|
||||
- `app/gosqs/create_queue.go`
|
||||
- `app/gosqs/validation.go`
|
||||
- `app/gosqs/tenant_helpers.go`
|
||||
|
||||
**Аутентификация / изоляция тенантов:**
|
||||
- `app/auth/auth_middleware.go`
|
||||
- `app/auth/jwt.go`
|
||||
- `app/auth/cache.go`
|
||||
- `app/tenant/tenant_store.go`
|
||||
|
||||
**Модели данных:**
|
||||
- `app/models/models.go`
|
||||
- `app/models/constants.go`
|
||||
- `app/models/globals.go`
|
||||
|
||||
**Персистентность:**
|
||||
- `app/persistence/redis.go`
|
||||
|
||||
**Биллинг и метрики:**
|
||||
- `app/billing/billing.go`
|
||||
- `app/metrics/metrics.go`
|
||||
|
||||
**Admin API:**
|
||||
- `app/admin/admin.go`
|
||||
|
||||
**Интерфейсы:**
|
||||
- `app/interfaces/interfaces.go`
|
||||
|
||||
**Документация (для контекста состояния):**
|
||||
- `README.md`
|
||||
- `doc/architecture/tenant-isolation.md`
|
||||
- `doc/billing-and-metrics.md`
|
||||
- `doc/byoc-credentials.md`
|
||||
- `HANDOFF-NEW-CHAT.md`
|
||||
|
||||
## ⛔ ГДЕ НЕ ЛАЗИТЬ
|
||||
|
||||
- **Не читать** `doc/legacy/**` (исторический план, старые решения — устарели).
|
||||
- **Не читать** `deployments/**` (легаси k8s/helm).
|
||||
- **Не читать** `doc/thinking/nubes-*` (сетевые тикеты платформы — отдельная
|
||||
задача, не относится к разбору архитектуры кода).
|
||||
- **Не читать** тесты `*_test.go` — они нужны только как сигнал «что покрыто»;
|
||||
для анализа архитектуры достаточно факта наличия из списка `app/gosqs/*`.
|
||||
- Не искать файлы за пределами перечисленного списка.
|
||||
|
||||
## КЛЮЧЕВОЙ КОНТЕКСТ (факты, которые уже установлены — не перепроверять с нуля)
|
||||
|
||||
- **Стек**: Go 1.23, gorilla/mux v1.8.0, redis/go-redis/v9, lib/pq,
|
||||
prometheus/client_golang. Сервис — один Go-бинарник.
|
||||
- **Состояние**: прод v0.1.35, все тесты зелёные, нагрузка 168 591 операция
|
||||
без сервисных ошибок. НО есть известные открытые вопросы (ниже).
|
||||
- **Персистентность**: Redis write-through cache. Память — источник правды
|
||||
для чтения; Redis — для восстановления после рестарта. Записи в Redis
|
||||
асинхронные (кроме DeleteMessagePersist — синхронно).
|
||||
- **Изоляция тенантов**: очередь хранится по ключу `{AccessKey}:{queueName}`;
|
||||
`TenantStore` с тремя индексами (`byID`, `byAccessKey`, `byEmail`).
|
||||
- **JWT-валидация**: подпись НЕ проверяется (trusted perimeter) — только
|
||||
структура + `exp` + `PingNubesAPI()` (проверка token через API Gateway).
|
||||
- **Биллинг**: асинхронная запись в PostgreSQL (таблица `sqs_usage_records`),
|
||||
опционально (включается при `BILLING_PG_HOST`).
|
||||
- **Внешняя проблема (НЕ код, на анализ кода не влияет)**: периодические
|
||||
сетевые таймауты ~31–33с на внешнем шлюзе Nubes + MSS-баг для больших тел.
|
||||
Разобрано в тикете — это задача платформы, не кода.
|
||||
|
||||
## ИЗВЕСТНЫЕ ОТКРЫТЫЕ ВОПРОСЫ (кандидаты в «доделать» — проверь их актуальность по коду)
|
||||
|
||||
1. **BYOC-credentials** — backend `CreateFixed` есть, но Admin API/UI
|
||||
(`POST /admin/tenants` с произвольными ключами) помечен TODO.
|
||||
2. **Подпись JWT не проверяется** — насколько это приемлемо для multi-tenant
|
||||
прод-сервиса, или нужно усилить.
|
||||
3. **Дрейф доков** — версии в README/Makefile/HANDOFF не всегда совпадают.
|
||||
4. **Легаси** — невычищенные следы старого k8s/helm-деплоя и SNS.
|
||||
|
||||
## ФОРМАТ ОТВЕТА
|
||||
|
||||
1. **КРАКО** — короткий критический разбор: что хорошо, что плохо, главные
|
||||
риски (по коду, а не по платформе).
|
||||
2. **Приоритизированный план доработок** (P0/P1/P2), с указанием конкретных
|
||||
файлов и сути правки.
|
||||
3. Список **противоречий/несоответствий** между доками и кодом, если найдёшь.
|
||||
@@ -0,0 +1,88 @@
|
||||
# P1.1 (per-queue mutex) — анализ и состояние (2026-10-06)
|
||||
|
||||
## Зачем нужен
|
||||
|
||||
Сейчас все операции с очередями держат ОДИН глобальный `models.SyncQueues.RWMutex`.
|
||||
Проблемы:
|
||||
1. Любая операция блокирует весь сервис. При этом часть операций делает
|
||||
**синхронный Redis-I/O прямо под глобальным локом** (таймаут до 3–5с):
|
||||
`SaveMessageSync` (send), `DeleteMessagePersist` (delete), `DeleteMessagesPersist`
|
||||
(retention-tick), `PurgeMessagesPersist` (purge), `DeleteQueue`. При деградации
|
||||
Redis весь сервис встаёт на секунды.
|
||||
2. `receiveMessagesUnderLock` держит глобальный лок на время O(N)-скана сообщений.
|
||||
|
||||
## Введённый groundwork (безопасно, поведение не меняется)
|
||||
|
||||
- `models.Queue` получил поле `mu sync.RWMutex` (не сериализуется) + методы
|
||||
`LockMu/UnlockMu/RLockMu/RUnlockMu`.
|
||||
- Исправлено копирование `Queue` by value (`numberOfHiddenMessagesInQueue`) —
|
||||
иначе `go vet` ругался на copylocks.
|
||||
- Глобальный лок пока используется везде (как раньше) — **поведение корректно**.
|
||||
|
||||
## Блокеры полного перехода (почему это не механическая замена)
|
||||
|
||||
1. **DLQ — общий указатель.** `Queue.DeadLetterQueue *Queue`. Перенос сообщения в
|
||||
DLQ мутирует `DeadLetterQueue.Messages` из-под лока ИСХОДНОЙ очереди
|
||||
(`gosqs.go:130`, `change_message_visibility.go:76`). При per-queue локах нужен
|
||||
порядок взятия (сначала исходная, потом DLQ) + гарантия отсутствия цикла.
|
||||
2. **`marshalQueueMeta` мутирует ЧУЖУЮ очередь.** `persistence/redis.go:146-160`
|
||||
временно зануляет `queue.Messages` И `queue.DeadLetterQueue.Messages` ради
|
||||
сериализации. Под глобальным локом это безопасно; под per-queue — гонка с
|
||||
операциями над DLQ. Нужен НЕмутирующий снапшот (DTO без `Messages`),
|
||||
но нельзя копировать `Queue` целиком (копируется мьютекс) → нужен отдельный
|
||||
тип-снапшот.
|
||||
3. **DLQ-указатель после загрузки из Redis не релинкуется** на объект из map
|
||||
(загружается как вложенная копия) — предсуществующая проблема, которую
|
||||
P1.1 обнажит (переносы в DLQ пойдут в detached-копию).
|
||||
4. **Объём — атомарный.** Переводить на per-queue локи надо СРАЗУ все операции
|
||||
с содержимым очереди (send/receive/delete/visibility/purge/attributes/tags/
|
||||
admin/gauge-tick) — частичный перевод создаёт рассинхрон (операции перестанут
|
||||
взаимоисключаться). Это ~20+ мест в 8 файлах.
|
||||
5. **Нет покрытия** для FIFO/batch/DLQ/visibility — самых сложных путей.
|
||||
|
||||
## Вывод
|
||||
|
||||
P1.1 — это дизайнерская задача (порядок локов + неммутирующий снапшот + релинк
|
||||
DLQ), а не построчная замена. Делать её «вслепую» без покрытия FIFO/batch/DLQ
|
||||
рискованно: ошибка = дедлоки/потеря данных на проде. Рекомендуется отдельный
|
||||
проход: сначала тесты на FIFO/DLQ/batch, затем атомарный перевод.
|
||||
|
||||
## СТАТУС: РЕАЛИЗОВАНО (2026-10-06)
|
||||
|
||||
Перевод выполнен атомарно. Что сделано:
|
||||
- `marshalQueueMeta` больше НЕ мутирует очередь (DTO-снапшот queueMetaDTO).
|
||||
- Все операции с содержимым очереди используют per-queue лок
|
||||
(`QueueStore.GetLocked/GetRLocked` + `queue.LockMu/RUnlockMu`) в gosqs, admin,
|
||||
metrics/gauge_updater, PeriodicTasks.
|
||||
- Глобальный `SyncQueues`-лок остался ТОЛЬКО для доступа к map и неизменяемых
|
||||
полей (create/delete/list/find/snapshot).
|
||||
- DLQ-переносы берут лок DLQ отдельно (порядок main→DLQ).
|
||||
- Устранён дедлок в create_queue (setQueueAttributesV1 вызывается ВНЕ глобального лока).
|
||||
- Добавлены конкурентные тесты (два тенанта / одна очередь), прогон `go test -race` — зелёный.
|
||||
|
||||
|
||||
## P1.2 (индексация сообщений) — та же природа
|
||||
|
||||
Индексация `Messages` (receipt→index) меняет структуру хранения, затронутую теми
|
||||
же путями (receive/delete/visibility/tick/сериализация), и требует покрытия тех
|
||||
же путей. Рекомендуется вместе с P1.1.
|
||||
|
||||
## P1.2 — СТАТУС: ОТЛОЖЕНО (обоснование, 2026-10-06)
|
||||
|
||||
После P1.1 главный боттлнек (глобальный лок на синхронный Redis-I/O и O(N)-скан
|
||||
по ВСЕМ очередям) устранён: теперь O(N)-сканы ограничены ОДНОЙ очередью и не
|
||||
блокируют остальные. Индекс `receipt→index` поверх срезa `[]SqsMessage` НЕ даёт
|
||||
O(1): удаление из середины среза сдвигает все последующие индексы (O(N) на
|
||||
переиндексацию). Реальный выигрыш требует смены контейнера (map+order-list), что
|
||||
затрагивает сериализацию и все пути — крупная рискованная правка с маргинальной
|
||||
выгодой на текущих объёмах. Отложено до появления реальной нагрузки/лимитов.
|
||||
|
||||
## P1.2 — СДЕЛАНО (2026-10-07)
|
||||
|
||||
Возврат к пункту по прямому указанию. Решение — tombstone + receipt/uuid-индексы
|
||||
+ амортизированная компакция (удаление НЕ сдвигает срез → индексы стабильны,
|
||||
O(1)-удаление без смены контейнера и без изменения Redis-схемы).
|
||||
Дизайн и результаты: `p1.2-message-index-design-2026-10-07.md`.
|
||||
По ходу найден и исправлен дефект утечки per-queue блокировки в receive
|
||||
(возврат до `defer UnlockMu`) — см. там же, раздел 6.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# P1.2 — индексация сообщений: дизайн и решение (2026-10-07)
|
||||
|
||||
> Статус: ЗАКРЫВАЕМ отсрочку от 2026-10-06. Задача — сделать DeleteMessage и
|
||||
> visibility-перерасчёт НЕ зависящими от полного размера очереди.
|
||||
> Источник: `plan-for-flash-2026-10-05.md` (P1.2), `p1.1-per-queue-lock-analysis-2026-10-06.md`.
|
||||
|
||||
## 1. Постановка
|
||||
|
||||
`Queue.Messages []SqsMessage` — единственное хранилище сообщений. Пути:
|
||||
|
||||
| Путь | Сейчас |
|
||||
|---|---|
|
||||
| ReceiveMessage (`receiveMessagesUnderLock`) | скан всего среза (нужен — ищем готовые) |
|
||||
| DeleteMessage | скан среза по ReceiptHandle, затем сдвиг `append(Messages[:i], Messages[i+1:]...)` |
|
||||
| DeleteMessageBatch | полная пересборка среза |
|
||||
| ChangeMessageVisibility(±Batch) | скан по ReceiptHandle |
|
||||
| `processQueueTick` (видимость + DLQ) | скан всего среза |
|
||||
| Retention-эвикция | скан всего среза (компакция на месте) |
|
||||
| `numberOfHiddenMessagesInQueue`, admin/health/peek/metrics | скан |
|
||||
|
||||
## 2. Почему «просто индекс receipt→index» недостаточно
|
||||
|
||||
Индекс поверх среза **не даёт O(1)** на удалении: физическое удаление из середины
|
||||
среза сдвигает все последующие элементы и ломает все сохранённые индексы
|
||||
(O(N) на переиндексацию). Именно это было причиной отсрочки 2026-10-06.
|
||||
|
||||
## 3. Решение: tombstone + индексы + амортизированная компакция
|
||||
|
||||
Ключевая идея: **удаление не двигает элементы**. Слот помечается «мёртвым»
|
||||
(tombstone) за O(1); физическая чистка (компакция) выполняется редко —
|
||||
раз в секунду в `processQueueTick` и по порогу при удалении.
|
||||
|
||||
Это сохраняет порядок сообщений (важно для FIFO и порядка выдачи),
|
||||
не меняет Redis-схему (сообщения и так лежат отдельными HASH-полями) и
|
||||
делает индексы стабильными между компакциями.
|
||||
|
||||
### 3.1. Новые поля `models.Queue` (все `json:"-"`, в Redis не попадают)
|
||||
|
||||
```go
|
||||
Messages []SqsMessage // физическое хранилище; слот может быть tombstone
|
||||
deadIdx []bool // параллельный Messages: true = слот удалён
|
||||
receiptIdx map[string]int // ReceiptHandle → индекс (только in-flight)
|
||||
uuidIdx map[string]int // Uuid → индекс (только живые)
|
||||
liveCount int // число живых сообщений
|
||||
deadCount int // число tombstone-слотов
|
||||
```
|
||||
|
||||
Схема Redis не меняется: `queueMetaDTO` сообщения не содержит, а сами сообщения
|
||||
хранятся отдельными полями `ssq:msg:{key}` → UUID. Tombstone-сообщения из Redis
|
||||
удаляются сразу (как и сейчас, `HDel` синхронно), поэтому «воскрешения» после
|
||||
рестарта не будет.
|
||||
|
||||
### 3.2. API `Queue` (примитивы, мутировать `Messages` напрямую запрещено)
|
||||
|
||||
- `AppendMessage(m SqsMessage) int` — добавить (append) + проставить индексы;
|
||||
- `RemoveIndex(i int)` — tombstone за O(1) + снять с индексов;
|
||||
- `IndexOfReceipt(rh string) (int, bool)` / `IndexOfUUID(u string) (int, bool)`;
|
||||
- `MarkInFlight(i int, rh string)` / `ClearInFlight(i int, rh string)`;
|
||||
- `IsDead(i int) bool`, `LiveCount() int`, `InFlightIndexes() []int`;
|
||||
- `Compact()` — физическое удаление tombstone + перестройка индексов (O(N));
|
||||
- `Reset()` — очистка (purge);
|
||||
- `RebuildIndexes()` — восстановление индексов (загрузка из Redis, ленивая защита).
|
||||
|
||||
Индексы строятся лениво (`ensureIndexes`), поэтому `Queue`, собранные литералом
|
||||
в тестах/загрузчике, работают без изменений вызовов.
|
||||
|
||||
### 3.3. Что становится O(1) / O(in-flight)
|
||||
|
||||
- **DeleteMessage** → `IndexOfReceipt` + `RemoveIndex` — O(1) (+ редкая компакция).
|
||||
- **ChangeMessageVisibility(±Batch)** → `IndexOfReceipt` — O(1) на запись.
|
||||
- **visibility-перерасчёт в тике** → итерация ТОЛЬКО по `receiptIdx` (in-flight),
|
||||
а не по всем сообщениям — O(in-flight).
|
||||
- **DLQ-перенос в тике** — внутри той же итерации по in-flight.
|
||||
|
||||
### 3.4. Что осознанно остаётся O(N)
|
||||
|
||||
- **receive** — нужно найти подходящие сообщения; полный O(1) требует ready-heap
|
||||
и в объём P1.2 не входит (критерий плана — delete + visibility).
|
||||
- **retention-эвикция** — раз в секунду, скан (отдельный пункт P1.3, уже сделан).
|
||||
|
||||
Компакция порогом: при `deadCount > 1024 && deadCount > liveCount` — прямо в
|
||||
`RemoveIndex` (амортизированно O(1) на удаление).
|
||||
|
||||
## 4. Затронутые файлы
|
||||
|
||||
`app/models/models.go`, `app/models/helpers.go`, `app/gosqs/{receive_message,
|
||||
delete_message, delete_message_batch, change_message_visibility,
|
||||
change_message_visibility_batch, gosqs, send_message, send_message_batch,
|
||||
get_queue_attributes}.go`, `app/admin/admin.go`, `app/metrics/gauge_updater.go`,
|
||||
`app/persistence/redis.go`, `app/gosqs/tenant_flow_test.go`.
|
||||
|
||||
## 5. Критерий готовности
|
||||
|
||||
1. `DeleteMessage`/`ChangeMessageVisibility` не сканируют срез.
|
||||
2. visibility-перерасчёт в тике идёт по числу in-flight, не по размеру очереди.
|
||||
3. Все существующие тесты зелёные; добавлены тесты на tombstone/компакцию.
|
||||
4. Redis-схема не изменилась; рестарт не «воскрешает» удалённые сообщения.
|
||||
|
||||
## 6. РЕАЛИЗОВАНО (2026-10-07) — и найденный по ходу дефект
|
||||
|
||||
Коммиты: `22e9bc8` (код), `e05af04` (тесты примитивов), `5b3fb51` (фикс ниже).
|
||||
|
||||
### 6.1. Найденный дефект (МОЯ РЕГРЕССИЯ) — утечка per-queue блокировки
|
||||
|
||||
При переводе `receiveMessagesUnderLock` на `LiveCount()` я сохранил исходный
|
||||
порядок строк:
|
||||
|
||||
```go
|
||||
queue := models.SyncQueues.GetLocked(key) // взяли Lock
|
||||
if queue == nil || queue.LiveCount() == 0 {
|
||||
return nil, false // ← возврат БЕЗ Unlock
|
||||
}
|
||||
defer queue.UnlockMu()
|
||||
```
|
||||
|
||||
Возврат на «пустой» очереди оставлял `queue.mu` захваченным НАВСЕГДА: любой
|
||||
последующий `Lock`/`RLock` этой очереди ждал вечно (в Go мьютекс не
|
||||
освобождается при выходе из функции).
|
||||
|
||||
Почему стало заметно именно сейчас: раньше условие было `len(queue.Messages) == 0`
|
||||
и срабатывало только на полностью пустой очереди; после перехода на tombstone
|
||||
добавился новый случай — «все сообщения удалены, но срез ещё не компактирован»
|
||||
(`LiveCount()==0` при `len(Messages)>0`), который в стресс-сценарии возникает
|
||||
на каждом цикле.
|
||||
|
||||
Диагностика (без гаданий):
|
||||
- симптом: `ReadTimeout` при нагрузке 2000 сообщений, единичные всплески ~1.9 с на `delete_batch`;
|
||||
- снят стек горутин по `SIGQUIT` → десятки горутин в `sync.RWMutex.RLock` на `Queue.RLockMu`
|
||||
(`models.go:101`), включая `metrics.updateGauges`; HTTP-хендлеры `GetQueueAttributesV1` — там же.
|
||||
- аудит всех `GetLocked`/`GetRLocked` показал: дефект ровно один — в `receive_message.go`.
|
||||
|
||||
Фикс (`5b3fb51`): `defer queue.UnlockMu()` — ДО любой проверки/возврата.
|
||||
Регрессионный тест `TestReceiveEmptyQueue_DoesNotLeakLock` (на дефекте падает
|
||||
за 2 с, на фиксе — зелёный).
|
||||
|
||||
### 6.2. Результаты проверок
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| `go build ./...`, `go vet ./...` | OK |
|
||||
| `go test -race ./app/...` | зелёные (admin, models, gosqs) |
|
||||
| `tests/api_test.sh` (aws CLI, локальный сервис) | **PASS=22 FAIL=0** |
|
||||
| `tests/sdk_test.py` (boto3) | **PASS=15 FAIL=0** |
|
||||
| `tests/fifo_dlq_probe.py` | **fifo_order/fifo_dedup/dlq = PASS** |
|
||||
| Стресс 3×2000 сообщений (send-batch → receive → delete-batch) | send ≈0.6 с, drain ≈1.2 с на цикл; `left=0/0`; ни одного вызова >0.5 с |
|
||||
|
||||
Стресс подтверждает и отсутствие «протечки» tombstone: после каждого цикла
|
||||
очередь возвращается к 0 живых (`CompactIfNeeded` в тике работает).
|
||||
|
||||
### 6.3. Замечание
|
||||
|
||||
В первом прогоне api/sdk/fifo уже проходили (22/22, 15/15, PASS×3) — то есть
|
||||
дефект проявлялся не на каждом сценарии, а на последовательных циклах полного
|
||||
опустошения очереди. Поэтому стресс-проверка была обязательной.
|
||||
@@ -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 ./...`.
|
||||
@@ -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<br/>(проверяет /ready)"]
|
||||
A["ВМ A: shared-sqs<br/>лидер → /ready 200"]
|
||||
P["ВМ B: shared-sqs<br/>пассив → /ready 503"]
|
||||
R[("Redis<br/>ssq:leader, ssq:queues, ssq:tenants")]
|
||||
|
||||
LB -->|трафик только на ready| A
|
||||
LB -.->|503 — трафик не идёт| P
|
||||
A <-->|SETNX / PEXPIRE heartbeat| R
|
||||
P <-->|пытается взять лок| R
|
||||
```
|
||||
|
||||
### 3.2. Механика лока
|
||||
|
||||
- Ключ: **`ssq:leader`**. Значение — **`<hostname>-<pid>`** (уникальный id инстанса,
|
||||
`app/cmd/goaws.go:180`).
|
||||
- **Взять лок:** `SET ssq:leader <id> NX PX <ttl>` — атомарно; `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 нет |
|
||||
| Значение ключа | `<hostname>-<pid>` того инстанса, который отдал 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) |
|
||||
@@ -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.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Находка: унаследованные Go-тесты gosqs сломаны форком (2026-10-06)
|
||||
|
||||
> Статус: подтверждено чтением кода. Меняет ранее данную рекомендацию.
|
||||
|
||||
## Факт 1 — тесты не компилируются
|
||||
|
||||
Все 13 файлов `app/gosqs/*_test.go` импортируют два пакета, которых НЕТ в репо
|
||||
(и никогда не было в git — проверено `git ls-files`, `git log --all`):
|
||||
- `shared-sqs/app/fixtures` (тестовые данные GoAws: `BASE_URL`, `QueueName`,
|
||||
`LOCAL_ENVIRONMENT`, `CreateQueueRequest`, `FullyPopulatedQueue` и др.)
|
||||
- `shared-sqs/app/test` (`GenerateRequestInfo`)
|
||||
|
||||
Оба — из upstream `github.com/Admiral-Piett/goaws`.
|
||||
|
||||
## Факт 2 — даже с восстановленными пакетами тесты ПАДАЮТ
|
||||
|
||||
Тесты унаследованы от дофоркового GoAws и не адаптированы к мультитенантному форку:
|
||||
|
||||
1. **Нет тенанта в контексте.** Все хендлеры начинаются с
|
||||
`t := getTenantFromContext(req); if t == nil { return InvalidClientTokenId }`
|
||||
(напр. `app/gosqs/create_queue.go:44-47`). Тесты вызывают хендлер без
|
||||
`auth.TenantContextKey` в контексте → тенант `nil` → ответ 403
|
||||
`InvalidClientTokenId`, тогда как тесты ожидают `http.StatusOK` (200).
|
||||
→ Любой такой assert падает.
|
||||
|
||||
2. **Ключ очереди теперь tenant-scoped.** Код хранит очереди под ключом
|
||||
`{accessKey}:{queueName}` (`tenantQueueKey`, `tenant_helpers.go:20`), а тесты
|
||||
читают `models.SyncQueues.Queues[fixtures.QueueName]` — по «голому» имени,
|
||||
без префикса → ключ не найден (`actualQueue == nil`), assert падает.
|
||||
|
||||
3. **URL/ARN содержат tenantID**, а не accountID
|
||||
(`tenantQueueURL`/`tenantQueueARN`) — ожидания тестов про URL/ARN тоже не совпадут.
|
||||
|
||||
## Вывод
|
||||
|
||||
Это **мёртвые тесты**: они не просто не собираются — они тестируют поведение
|
||||
ДО форка и принципиально несовместимы с текущим мультитенантным кодом.
|
||||
Наличие `fixtures`+`test` проблему НЕ решает (будет компилироваться, но краснеть).
|
||||
|
||||
## Что это значит для плана
|
||||
|
||||
- Прежняя рекомендация «сначала восстановить fixtures → зелёные тесты → P1.1»
|
||||
**ошибочна**. Пакеты восстановить можно (исходники upstream получены), но
|
||||
тесты всё равно не пройдут.
|
||||
- Правильный путь: **не воскрешать мёртвые тесты, а написать НОВЫЕ** — для
|
||||
текущего мультитенантного поведения (тенант в контексте, tenant-scoped ключи).
|
||||
Либо удалить унаследованные, либо переписать их в мультитенантном виде.
|
||||
- Пункт P1.1 (per-queue mutex) требует тестов на конкурентный доступ — их надо
|
||||
писать с нуля под текущую архитектуру.
|
||||
|
||||
## Источники
|
||||
- `app/gosqs/create_queue.go:44-47` — требование тенанта из контекста.
|
||||
- `app/gosqs/create_queue.go:52` — `key := tenantQueueKey(t.AccessKey, queueName)`.
|
||||
- `app/gosqs/tenant_helpers.go:20` — формат ключа `{accessKey}:{queueName}`.
|
||||
- `app/gosqs/create_queue_test.go:38` — `Queues[fixtures.QueueName]` без префикса.
|
||||
- `git ls-files 'app/fixtures*' 'app/test*'` — пакеты отсутствуют в истории.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Публичная точка SQS на ВМ 213 — как устроено (справочник, 2026-10-10)
|
||||
|
||||
> Текущее состояние и конфигурация. Хронология и ошибки — в `HISTORY/2026-10-10-session-log.md`.
|
||||
> Проверено снаружи: `https://sqs.sinefo.ru/health` → 200.
|
||||
|
||||
## 1. Схема
|
||||
|
||||
```
|
||||
клиент (AWS SDK, SigV4)
|
||||
│ https://sqs.sinefo.ru (443, TLS Let's Encrypt)
|
||||
▼
|
||||
[ ВМ 213: главный nginx ] conf.d/sqs-sinefo.conf
|
||||
│ proxy_pass http://127.0.0.1:4100 ; Host $http_host
|
||||
▼
|
||||
[ shared-sqs ] ~/sqs-test/shared-sqs (Go, memory-only)
|
||||
```
|
||||
|
||||
ВМ: публичный `5.172.178.213` (NAT), внутренний `10.10.102.21`, hostname `naeel-vm`.
|
||||
|
||||
## 2. Файлы
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Vhost nginx (80 ACME+редирект, 443 TLS) | `/etc/nginx/conf.d/sqs-sinefo.conf` (ВМ) |
|
||||
| Сертификат | `/etc/letsencrypt/live/sqs.sinefo.ru/{fullchain,privkey}.pem` |
|
||||
| ACME-webroot | `/var/www/acme/.well-known/acme-challenge/` |
|
||||
| Бинарник/лог/креды | `~/sqs-test/shared-sqs`, `~/sqs-test/sqs.log` |
|
||||
| Бэкапы nginx | `/etc/nginx/conf.d.bak-*` |
|
||||
|
||||
## 3. Конфиг vhost (эталон)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name sqs.sinefo.ru;
|
||||
location /.well-known/acme-challenge/ { root /var/www/acme; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sqs.sinefo.ru;
|
||||
ssl_certificate /etc/letsencrypt/live/sqs.sinefo.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sqs.sinefo.ru/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
|
||||
client_max_body_size 10m;
|
||||
proxy_request_buffering off;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4100;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host; # ВАЖНО: не $host — иначе SigV4 не сойдётся
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Критичные условия (иначе не работает)
|
||||
|
||||
1. **`Host $http_host`** — SigV4 подписывает заголовок `Host` целиком (с портом).
|
||||
`$host` порт отбрасывает → `SignatureDoesNotMatch`. Сервис берёт хост из запроса:
|
||||
`app/auth/auth_middleware.go:315-317`.
|
||||
2. **DNS:** `sqs.sinefo.ru A 5.172.178.213`; делегирование `sinefo.ru` → `ns3/ns4.ngcloud.ru`.
|
||||
Сертификат выдаётся только когда домен резолвится (HTTP-01 через главный nginx, порт 80).
|
||||
3. **Порт 8443 на 213 занят** DNAT-ом транзита (`→ 95.179.252.111:443`) — не использовать.
|
||||
4. Проксирование тел — `proxy_request_buffering off`; лимит SQS 256 КБ.
|
||||
|
||||
## 5. Замеры (2026-10-10) — болезнь managed-шлюза НЕ воспроизводится
|
||||
|
||||
- HTTP 30× (1КБ–256КБ): 30/30 200, max 0.99 с.
|
||||
- HTTPS 10× (26КБ–260КБ): 10/10 200, 0.7–3.2 с.
|
||||
- Long-poll `WaitTimeSeconds=20` → 20.3 с, 200.
|
||||
|
||||
Гипотеза мануала (Шаг 1) подтверждена: 0 зависаний ~51 с, 0 таймаутов 31–33 с.
|
||||
|
||||
## 6. Управление
|
||||
|
||||
```bash
|
||||
# запуск SQS на ВМ
|
||||
cd ~/sqs-test && setsid nohup env SHARED_SQS_SEED_DEMO=true \
|
||||
./shared-sqs --admin-token=dev-token-123 --port=4100 --loglevel=info > sqs.log 2>&1 </dev/null &
|
||||
# креды демо-тенанта
|
||||
grep "seed: создан тенант" ~/sqs-test/sqs.log | tail -1
|
||||
# проверка
|
||||
curl -sS https://sqs.sinefo.ru/health
|
||||
```
|
||||
|
||||
## 7. QueueUrl — ИСПРАВЛЕНО (2026-10-10)
|
||||
|
||||
**Было:** `QueueUrl` = `http://region.host:port/<tenantID>/<queue>` (плейсхолдеры — на ВМ нет `goaws.yaml`).
|
||||
|
||||
**Стало:** база URL задаётся через `ExternalURLBase` (config `goaws.yaml` **или** env
|
||||
`SHARED_SQS_EXTERNAL_URL_BASE`). На ВМ запускается с `SHARED_SQS_EXTERNAL_URL_BASE=https://sqs.sinefo.ru` →
|
||||
`QueueUrl` = `https://sqs.sinefo.ru/<tenantID>/<queue>` (проверено снаружи).
|
||||
|
||||
Изменения (коммиты `469132b`, `ca0372b`):
|
||||
- `app/models/configuration.go` — поле `Environment.ExternalURLBase`;
|
||||
- `app/gosqs/tenant_helpers.go` — `tenantQueueURL` учитывает `ExternalURLBase`; добавлена
|
||||
экспортированная `TenantQueueURL`;
|
||||
- `app/conf/config.go` — env-override `SHARED_SQS_EXTERNAL_URL_BASE` в `applyEnvironmentDefaults`
|
||||
(срабатывает и при отсутствии `goaws.yaml` — важная тонкость: `LoadYamlConfig` делает ранний
|
||||
`return` без конфига, поэтому override там не срабатывал);
|
||||
- `app/cmd/seed.go` — демо-очереди формируют URL через `gosqs.TenantQueueURL` (убрано
|
||||
дублирование логики — оно и было причиной, что фикс «не применялся»);
|
||||
- тест `app/gosqs/tenant_helpers_test.go`.
|
||||
|
||||
## 8. Устойчивость: systemd + локальный Redis (2026-10-10)
|
||||
|
||||
- **Redis** установлен на 213 (`apt install redis-server` 7.0.15), слушает `127.0.0.1:6379`,
|
||||
включён **AOF** (`/etc/redis/redis.conf`: `appendonly yes`) — состояние переживает рестарт.
|
||||
- **systemd-юнит** `/etc/systemd/system/shared-sqs.service`: `Restart=always`, `RestartSec=2`,
|
||||
`WantedBy=multi-user.target` (автостарт при ребуте), `Requires=redis-server.service`,
|
||||
`User=naeel`, `WorkingDirectory=/home/naeel/sqs-test`, `StandardOutput=append:.../sqs.log`.
|
||||
- **env** `/etc/shared-sqs.env` (600, root): `SHARED_SQS_ADMIN_TOKEN`, `SHARED_SQS_SEED_DEMO`,
|
||||
`SHARED_SQS_EXTERNAL_URL_BASE=https://sqs.sinefo.ru`, `REDIS_ADDR=127.0.0.1:6379`.
|
||||
- Управление: `sudo systemctl {status,restart,stop,start} shared-sqs`.
|
||||
|
||||
**Закрыто:** крах процесса (авторестарт) и ребут ВМ (автостарт + Redis с диска из AOF; проверено —
|
||||
сообщение пережило рестарт).
|
||||
**НЕ закрыто:** падение самой ВМ — SQS и Redis лежат на одной машине (single point of failure).
|
||||
Настоящая отказоустойчивость — после Шага 3 (Redis на отдельном хосте + несколько инстансов).
|
||||
|
||||
### Дефекты seed, обнаруженные при включении Redis (исправлены, коммит `c0aa618`)
|
||||
1. **Демо-очереди не персистились** — после рестарта у демо-тенанта не было очередей.
|
||||
Фикс: `persistence.SaveQueue` + `SaveMessages` в `app/cmd/seed.go`.
|
||||
2. **Дубли тенантов** — каждый старт создавал нового демо-тенанта (новые креды). При
|
||||
`Restart=always` это утечка. Фикс: идемпотентность в `seedDemoData` (проверка по имени
|
||||
через `store.List()` → «сид пропущен»).
|
||||
|
||||
### Для прода
|
||||
`SHARED_SQS_SEED_DEMO=false` (демо-данные не нужны), тенанты/очереди создаются через API
|
||||
(они персистятся штатно).
|
||||
|
||||
## 9. Прод-хардненинг (2026-10-10, выполнено)
|
||||
|
||||
- `SHARED_SQS_SEED_DEMO=false` — демо-данные отключены (существующий тенант подхватывается из Redis).
|
||||
- **admin-token сменён** на случайный 64-hex (хранится в `/etc/shared-sqs.env`, 600). Прежний
|
||||
`dev-token-123` был слабым, а сервис теперь публичный. Прочитать: `sudo sed -n '1p' /etc/shared-sqs.env`.
|
||||
- **logrotate** `/etc/logrotate.d/shared-sqs`: daily, rotate 7, compress, delaycompress,
|
||||
copytruncate, `su naeel naeel` (copytruncate — процесс держит файл открытым).
|
||||
- Billing (PostgreSQL) и JWT-валидация — **не настроены** (сервис работает без них).
|
||||
|
||||
## 10. Разбиение data-plane / control-plane (Шаг 2, 2026-10-10, коммит `c1520c0`)
|
||||
|
||||
- `router.NewDataPlane(store)` — публичный SQS API: `/health`, `/static`, `/examples`, SQS actions.
|
||||
- `router.NewControlPlane(store, adminToken)` — админка: `/admin` (UI+API), `/metrics`, `/ui`, `/static`.
|
||||
- `main`: два HTTP-сервера — data-plane на `0.0.0.0:<--port>` (4100, публично), control-plane
|
||||
на `127.0.0.1:<--control-port>` (4101, только localhost).
|
||||
- Проверено: публично `/health` → 200; SQS API работает; `/admin` и `/metrics` публично → **403**
|
||||
(падают в SQS-auth — админка наружу **НЕ доступна**). Локально: `127.0.0.1:4101/admin` → 200,
|
||||
`4101/metrics` без токена → 401.
|
||||
|
||||
**Почему пока два листенера в одном процессе, а не два процесса:** состояние (очереди/тенанты) —
|
||||
in-memory; два процесса разъедутся (админка создаст тенанта в своей памяти — data-plane его не увидит).
|
||||
Полноценный процесс-split — после Шага 3 (Redis как источник истины).
|
||||
|
||||
## 11. Надёжность записи (durability) и billing (2026-10-10)
|
||||
|
||||
### 11.1. Синхронная запись критичных операций
|
||||
- `SendMessage` — sync (`SaveMessageSync`, фикс P1.1) ✅
|
||||
- `DeleteMessage` / `DeleteMessageBatch` — sync (`DeleteMessagePersist`/`DeleteMessagesPersist`) ✅
|
||||
- **`SendMessageBatch` — ИСПРАВЛЕНО (коммит `3559414`):** было `SaveMessages` (async) → стало
|
||||
`SaveMessagesSync` (ответ 200 только после записи всех сообщений; при ошибке — откат append).
|
||||
Заодно: не персистим дубли (раньше в Redis попадали дубликаты, которых нет в очереди).
|
||||
- Проверено: batch из 2 сообщений в пустую очередь **пережил рестарт** сервиса.
|
||||
- Остаётся async: `SaveQueue` (метаданные очереди: FIFO-состояние, duplicates) и метаданные
|
||||
в Create/Delete/SetAttributes/Purge/Tags — потеря возможна только в узком окне при крахе;
|
||||
сами сообщения защищены.
|
||||
|
||||
### 11.2. Billing — предусмотрен, но НЕ включён
|
||||
- Модуль есть (`app/billing`, PG-воркер + буфер), включается через `BILLING_PG_HOST` (сейчас не задан).
|
||||
- Решение: **оставить выключенным**; включать, когда появятся реальные клиенты/тарификация.
|
||||
- Учёт операций уже рассчитан под биллинг — при включении заработает без изменений ядра.
|
||||
+6
-2
@@ -68,10 +68,14 @@ done
|
||||
N=$(echo "$RM" | json 'len(d.get("Messages", []))')
|
||||
[[ "$N" == "3" ]] && ok "ReceiveMessage (3 сообщения)" || bad "ReceiveMessage: получили $N"
|
||||
|
||||
# ChangeMessageVisibility по первому handle
|
||||
# ChangeMessageVisibility по первому handle.
|
||||
# visibility-timeout=5с (НЕ 1с): после истечения таймаута сообщение снова
|
||||
# видимо, а handle сбрасывается — delete по старому handle даёт
|
||||
# ReceiptHandleIsInvalid (корректная AWS-семантика). При 1с сетевые задержки
|
||||
# приводили к случайным FAIL теста.
|
||||
RH1=$(echo "$RM" | json 'd["Messages"][0]["ReceiptHandle"]')
|
||||
if [[ -n "$RH1" ]]; then
|
||||
"${AWS[@]}" sqs change-message-visibility --queue-url "$QUEUE_URL" --receipt-handle "$RH1" --visibility-timeout 1 >/dev/null 2>&1 \
|
||||
"${AWS[@]}" sqs change-message-visibility --queue-url "$QUEUE_URL" --receipt-handle "$RH1" --visibility-timeout 5 >/dev/null 2>&1 \
|
||||
&& ok "ChangeMessageVisibility" || bad "ChangeMessageVisibility"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI v2 (aws cli) в нагрузке: 50 циклов send/receive/delete с замером латентности.
|
||||
Запуск на ВМ: python3 tests/cli_load_test.py"""
|
||||
import boto3
|
||||
import subprocess
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
|
||||
ENV = dict(os.environ)
|
||||
EP = 'https://sqs.containerk8s.dev.nubes.ru'
|
||||
sqs = boto3.client('sqs', endpoint_url=EP, region_name='us-east-1')
|
||||
|
||||
|
||||
def cli(*a, tmo=20):
|
||||
t = time.time()
|
||||
p = subprocess.run(['aws', '--endpoint-url', EP, '--region', 'us-east-1', 'sqs', *a],
|
||||
env=ENV, capture_output=True, text=True, timeout=tmo)
|
||||
return p, time.time() - t
|
||||
|
||||
|
||||
def main():
|
||||
q = 'cliload-%d' % int(time.time())
|
||||
sqs.create_queue(QueueName=q)
|
||||
u = EP + '/' + q
|
||||
lat = {'send': [], 'receive': [], 'delete': []}
|
||||
failed = 0
|
||||
for i in range(50):
|
||||
p, t = cli('send-message', '--queue-url', u, '--message-body', 'msg-%d' % i)
|
||||
lat['send'].append(t)
|
||||
if p.returncode != 0:
|
||||
failed += 1
|
||||
print('send FAIL', i, p.stderr[:120])
|
||||
continue
|
||||
p, t = cli('receive-message', '--queue-url', u, '--max-number-of-messages', '1', '--visibility-timeout', '3')
|
||||
lat['receive'].append(t)
|
||||
m = re.search(r'"ReceiptHandle":\s*"([^"]+)"', p.stdout)
|
||||
if not m:
|
||||
failed += 1
|
||||
print('receive FAIL', i, p.stdout[:100], p.stderr[:100])
|
||||
continue
|
||||
p, t = cli('delete-message', '--queue-url', u, '--receipt-handle', m.group(1))
|
||||
lat['delete'].append(t)
|
||||
if p.returncode != 0:
|
||||
failed += 1
|
||||
print('delete FAIL', i, p.stderr[:120])
|
||||
for k in lat:
|
||||
v = sorted(lat[k])
|
||||
n = len(v)
|
||||
if n:
|
||||
print('%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms' % (k, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
|
||||
else:
|
||||
print('%s: n=0' % k)
|
||||
sqs.delete_queue(QueueUrl=u)
|
||||
print('CLI load done, failed=%d' % failed)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Сравнение shared-SQS vs Yandex YMQ (очередь newsqs).
|
||||
|
||||
Короткий замер: последовательные операции, N send → N receive+delete.
|
||||
Метрики: p50/p95/max латентности send/receive/delete, число ошибок.
|
||||
|
||||
Требуемые переменные окружения:
|
||||
SHARED_AK, SHARED_SK — креды shared-SQS
|
||||
YMQ_AK, YMQ_SK — креды Yandex YMQ (SA newsqs)
|
||||
YMQ_QUEUE_URL — URL очереди Яндекса (newsqs)
|
||||
|
||||
Запуск: python3 tests/compare_ymq_vs_shared.py [N]
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
N = int(sys.argv[1]) if len(sys.argv) > 1 else 50
|
||||
BODY = "x" * 512
|
||||
|
||||
SHARED_EP = "https://sqs.containerk8s.dev.nubes.ru"
|
||||
YMQ_EP = "https://message-queue.api.cloud.yandex.net"
|
||||
|
||||
SHARED_AK = os.environ["SHARED_AK"]
|
||||
SHARED_SK = os.environ["SHARED_SK"]
|
||||
YMQ_AK = os.environ["YMQ_AK"]
|
||||
YMQ_SK = os.environ["YMQ_SK"]
|
||||
YMQ_QUEUE_URL = os.environ["YMQ_QUEUE_URL"]
|
||||
|
||||
CFG = Config(connect_timeout=15, read_timeout=30, retries={"max_attempts": 0})
|
||||
|
||||
|
||||
def mk_client(endpoint, region, ak, sk):
|
||||
return boto3.client(
|
||||
"sqs",
|
||||
endpoint_url=endpoint,
|
||||
region_name=region,
|
||||
aws_access_key_id=ak,
|
||||
aws_secret_access_key=sk,
|
||||
config=CFG,
|
||||
)
|
||||
|
||||
|
||||
def stats(name, values):
|
||||
v = sorted(values)
|
||||
if not v:
|
||||
print("%s: n=0" % name)
|
||||
return
|
||||
n = len(v)
|
||||
print("%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms" %
|
||||
(name, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
|
||||
|
||||
|
||||
def bench(client, queue_url, label):
|
||||
errs = 0
|
||||
lat_send, lat_recv, lat_del = [], [], []
|
||||
t_start = time.time()
|
||||
|
||||
for i in range(N):
|
||||
t0 = time.time()
|
||||
try:
|
||||
client.send_message(QueueUrl=queue_url, MessageBody=BODY)
|
||||
lat_send.append(time.time() - t0)
|
||||
except Exception as e:
|
||||
errs += 1
|
||||
if errs <= 3:
|
||||
print(" send err: %s %s" % (type(e).__name__, str(e)[:80]))
|
||||
|
||||
got = 0
|
||||
tries = 0
|
||||
while got < N and tries < N * 6:
|
||||
tries += 1
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = client.receive_message(
|
||||
QueueUrl=queue_url, MaxNumberOfMessages=1, VisibilityTimeout=30)
|
||||
lat_recv.append(time.time() - t0)
|
||||
msgs = r.get("Messages", [])
|
||||
except Exception as e:
|
||||
errs += 1
|
||||
if errs <= 3:
|
||||
print(" recv err: %s %s" % (type(e).__name__, str(e)[:80]))
|
||||
continue
|
||||
for m in msgs:
|
||||
got += 1
|
||||
t0 = time.time()
|
||||
try:
|
||||
client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"])
|
||||
lat_del.append(time.time() - t0)
|
||||
except Exception as e:
|
||||
errs += 1
|
||||
if errs <= 3:
|
||||
print(" del err: %s %s" % (type(e).__name__, str(e)[:80]))
|
||||
|
||||
total = time.time() - t_start
|
||||
print("== %s (N=%d) ==" % (label, N))
|
||||
stats(" send ", lat_send)
|
||||
stats(" receive", lat_recv)
|
||||
stats(" delete ", lat_del)
|
||||
print(" received=%d/%d errors=%d total=%.1fs" % (got, N, errs, total))
|
||||
print(" throughput: send=%.1f op/s, recv+del=%.1f op/s" %
|
||||
(N / total if total else 0, got / total if total else 0))
|
||||
return errs
|
||||
|
||||
|
||||
def drain(client, queue_url):
|
||||
while True:
|
||||
r = client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=10)
|
||||
msgs = r.get("Messages", [])
|
||||
if not msgs:
|
||||
break
|
||||
for m in msgs:
|
||||
try:
|
||||
client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
shared = mk_client(SHARED_EP, "us-east-1", SHARED_AK, SHARED_SK)
|
||||
ymq = mk_client(YMQ_EP, "ru-central1", YMQ_AK, YMQ_SK)
|
||||
|
||||
# Временная очередь shared-SQS
|
||||
qname = "cmp-%s" % uuid.uuid4().hex[:8]
|
||||
shared_url = shared.create_queue(QueueName=qname)["QueueUrl"]
|
||||
print("shared queue: %s" % shared_url)
|
||||
|
||||
# YMQ newsqs: перед тестом чистим
|
||||
drain(ymq, YMQ_QUEUE_URL)
|
||||
print("ymq queue: %s (drained)" % YMQ_QUEUE_URL)
|
||||
|
||||
print()
|
||||
e1 = bench(shared, shared_url, "shared-SQS (наш сервис)")
|
||||
print()
|
||||
e2 = bench(ymq, YMQ_QUEUE_URL, "Yandex YMQ (newsqs)")
|
||||
|
||||
# Очистка
|
||||
drain(ymq, YMQ_QUEUE_URL)
|
||||
try:
|
||||
shared.delete_queue(QueueUrl=shared_url)
|
||||
print("\ncleanup: shared temp queue deleted, ymq drained")
|
||||
except Exception as ex:
|
||||
print("\ncleanup err: %s" % str(ex)[:80])
|
||||
|
||||
print("\nRESULT: errors shared=%d ymq=%d" % (e1, e2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Детерминированная проверка DLQ-переноса (без гонок тайминга):
|
||||
после каждой доставки ждём истечения visibility + тик PeriodicTasks (2.5с).
|
||||
Если 5/5 PASS — сервис корректен, флап fifo_dlq_probe — гонка теста."""
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
ENDPOINT = "https://sqs.containerk8s.dev.nubes.ru"
|
||||
REGION = "us-east-1"
|
||||
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
|
||||
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 0}))
|
||||
|
||||
|
||||
def drain(url, n=10):
|
||||
msgs = []
|
||||
while True:
|
||||
b = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=n).get("Messages", [])
|
||||
if not b:
|
||||
break
|
||||
msgs.extend(b)
|
||||
return msgs
|
||||
|
||||
|
||||
def run(i):
|
||||
uid = str(uuid.uuid4())[:8]
|
||||
dlq_name = "dlqdet-%s-%d" % (uid, i)
|
||||
main_name = "maindet-%s-%d" % (uid, i)
|
||||
dlq_url = sqs.create_queue(QueueName=dlq_name)["QueueUrl"]
|
||||
tenant = dlq_url.split("/")[-2]
|
||||
arn = "arn:aws:sqs:%s:%s:%s" % (REGION, tenant, dlq_name)
|
||||
policy = json.dumps({"deadLetterTargetArn": arn, "maxReceiveCount": "2"})
|
||||
main_url = sqs.create_queue(
|
||||
QueueName=main_name,
|
||||
Attributes={"VisibilityTimeout": "1", "RedrivePolicy": policy},
|
||||
)["QueueUrl"]
|
||||
sqs.send_message(QueueUrl=main_url, MessageBody="to-dlq")
|
||||
|
||||
r1 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
|
||||
time.sleep(2.5) # visibility истёк + тик вернул (Retry=1)
|
||||
r2 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
|
||||
time.sleep(2.5) # visibility истёк + тик: Retry=2 >= maxReceiveCount=2 -> DLQ
|
||||
r3 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
|
||||
time.sleep(2.0)
|
||||
dlq_msgs = drain(dlq_url)
|
||||
main_left = drain(main_url)
|
||||
|
||||
ok = (len(r1) == 1 and len(r2) == 1 and len(r3) == 0
|
||||
and len(dlq_msgs) == 1 and dlq_msgs[0]["Body"] == "to-dlq"
|
||||
and len(main_left) == 0)
|
||||
print("run %d: r1=%d r2=%d r3=%d dlq=%d main_left=%d -> %s"
|
||||
% (i, len(r1), len(r2), len(r3), len(dlq_msgs), len(main_left),
|
||||
"PASS" if ok else "FAIL"), flush=True)
|
||||
try:
|
||||
sqs.delete_queue(QueueUrl=main_url)
|
||||
sqs.delete_queue(QueueUrl=dlq_url)
|
||||
except Exception:
|
||||
pass
|
||||
return ok
|
||||
|
||||
|
||||
ok = 0
|
||||
for i in range(1, 6):
|
||||
try:
|
||||
if run(i):
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
print("run %d: EXC %s %s" % (i, type(e).__name__, str(e)[:120]), flush=True)
|
||||
print("TOTAL: %d/5 PASS" % ok, flush=True)
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tests/fifo_dlq_probe.py — e2e: FIFO порядок + dedup внутри окна + DLQ (план Соннета P2).
|
||||
|
||||
Проверки:
|
||||
1. FIFO: 10 сообщений в одну группу выдаются строго по порядку.
|
||||
2. FIFO dedup: два send с одинаковым MessageDeduplicationId внутри окна
|
||||
(5 мин) → в очереди оказывается ОДНО сообщение.
|
||||
3. DLQ: сообщение после MaxReceiveCount=2 попыток (VisibilityTimeout=1с)
|
||||
переносится в dead-letter очередь.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
|
||||
REGION = os.environ.get("REGION", "us-east-1")
|
||||
|
||||
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
|
||||
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 3}))
|
||||
|
||||
uid = str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def drain(url, n=10):
|
||||
msgs = []
|
||||
while True:
|
||||
b = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=n).get("Messages", [])
|
||||
if not b:
|
||||
break
|
||||
msgs.extend(b)
|
||||
return msgs
|
||||
|
||||
|
||||
results = {}
|
||||
|
||||
|
||||
def check_fifo_order():
|
||||
q = "fifo-order-%s.fifo" % uid
|
||||
u = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
|
||||
for i in range(10):
|
||||
sqs.send_message(QueueUrl=u, MessageBody="m%d" % i, MessageGroupId="g", MessageDeduplicationId="d%d" % (i,))
|
||||
got = []
|
||||
while True:
|
||||
b = sqs.receive_message(QueueUrl=u, MaxNumberOfMessages=10).get("Messages", [])
|
||||
if not b:
|
||||
break
|
||||
got.extend(b)
|
||||
for m in b:
|
||||
sqs.delete_message(QueueUrl=u, ReceiptHandle=m["ReceiptHandle"])
|
||||
bodies = [m["Body"] for m in got]
|
||||
want = ["m%d" % i for i in range(10)]
|
||||
results["fifo_order"] = bodies == want
|
||||
print("fifo_order: got=%s want=%s %s" % (bodies, want, "PASS" if bodies == want else "FAIL"), flush=True)
|
||||
sqs.delete_queue(QueueUrl=u)
|
||||
|
||||
|
||||
def check_fifo_dedup():
|
||||
q = "fifo-dedup-%s.fifo" % uid
|
||||
u = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
|
||||
sqs.send_message(QueueUrl=u, MessageBody="first", MessageGroupId="g", MessageDeduplicationId="SAME")
|
||||
sqs.send_message(QueueUrl=u, MessageBody="dup", MessageGroupId="g", MessageDeduplicationId="SAME")
|
||||
msgs = drain(u)
|
||||
ok = len(msgs) == 1 and msgs[0]["Body"] == "first"
|
||||
results["fifo_dedup"] = ok
|
||||
print("fifo_dedup: count=%d body=%s %s" % (len(msgs), msgs[0]["Body"] if msgs else None,
|
||||
"PASS" if ok else "FAIL"), flush=True)
|
||||
sqs.delete_queue(QueueUrl=u)
|
||||
|
||||
|
||||
def check_dlq():
|
||||
dlq_name = "fifo-dlq-%s" % uid
|
||||
main_name = "fifo-main-%s" % uid
|
||||
dlq_url = sqs.create_queue(QueueName=dlq_name)["QueueUrl"]
|
||||
# tenantID из URL DLQ: http://us-east-1.goaws.com:4100/t-<id>/<name>
|
||||
tenant = dlq_url.split("/")[-2]
|
||||
arn = "arn:aws:sqs:%s:%s:%s" % (REGION, tenant, dlq_name)
|
||||
policy = json.dumps({"deadLetterTargetArn": arn, "maxReceiveCount": "2"})
|
||||
main_url = sqs.create_queue(
|
||||
QueueName=main_name,
|
||||
Attributes={"VisibilityTimeout": "1", "RedrivePolicy": policy},
|
||||
)["QueueUrl"]
|
||||
sqs.send_message(QueueUrl=main_url, MessageBody="to-dlq")
|
||||
for attempt in range(3):
|
||||
b = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
|
||||
print("attempt %d: %s" % (attempt + 1, "received" if b else "empty"), flush=True)
|
||||
time.sleep(1.5)
|
||||
# Перенос в DLQ выполняет фоновый тик PeriodicTasks (период 1с) при
|
||||
# истечении VisibilityTimeout. Мгновенная проверка гоняет с фазой тика —
|
||||
# опрашиваем DLQ до 10с.
|
||||
dlq_msgs = []
|
||||
for _ in range(10):
|
||||
dlq_msgs = drain(dlq_url)
|
||||
if dlq_msgs:
|
||||
break
|
||||
time.sleep(1)
|
||||
ok = len(dlq_msgs) == 1 and dlq_msgs[0]["Body"] == "to-dlq"
|
||||
results["dlq"] = ok
|
||||
print("dlq: count=%d body=%s %s" % (len(dlq_msgs), dlq_msgs[0]["Body"] if dlq_msgs else None,
|
||||
"PASS" if ok else "FAIL"), flush=True)
|
||||
sqs.delete_queue(QueueUrl=main_url)
|
||||
sqs.delete_queue(QueueUrl=dlq_url)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
check_fifo_order()
|
||||
except Exception as e:
|
||||
results["fifo_order"] = False
|
||||
print("fifo_order: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
|
||||
try:
|
||||
check_fifo_dedup()
|
||||
except Exception as e:
|
||||
results["fifo_dedup"] = False
|
||||
print("fifo_dedup: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
|
||||
try:
|
||||
check_dlq()
|
||||
except Exception as e:
|
||||
results["dlq"] = False
|
||||
print("dlq: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
|
||||
print("SUMMARY: %s" % json.dumps(results), flush=True)
|
||||
return 0 if all(results.values()) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ДЛИННОЕ сравнение shared-SQS vs Yandex YMQ — с ЛОКАЛИ, по таймеру.
|
||||
|
||||
Схема (честная — обе очереди в одних сетевых условиях, раунды попеременно):
|
||||
1. Старт: seed K сообщений в каждую очередь.
|
||||
2. Раунд для каждой очереди: receive(1) -> delete -> send(1) (восполнение пула).
|
||||
3. Раунды чередуются (наш, яндекс, наш, яндекс...) до истечения DURATION.
|
||||
|
||||
Метрики: p50/p95/max для send/receive/delete, ошибки по типам (с таймаутами),
|
||||
op/s. Прогресс каждые 60с пишется в LOG и stdout.
|
||||
|
||||
Env: SHARED_AK, SHARED_SK, YMQ_AK, YMQ_SK, YMQ_QUEUE_URL, DURATION (сек, default 1800)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
DURATION = int(os.environ.get("DURATION", "1800"))
|
||||
K = 10
|
||||
BODY = "x" * 512
|
||||
LOG = "tests/long_compare.log"
|
||||
|
||||
SHARED_EP = "https://sqs.containerk8s.dev.nubes.ru"
|
||||
YMQ_EP = "https://message-queue.api.cloud.yandex.net"
|
||||
|
||||
CFG = Config(connect_timeout=15, read_timeout=30, retries={"max_attempts": 0})
|
||||
|
||||
|
||||
def mk(endpoint, region, ak, sk):
|
||||
return boto3.client("sqs", endpoint_url=endpoint, region_name=region,
|
||||
aws_access_key_id=ak, aws_secret_access_key=sk, config=CFG)
|
||||
|
||||
|
||||
class Stat:
|
||||
def __init__(self):
|
||||
self.vals = {"send": [], "receive": [], "delete": []}
|
||||
self.errs = {} # type -> count
|
||||
self.ops = {"send": 0, "receive": 0, "delete": 0}
|
||||
|
||||
def op(self, name, fn):
|
||||
self.ops[name] += 1
|
||||
t0 = time.time()
|
||||
try:
|
||||
fn()
|
||||
self.vals[name].append(time.time() - t0)
|
||||
except Exception as e:
|
||||
k = type(e).__name__
|
||||
self.errs[k] = self.errs.get(k, 0) + 1
|
||||
|
||||
|
||||
def stat_line(st, label):
|
||||
out = [label]
|
||||
for name in ("send", "receive", "delete"):
|
||||
v = sorted(st.vals[name])
|
||||
n = len(v)
|
||||
if n:
|
||||
out.append("%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms" %
|
||||
(name, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
|
||||
else:
|
||||
out.append("%s: n=0" % name)
|
||||
out.append("errors=%s" % (st.errs if st.errs else "0"))
|
||||
return " | ".join(out)
|
||||
|
||||
|
||||
def logp(msg):
|
||||
line = "%s %s" % (time.strftime("%H:%M:%S"), msg)
|
||||
print(line, flush=True)
|
||||
try:
|
||||
with open(LOG, "a") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
shared = mk(SHARED_EP, "us-east-1", os.environ["SHARED_AK"], os.environ["SHARED_SK"])
|
||||
ymq = mk(YMQ_EP, "ru-central1", os.environ["YMQ_AK"], os.environ["YMQ_SK"])
|
||||
ymq_url = os.environ["YMQ_QUEUE_URL"]
|
||||
|
||||
# временная очередь shared
|
||||
qname = "longcmp-%s" % uuid.uuid4().hex[:8]
|
||||
shared_url = shared.create_queue(QueueName=qname)["QueueUrl"]
|
||||
|
||||
def drain(client, url):
|
||||
while True:
|
||||
try:
|
||||
r = client.receive_message(QueueUrl=url, MaxNumberOfMessages=10)
|
||||
msgs = r.get("Messages", [])
|
||||
except Exception:
|
||||
break
|
||||
if not msgs:
|
||||
break
|
||||
for m in msgs:
|
||||
try:
|
||||
client.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
drain(shared, shared_url)
|
||||
drain(ymq, ymq_url)
|
||||
|
||||
ss = Stat()
|
||||
ys = Stat()
|
||||
sts = {"shared": (shared, shared_url, ss), "ymq": (ymq, ymq_url, ys)}
|
||||
|
||||
# seed K в обе очереди
|
||||
for key in ("shared", "ymq"):
|
||||
c, u, st = sts[key]
|
||||
for _ in range(K):
|
||||
st.op("send", lambda c=c, u=u: c.send_message(QueueUrl=u, MessageBody=BODY))
|
||||
|
||||
logp("LONG COMPARE START: duration=%ds K=%d shared=%s" % (DURATION, K, shared_url))
|
||||
logp("ymq=%s" % ymq_url)
|
||||
|
||||
deadline = time.time() + DURATION
|
||||
last_prog = time.time()
|
||||
rounds = 0
|
||||
|
||||
def round_for(key):
|
||||
nonlocal rounds
|
||||
c, u, st = sts[key]
|
||||
# receive (замер) + получить сообщения для delete
|
||||
got = []
|
||||
t0 = time.time()
|
||||
st.ops["receive"] += 1
|
||||
try:
|
||||
r = c.receive_message(QueueUrl=u, MaxNumberOfMessages=1, VisibilityTimeout=30)
|
||||
st.vals["receive"].append(time.time() - t0)
|
||||
got = r.get("Messages", [])
|
||||
except Exception as e:
|
||||
st.errs[type(e).__name__] = st.errs.get(type(e).__name__, 0) + 1
|
||||
for m in got:
|
||||
st.op("delete", lambda c=c, u=u, m=m: c.delete_message(QueueUrl=u, ReceiptHandle=m["ReceiptHandle"]))
|
||||
st.op("send", lambda c=c, u=u: c.send_message(QueueUrl=u, MessageBody=BODY))
|
||||
|
||||
while time.time() < deadline:
|
||||
round_for("shared")
|
||||
round_for("ymq")
|
||||
rounds += 1
|
||||
if time.time() - last_prog >= 60:
|
||||
last_prog = time.time()
|
||||
logp("progress %d/%ds" % (int(time.time() - (deadline - DURATION)), DURATION))
|
||||
logp(" " + stat_line(ss, "SHARED"))
|
||||
logp(" " + stat_line(ys, "YMQ"))
|
||||
|
||||
logp("LONG COMPARE DONE: rounds=%d" % rounds)
|
||||
logp(stat_line(ss, "FINAL SHARED"))
|
||||
logp(stat_line(ys, "FINAL YMQ"))
|
||||
|
||||
# cleanup
|
||||
drain(shared, shared_url)
|
||||
drain(ymq, ymq_url)
|
||||
try:
|
||||
shared.delete_queue(QueueUrl=shared_url)
|
||||
logp("cleanup: shared temp queue deleted, both drained")
|
||||
except Exception as e:
|
||||
logp("cleanup err: %s" % str(e)[:100])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tests/reboot_probe.py — тест рестарта пода с in-flight сообщениями (план Соннета P0).
|
||||
|
||||
Фаза 1 (phase1): 10 очередей x 20 сообщений (проверка восстановления из Redis)
|
||||
+ основная очередь со 100 сообщениями, из которых 50 выдаются (in-flight).
|
||||
Фаза 2 (phase2, ПОСЛЕ kubectl delete pod --grace-period=0):
|
||||
- /health поднимается;
|
||||
- во всех 10 очередях должны быть ровно по 20 сообщений;
|
||||
- из основной должны выдаться все 100 уникальных тел (50 in-flight + 50 видимых).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
import urllib.request
|
||||
from botocore.config import Config
|
||||
|
||||
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
|
||||
REGION = os.environ.get("REGION", "us-east-1")
|
||||
STATE = "/tmp/reboot_state.json"
|
||||
|
||||
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
|
||||
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 3}))
|
||||
|
||||
|
||||
def wait_health(timeout=180):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
try:
|
||||
with urllib.request.urlopen(ENDPOINT + "/health", timeout=10) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
return False
|
||||
|
||||
|
||||
def drain(url):
|
||||
got = []
|
||||
while True:
|
||||
msgs = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10).get("Messages", [])
|
||||
if not msgs:
|
||||
break
|
||||
got.extend(msgs)
|
||||
return got
|
||||
|
||||
|
||||
def phase1():
|
||||
uid = str(uuid.uuid4())[:8]
|
||||
state = {"uid": uid, "restore_queues": [], "main_queue": None, "bodies": []}
|
||||
|
||||
# 10 очередей по 20 сообщений — проверка восстановления из Redis
|
||||
for i in range(10):
|
||||
u = sqs.create_queue(QueueName="reboot-restore-%d-%s" % (i, uid))["QueueUrl"]
|
||||
for j in range(20):
|
||||
sqs.send_message(QueueUrl=u, MessageBody="r-%d-%d-%s" % (i, j, uid))
|
||||
state["restore_queues"].append(u)
|
||||
print("restore queue %d ready" % i, flush=True)
|
||||
|
||||
# основная: 100 сообщений, 50 выдаём (in-flight)
|
||||
u = sqs.create_queue(QueueName="reboot-main-%s" % uid)["QueueUrl"]
|
||||
bodies = ["m-%03d-%s" % (i, uid) for i in range(100)]
|
||||
for b in bodies:
|
||||
sqs.send_message(QueueUrl=u, MessageBody=b)
|
||||
first = sqs.receive_message(QueueUrl=u, MaxNumberOfMessages=10, WaitTimeSeconds=1).get("Messages", [])
|
||||
# добираем до 50 in-flight (Max=10 за вызов)
|
||||
while len(first) < 50:
|
||||
batch = sqs.receive_message(QueueUrl=u, MaxNumberOfMessages=10, WaitTimeSeconds=1).get("Messages", [])
|
||||
if not batch:
|
||||
break
|
||||
first.extend(batch)
|
||||
print("in-flight на момент рестарта: %d" % len(first), flush=True)
|
||||
state["main_queue"] = u
|
||||
state["bodies"] = bodies
|
||||
json.dump(state, open(STATE, "w"))
|
||||
print("PHASE1_DONE", flush=True)
|
||||
|
||||
|
||||
def phase2():
|
||||
st = json.load(open(STATE))
|
||||
print("ждём /health после рестарта...", flush=True)
|
||||
if not wait_health():
|
||||
print("HEALTH_FAIL: /health не поднялся", flush=True)
|
||||
sys.exit(1)
|
||||
print("health ok", flush=True)
|
||||
|
||||
# восстановление 10 очередей
|
||||
restore_ok = True
|
||||
for u in st["restore_queues"]:
|
||||
msgs = drain(u)
|
||||
if len(msgs) != 20:
|
||||
restore_ok = False
|
||||
print("RESTORE FAIL: %s -> %d сообщений" % (u.split("/")[-1], len(msgs)), flush=True)
|
||||
print("restore: %s" % ("OK (10x20)" if restore_ok else "FAIL"), flush=True)
|
||||
|
||||
# основная: все 100 уникальных
|
||||
got = drain(st["main_queue"])
|
||||
got_bodies = {m["Body"] for m in got}
|
||||
want = set(st["bodies"])
|
||||
missing = want - got_bodies
|
||||
dups = len(got) - len(got_bodies)
|
||||
print("main: получено %d, уникальных %d, пропущено %d, дублей %d"
|
||||
% (len(got), len(got_bodies), len(missing), dups), flush=True)
|
||||
print("MAIN_RESULT: %s" % ("PASS" if not missing else "FAIL (потеря!)"), flush=True)
|
||||
|
||||
# cleanup
|
||||
for u in st["restore_queues"] + [st["main_queue"]]:
|
||||
try:
|
||||
sqs.delete_queue(QueueUrl=u)
|
||||
except Exception:
|
||||
pass
|
||||
print("PHASE2_DONE", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in ("phase1", "phase2"):
|
||||
print("usage: reboot_probe.py phase1|phase2")
|
||||
sys.exit(2)
|
||||
if sys.argv[1] == "phase1":
|
||||
phase1()
|
||||
else:
|
||||
phase2()
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tcp_mtu_probe.py — бинарный поиск MTU-порога по TCP к шлюзу Nubes.
|
||||
|
||||
Критерий: время до ответа nginx. Тело дошло → мгновенный ответ (<2с).
|
||||
Сегмент дропнут (MTU/PMTUD) → ответ не приходит (nginx ждёт тело) → считаем
|
||||
"не прошло" по таймауту FAST_TIMEOUT. Порог ищем делением пополам между
|
||||
LO и HI, потом уточняем повторными пробами вокруг порога.
|
||||
"""
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
|
||||
HOST = "185.247.187.151"
|
||||
PORT = 443 # реальный путь клиентов; 32391 (NodePort) снаружи закрыт
|
||||
LO = 512 # заведомо проходит
|
||||
HI = 20000 # заведомо не проходит (14+ сегментов по MSS)
|
||||
FAST_TIMEOUT = 20 # быстрее полного зависания ~51с
|
||||
FAST_OK = 2.0 # ответ быстрее этого = прошло
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def probe(n):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(FAST_TIMEOUT)
|
||||
t0 = time.time()
|
||||
try:
|
||||
s.connect((HOST, PORT))
|
||||
mss = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_MAXSEG)
|
||||
ss = ctx.wrap_socket(s, server_hostname="sqs.containerk8s.dev.nubes.ru")
|
||||
body = b"x" * n
|
||||
req = (b"POST / HTTP/1.1\r\n"
|
||||
b"Host: sqs.containerk8s.dev.nubes.ru\r\n"
|
||||
b"Content-Type: application/x-www-form-urlencoded\r\n"
|
||||
b"Content-Length: " + str(n).encode() + b"\r\n"
|
||||
b"Connection: close\r\n\r\n" + body)
|
||||
ss.sendall(req)
|
||||
data = ss.recv(4096)
|
||||
dt = time.time() - t0
|
||||
first = data.split(b"\r\n")[0][:40].decode("latin1", "replace")
|
||||
return dt, mss, first
|
||||
except Exception as e:
|
||||
return time.time() - t0, None, "%s" % type(e).__name__
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def passed(n):
|
||||
dt, mss, resp = probe(n)
|
||||
ok = dt < FAST_OK
|
||||
print(" size=%6d: %.2fs mss=%s %s -> %s" %
|
||||
(n, dt, mss if mss else "-", resp, "OK" if ok else "DROP"), flush=True)
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
# санпроверка границ
|
||||
print("check LO=%d:" % LO)
|
||||
if not passed(LO):
|
||||
print("LO не проходит — границы неверны, выход")
|
||||
return
|
||||
print("check HI=%d:" % HI)
|
||||
if passed(HI):
|
||||
print("HI тоже проходит мгновенно — порога нет в диапазоне, выход")
|
||||
return
|
||||
|
||||
lo, hi = LO, HI
|
||||
while hi - lo > 50:
|
||||
mid = (lo + hi) // 2
|
||||
if passed(mid):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
print("\nпорог найден: lo=%d hi=%d (проходит <=lo, дроп >=hi)" % (lo, hi))
|
||||
|
||||
# уточнение вокруг порога: 3 пробы на каждую из 4 точек рядом
|
||||
print("\nуточнение (по 3 пробы):")
|
||||
for n in (lo - 200, lo, (lo + hi) // 2, hi + 200):
|
||||
res = sum(1 for _ in range(3) if passed(max(n, 1)))
|
||||
print(" size=%d: %d/3 прошли" % (n, res))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user