Author SHA1 Message Date
Repinoid 533c107075 chore: update ignore rules 2026-10-27 14:08:53 +03:00
159 changed files with 11087 additions and 11409 deletions
+84 -13
View File
@@ -1,20 +1,91 @@
# Правила (кратко) # Правила работы агента в этом проекте
## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ ## ГЛАВНОЕ ПРАВИЛО
- Только проверенные факты. Не проверено — писать «не проверено».
## ⛔ ОТВЕЧАТЬ КРАТКО **НЕ "СОВЕРШЕНСТВОВАТЬ" РАБОЧИЙ КОД БЕЗ ЯВНОГО УКАЗАНИЯ.**
- Вопрос → короткий ответ → СТОП. Код — только по запросу.
## ⛔⛔⛔ ВОПРОС = СТОП ---
- Любой вопрос → только ответить, ждать «делай». Не начинать работу без команды.
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД ## ЗАПРЕТ НА ВЫДУМКИ
- Никаких изменений кода/инфраструктуры без явного «делай».
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРИКАЗАНО **КАТЕГОРИЧЕСКИ ЗАПРЕЩАЕТСЯ придумывать, догадываться или предполагать:**
- Без додумывания, без «логичных следующих шагов». - значения параметров, которые не видны в коде или документации
- допустимые значения enum/ролей/типов — если не взяты из реального источника
- поведение API, провайдеров, библиотек — если не подтверждено кодом или документацией
- любые факты о системе, которые агент "знает" из общих соображений
## ЕСЛИ ПРАВИШЬ КОД **Если информации нет — спросить у пользователя. Не угадывать.**
- Сначала прочитай /home/naeel/nubes/SQS-service/.github/pravila.md (там: git локально + после пуша синк на ВМ, docker/ssh на ВМ, деплой).
Если код работает — не трогать. Никаких:
- рефакторингов "попутно"
- улучшений стиля
- добавления комментариев / docstring
- переименований переменных
- "пока уж заодно поправлю"
Делай только то, о чём явно попросили. Ничего лишнего.
---
## Комментарии в коде
Комментарии — обязательны:
- В начале каждого файла при создании или правке — дата и время изменения
- На каждой функции/методе — краткое назначение
- На нетривиальной логике — **почему** сделано именно так (не "что делает", а "зачем")
Цель: любой агент в новом чате должен понять логику без дополнительных вопросов.
---
## Темп работы
Не спешить. Перед каждым шагом — убедиться что предыдущий понят и согласован.
---
## Документация
Всё важное фиксировать в `doc/`:
- `doc/architecture/` — архитектура, стек, схемы
- `doc/api/` — дизайн API
- `doc/decisions/` — принятые решения с обоснованием
- `doc/infrastructure/` — инфраструктура, кластер, сервисы
- `doc/errors/` — ошибки и как решили
- `doc/progress.md` — трекер задач
Обновлять после каждого значимого изменения.
---
## Именование
Имена должны быть **уникальными и осмысленными по всему проекту**:
- имена файлов
- имена функций/методов
- имена переменных/констант
- имена ресурсов (Terraform, Kubernetes и т.д.)
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
---
## Лог мышления (обязательно)
Каждый агент в каждом чате **обязан** вести лог своих рассуждений:
- Папка: `doc/thinking/`
- Файл: `ГГГГ-ММ-ДД.md` (по дате сессии)
- В начале файла указать имя агента и модель
- Если файл на текущую дату уже существует — дописывать в конец, добавив разделитель `---` и имя агента
- Записывать **полный** ход мыслей: что анализирую, какие гипотезы, что нашёл, что отбросил, к чему пришёл, почему
- Записывать **до** начала действий (план) и **после** (результат)
Цель: пользователь должен видеть весь процесс рассуждений в читаемом виде.
---
## Git
Коммитить и пушить после каждого завершённого этапа.
-126
View File
@@ -1,126 +0,0 @@
# Правила работы агента
## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ
**АБСОЛЮТНОЕ ПРАВИЛО:**
- Не выдавать предположения/допущения за факты.
- Говорить только то, что ПРОВЕРЕНО (прочитано, выполнено, подтверждено).
- Если не проверено — прямо писать «не проверено», не додумывать.
- Не строить выводы о реальном состоянии системы на основе манифестов/доков.
## ⛔⛔⛔ ВОПРОС = СТОП
**Если в сообщении есть вопрос в ЛЮБОЙ форме:**
1. ТОЛЬКО ответить на вопрос
2. ОСТАНОВИТЬСЯ
3. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
> ⚠️ ЛЕГАСИ — старый k8s-деплой (kubectl apply). Уходим на Nubes Managed. ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/deployment.yaml` (vX.Y.Z → vX.Y.Z+1)
2. rsync на ВМ
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
4. docker build с НОВЫМ тегом
5. docker push с НОВЫМ тегом
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
## Файловая система (актуально)
1. Все файлы редактируются локально: `/home/naeel/nubes/SQS-service`
2. После любых изменений — обязательно rsync на ВМ:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
/home/naeel/nubes/SQS-service/ \
naeel@5.172.178.213:~/terra/SQS-service/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО. После пуша — ОБЯЗАТЕЛЬНО синк на ВМ (rsync).
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
7. Только rsync для синхронизации
Пример:
1. Редактируешь локально
2. git add/commit/push ЛОКАЛЬНО
3. rsync на ВМ (после пуша)
4. Выполняешь команды через SSH на ВМ
## SSH
Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы.
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, любые скрипты проекта. (git — разрешён локально)
## Документация
- `doc/` — документация проекта
- Обновлять после каждого значимого изменения
- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой
## Git
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ЛОКАЛЬНО в /home/naeel/nubes/SQS-service. После push — синк на ВМ (rsync).
- Разрешены операции: `git add`, `git commit`, `git push` (локально).
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме add/commit/push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
Версионирование тагами: `vMAJOR.MINOR.PATCH`
- Patch — любое изменение кода
- Minor — новая фича / компонент
- Major — breaking change
```bash
git tag vX.Y.Z && git push origin vX.Y.Z
```
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
**АБСОЛЮТНОЕ ПРАВИЛО:**
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОНОВ — В РЕАЛЬНОМ ВРЕМЕНИ
**Правила:**
1. Перед запуском тестов — создать файл `tests/results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
2. Запускать тесты с `2>&1 | tee ~/terra/SQS-service/tests/results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
3. После завершения — rsync лога локально. Лог остаётся как документация.
4. Папка `tests/results/` в репозитории — логи коммитить.
**Формат запуска:**
```bash
LOG="tests/results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/SQS-service/tests/run_tests.sh 2>&1 | tee ~/terra/SQS-service/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/SQS-service/tests/results/ /home/naeel/nubes/SQS-service/tests/results/
```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
- Все изменения — только через код (манифесты, Go-код) + сборка + деплой.
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
- Исключение: только если пользователь явно написал "примени ручной патч".
## Поведение агента
- Не трогать рабочий код без явного указания
- Не делать НИЧЕГО сверх того, о чём явно приказали — ни git-команд, ни rebase, ни дополнительных шагов
- Если для продолжения нужен выбор — СПРОСИТЬ разрешения, не делать самостоятельно
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
+1 -9
View File
@@ -1,8 +1,5 @@
# Binaries # Binaries
/shared-sqs shared-sqs
# Публичная репа (независимый git repo, не трекается)
/shared-SQS/
*.exe *.exe
*.dll *.dll
*.dylib *.dylib
@@ -30,11 +27,6 @@ build/
# Secrets # Secrets
secrets/ secrets/
goaws goaws
cmd
# Python cache
__pycache__/
*.pyc
/HISTORY/2026-08-13-session-log.md /HISTORY/2026-08-13-session-log.md
/HISTORY/2026-08-13-sonnet-answer-v2.md /HISTORY/2026-08-13-sonnet-answer-v2.md
/HISTORY/2026-08-14-session-log.md /HISTORY/2026-08-14-session-log.md
@@ -1,8 +1,3 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Исторические сравнительные тесты vs Yandex MQ (апрель 2026).
Перенесён из корня репозитория в doc/legacy 2026-08-13.
Замеры устарели, только для справки. -->
# Сравнительный анализ shared-sqs vs Yandex MQ # Сравнительный анализ shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 06:05 UTC **Дата:** 2026-04-12 06:05 UTC
+3 -8
View File
@@ -1,23 +1,18 @@
# Dockerfile — shared-sqs multi-stage build # Dockerfile — shared-sqs multi-stage build
# Updated: 2026-04-12 — Go 1.23 (required by prometheus client) # Updated: 2026-04-09
FROM golang:1.23-alpine AS builder FROM golang:1.22-alpine AS builder
WORKDIR /build WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG VERSION=dev RUN CGO_ENABLED=0 go build -o shared-sqs ./app/cmd/
RUN CGO_ENABLED=0 go build -ldflags "-X shared-sqs/app/models.Version=${VERSION}" -o shared-sqs ./app/cmd/
FROM alpine:3.19 FROM alpine:3.19
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates
COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs
COPY --from=builder /build/app/conf/goaws.yaml /conf/goaws.yaml COPY --from=builder /build/app/conf/goaws.yaml /conf/goaws.yaml
EXPOSE 4100 EXPOSE 4100
# Нечувствительные значения зашиты в образ. Секреты (SHARED_SQS_ADMIN_TOKEN, REDIS_PASSWORD) — env'ом контейнера в deck.
ENV REDIS_ADDR=redisk8s.4ff5678b-b683-4aef-91da-22d89dec8a25.svc.cluster.local:6379 \
REDIS_USER=default \
SHARED_SQS_SEED_DEMO=false
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \ HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD wget -q -O - http://localhost:4100/health || exit 1 CMD wget -q -O - http://localhost:4100/health || exit 1
ENTRYPOINT ["shared-sqs", "--config", "/conf/goaws.yaml"] ENTRYPOINT ["shared-sqs", "--config", "/conf/goaws.yaml"]
-96
View File
@@ -1,96 +0,0 @@
# 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 запрещён (только редакторы файлов).
-151
View File
@@ -1,151 +0,0 @@
# История правок 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`.
-290
View File
@@ -1,290 +0,0 @@
# История правок 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 под глобальным локом (рефакторинг).
-124
View File
@@ -1,124 +0,0 @@
# История правок 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`.
-169
View File
@@ -1,169 +0,0 @@
# МАНУАЛ: что делать дальше (состояние на 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) |
+5 -9
View File
@@ -1,25 +1,21 @@
# Makefile — shared-sqs # Makefile — shared-sqs
# Created: 2026-04-09 # Created: 2026-04-09
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда. # Registry: Docker Hub (naeel/shared-sqs) — pearlharbor не используется (нестабилен)
# Проверено 2026-10-11: gitea.services.ngcloud.ru/nail/shared-sqs существует (login naeel).
REGISTRY ?= gitea.services.ngcloud.ru/nail IMAGE_REPO=naeel/shared-sqs
IMAGE_REPO=$(REGISTRY)/shared-sqs VERSION=v0.1.0
VERSION=v0.1.35
LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION)
BINARY=shared-sqs BINARY=shared-sqs
.PHONY: build docker-build docker-push test run clean .PHONY: build docker-build docker-push test run clean
build: build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./app/cmd/ CGO_ENABLED=0 go build -o $(BINARY) app/cmd/goaws.go
docker-build: docker-build:
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE_REPO):$(VERSION) -t $(IMAGE_REPO):latest . docker build -t $(IMAGE_REPO):$(VERSION) .
docker-push: docker-push:
docker push $(IMAGE_REPO):$(VERSION) docker push $(IMAGE_REPO):$(VERSION)
docker push $(IMAGE_REPO):latest
test: test:
go test ./... go test ./...
@@ -1,8 +1,3 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Старая памятка для агента/нового чата (апрель 2026).
Перенесена из корня репозитория в doc/legacy 2026-08-13.
Описывает устаревший k8s-деплой и порядок работы. -->
# ONBOARDING.md — Памятка для нового чата / агента # ONBOARDING.md — Памятка для нового чата / агента
# Дата: 2026-04-10 # Дата: 2026-04-10
# Автор: GitHub Copilot (Claude Opus 4.6) # Автор: GitHub Copilot (Claude Opus 4.6)
+1 -6
View File
@@ -1,8 +1,3 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Исторический план реализации shared-sqs (апрель 2026).
Перенесён из корня репозитория в doc/legacy 2026-08-13.
Содержит устаревшие схемы и ссылки (pearlharbor, sqs-operator). -->
# shared-sqs — План реализации # shared-sqs — План реализации
**Дата:** 2026-04-09 **Дата:** 2026-04-09
@@ -850,7 +845,7 @@ shared-sqs/
- ❌ UI — нет UI, только API - ❌ UI — нет UI, только API
- ❌ Scale-to-zero — один pod всегда работает - ❌ Scale-to-zero — один pod всегда работает
ENDOFPLAN cat > ~/terra/sless/shared-sqs/PLAN.md << 'ENDOFPLAN' ENDOFPLAN cat > ~/terra/sless/shared-sqs/PLAN.md << 'ENDOFPLAN'
# shared-sqs — План реализации (heredoc-дубль) # shared-sqs — План реализации
**Дата:** 2026-04-09 **Дата:** 2026-04-09
**Исполнитель:** Claude Sonnet (или другой агент) **Исполнитель:** Claude Sonnet (или другой агент)
+22 -30
View File
@@ -8,34 +8,29 @@
- **Многотенантность** — каждый тенант имеет отдельный Access Key и Secret Key - **Многотенантность** — каждый тенант имеет отдельный Access Key и Secret Key
- **Redis persistence** — очереди и сообщения сохраняются при перезапуске сервиса - **Redis persistence** — очереди и сообщения сохраняются при перезапуске сервиса
- **Web UI Console** — управление тенантами и просмотр сообщений - **Web UI Console** — управление тенантами и просмотр сообщений
- **Managed-ready** — деплой Docker-образом через Nubes («Простой HTTP контейнер»), health-check /health - **Kubernetes-ready** — Helm/манифесты, TLS Ingress, здоровье-checks
- **Admin API** — управление тенантами программно - **Admin API** — управление тенантами программно
## Деплой (Nubes Managed) ## Быстрый старт
Сервис деплоится как Docker-образ через «Простой HTTP контейнер» (deck-UI) + Managed Redis. ### Локально
Никакого Kubernetes/VPS.
### 1. Собрать и запушить образ в Gitea registry
```bash ```bash
docker build -t gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25 . docker run -d -p 9090:9090 naeel/shared-sqs:latest
docker login gitea.services.ngcloud.ru -u ntazetdinov -p <токен> # Откройте http://localhost:9090
docker push gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25
``` ```
### 2. Создать контейнер в deck-UI ### На Kubernetes
- Сервис: «Простой HTTP контейнер» ```bash
- Образ: `gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25` kubectl apply -k deployments/k8s/
- Realm: `iot-naeel` ```
- Env (СРАЗУ, потом не меняются): `SHARED_SQS_ADMIN_TOKEN`, `REDIS_PASSWORD`
### Локальный запуск ### Сборка локально
```bash ```bash
go build -o shared-sqs ./app/cmd go build -o shared-sqs ./app/cmd
./shared-sqs --port 4100 ./shared-sqs --port 9090
``` ```
## Архитектура ## Архитектура
@@ -52,28 +47,26 @@ app/
├── router/ — HTTP маршруты ├── router/ — HTTP маршруты
└── ui/ — Web Console (HTML/JS) └── ui/ — Web Console (HTML/JS)
deployments/k8s/ — ЛЕГАСИ (старый k8s-деплой, не использовать) deployments/k8s/
deployments/helm/ — ЛЕГАСИ (helm chart, не использовать) ├── deployment.yaml — Deployment с Redis sidecar
├── ingress.yaml — TLS, qu.kube5s.ru
└── redis.yaml — Redis Persistent Volume
``` ```
## Окружение ## Окружение
| Переменная | Значение | Описание | | Переменная | Значение | Описание |
|-----------|---------|---------| |-----------|---------|---------|
| `PORT` (--port) | 4100 | Порт HTTP сервера | | `PORT` | 9090 | Порт HTTP сервера |
| `REDIS_ADDR` | host:6379 | Redis для persistence | | `REDIS_URL` | localhost:6379 | Redis для persistence |
| `REDIS_USER` | default | Redis username |
| `REDIS_PASSWORD` | — | Redis password (секрет) |
| `SHARED_SQS_ADMIN_TOKEN` | — | Admin API bearer token (обязательный) |
| `SHARED_SQS_SEED_DEMO` | true/false | Загрузить demo данные на старте | | `SHARED_SQS_SEED_DEMO` | true/false | Загрузить demo данные на старте |
| `NUBES_ENDPOINT` | https://deck-api.ngcloud.ru/api/v1 | JWT-валидация UI |
## API примеры ## API примеры
### Создать очередь (как тенант) ### Создать очередь (как тенант)
```bash ```bash
curl -X POST "http://localhost:4100/" \ curl -X POST "http://localhost:9090/" \
-H "Authorization: AWS4-HMAC-SHA256 Credential=SSAK-demo-shared-sqs/20260410/us-east-1/sqs/aws4_request" \ -H "Authorization: AWS4-HMAC-SHA256 Credential=SSAK-demo-shared-sqs/20260410/us-east-1/sqs/aws4_request" \
-d "Action=CreateQueue&QueueName=myqueue&Version=2012-11-05" -d "Action=CreateQueue&QueueName=myqueue&Version=2012-11-05"
``` ```
@@ -82,8 +75,8 @@ curl -X POST "http://localhost:4100/" \
```bash ```bash
aws sqs send-message \ aws sqs send-message \
--endpoint-url http://localhost:4100 \ --endpoint-url http://localhost:9090 \
--queue-url http://localhost:4100/demo-shared-sqs/myqueue \ --queue-url http://localhost:9090/demo-shared-sqs/myqueue \
--message-body "Hello World" --message-body "Hello World"
``` ```
@@ -95,12 +88,11 @@ aws sqs send-message \
## Документация ## Документация
- [PLAN.md](doc/legacy/PLAN.md) — ЛЕГАСИ, исторический план разработки - [PLAN.md](PLAN.md) — детальный план разработки
- [doc/billing-and-metrics.md](doc/billing-and-metrics.md) — биллинг и Prometheus-метрики
- [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция - [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция
## Ссылки ## Ссылки
- Репо: https://gitea.services.ngcloud.ru/Nail/SQS-service - Репо: https://gitea.services.ngcloud.ru/Nail/SQS-service
- Registry: https://gitea.services.ngcloud.ru/nail/shared-sqs - Demo (dev): https://qu.kube5s.ru
- Основана на: [GoAws](https://github.com/Admiral-Piett/goaws) - Основана на: [GoAws](https://github.com/Admiral-Piett/goaws)
+152 -168
View File
@@ -1,24 +1,25 @@
// app/admin/admin.go // app/admin/admin.go
// Admin API handlers for shared-sqs management // Admin API handlers for shared-sqs management
// Created: 2026-04-09 // Created: 2026-04-09
// Updated: 2026-04-12 10:12 MSK — пометки о временном demo showcase режиме // Updated: 2026-04-12 09:28 MSK — demo UI token и изоляция UI API одним tenant-ом
package admin package admin
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time" "time"
"shared-sqs/app/auth" "shared-sqs/app/auth"
"shared-sqs/app/gosqs"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence" "shared-sqs/app/persistence"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
"github.com/google/uuid"
"github.com/gorilla/mux" "github.com/gorilla/mux"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -29,6 +30,14 @@ const (
uiTenantContextKey uiContextKey = "ui-tenant" uiTenantContextKey uiContextKey = "ui-tenant"
) )
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 если не найдено. // findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) { func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
@@ -52,11 +61,49 @@ type Handler struct {
// NewHandler — создаёт admin handler // NewHandler — создаёт admin handler
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler { func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
nubesEndpoint := os.Getenv("NUBES_ENDPOINT") nubesEndpoint := os.Getenv("NUBES_ENDPOINT")
// Если не задан — пустой: PingNubesAPI переберёт все стенды сам. if nubesEndpoint == "" {
auth.StartTokenCacheCleanup() // фоновая очистка кэша валидации (идемпотентно) nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint} return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
} }
// uiDemoToken — возвращает публичный demo token для UI, если он не переопределён через env.
func (h *Handler) uiDemoToken() string {
if token := os.Getenv("SHARED_SQS_UI_DEMO_TOKEN"); token != "" {
return token
}
return defaultUIDemoToken
}
// uiDemoTenantID — возвращает tenant ID, к которому привязан demo token.
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.
func (h *Handler) uiDemoEmail() string {
if email := os.Getenv("SHARED_SQS_UI_DEMO_EMAIL"); email != "" {
return email
}
return defaultUIDemoEmail
}
// authenticateUIDemoToken — маппит публичный demo token на заранее сидированный demo tenant.
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. // currentUITenant — возвращает tenant, авторизованный через UI middleware.
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) { func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant) t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
@@ -102,7 +149,6 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
// jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint // jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint
ui.Use(h.jwtMiddleware) ui.Use(h.jwtMiddleware)
ui.HandleFunc("/auth", h.jwtAuth).Methods("POST") ui.HandleFunc("/auth", h.jwtAuth).Methods("POST")
ui.HandleFunc("/credentials", h.uiCredentials).Methods("GET")
ui.HandleFunc("/health", h.detailedHealth).Methods("GET") ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
ui.HandleFunc("/tenants", h.listTenants).Methods("GET") ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
ui.HandleFunc("/tenants", h.createTenant).Methods("POST") ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
@@ -120,12 +166,8 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler { func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
// Timing-safe сравнение: длина префикса и сам токен проверяются отдельно,
// само сравнение значения — через subtle.ConstantTimeCompare, чтобы не
// давать timing side-channel при подборе admin-токена.
expected := "Bearer " + h.adminToken expected := "Bearer " + h.adminToken
if len(authHeader) != len(expected) || if authHeader != expected {
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
@@ -147,6 +189,24 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
return 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 // Парсим JWT claims
claims, err := auth.ParseJWTClaims(req.Token) claims, err := auth.ParseJWTClaims(req.Token)
if err != nil { if err != nil {
@@ -155,14 +215,6 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
// Email — единственный ключ идентичности. Без него не создаём тенанта
// (иначе hash("") даст один тенант на всех пользователей без email).
if claims.Email == "" {
log.Warnf("jwt auth: token for sub=%s has no email claim", claims.Sub)
jsonErr(w, http.StatusBadRequest, "token has no email claim")
return
}
// Валидируем через nubes API // Валидируем через nubes API
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel() defer cancel()
@@ -171,13 +223,10 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error()) jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return return
} }
// Логин — всегда свежая проверка; после успеха прогреваем кэш,
// чтобы дальнейшие запросы консоли не дёргали нестабильный шлюз.
auth.WarmTokenCache(req.Token)
// Auto-provisioning: тенант и ключи детерминированы из email. // Auto-provisioning: создаём тенанта если не существует
// Токен — только аутентификация; ключи в ответ не возвращаем (GET /ui/api/credentials). tenantID := auth.TenantIDFromSub(claims.Sub)
t, err := h.store.CreateFromJWT(claims.Sub, claims.Email, tenant.DefaultTenantMaxQueues) t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10)
if err != nil { if err != nil {
log.Errorf("jwt auth: failed to create tenant: %v", err) log.Errorf("jwt auth: failed to create tenant: %v", err)
jsonErr(w, http.StatusInternalServerError, "failed to provision tenant") jsonErr(w, http.StatusInternalServerError, "failed to provision tenant")
@@ -190,25 +239,10 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"email": claims.Email, "email": claims.Email,
"tenant_id": t.ID, "tenant_id": t.ID,
"max_queues": t.MaxQueues,
"token": req.Token, // возвращаем для использования в последующих запросах
})
}
// uiCredentials — GET /ui/api/credentials: отдаёт AccessKey/SecretKey текущего тенанта.
// Ключи НЕ светятся в других ответах UI API — только по явному запросу под JWT-сессией.
func (h *Handler) uiCredentials(w http.ResponseWriter, r *http.Request) {
t, ok := currentUITenant(r)
if !ok {
jsonErr(w, http.StatusUnauthorized, "unauthorized")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"tenant_id": t.ID,
"email": t.Email,
"access_key": t.AccessKey, "access_key": t.AccessKey,
"secret_key": t.SecretKey, "secret_key": t.SecretKey,
"max_queues": t.MaxQueues,
"token": req.Token, // возвращаем для использования в последующих запросах
}) })
} }
@@ -219,10 +253,8 @@ func (h *Handler) uiCredentials(w http.ResponseWriter, r *http.Request) {
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth. // НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler { func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT. // /ui/api/auth — публичный endpoint, пропускаем без проверки JWT
// Сравниваем ТОЧНЫЙ путь, а не суффикс "/auth": иначе, например, if strings.HasSuffix(r.URL.Path, "/auth") {
// DELETE /ui/api/tenants/{id}/queues/auth пропускался бы без JWT и IDOR-проверки.
if r.URL.Path == "/ui/api/auth" {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@@ -239,24 +271,36 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
} }
token := parts[1] 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) claims, err := auth.ParseJWTClaims(token)
if err != nil { if err != nil {
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error()) jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
return return
} }
// Повторная валидация через кэш: в течение TTL шлюз не вызывается // Повторно валидируем токен в nubes API на каждый UI API запрос,
// (шлюз нестабилен с IP платформы), отозванный токен умрёт через TTL, // чтобы отозванные токены не оставались валидными до повторного логина.
// явный 401/403 от шлюза сбрасывает кэш.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()
if err := auth.PingNubesAPICached(ctx, h.nubesEndpoint, token); err != nil { if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, token); err != nil {
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error()) jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return return
} }
// Проверяем что тенант существует (был создан при /ui/api/auth) // Проверяем что тенант существует (был создан при /ui/api/auth)
jwtTenant, ok := h.store.GetByEmail(claims.Email) jwtTenant, ok := h.store.GetBySub(claims.Sub)
if !ok { if !ok {
jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth") jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth")
return return
@@ -277,10 +321,6 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
type createTenantRequest struct { type createTenantRequest struct {
Name string `json:"name"` Name string `json:"name"`
MaxQueues int `json:"max_queues"` MaxQueues int `json:"max_queues"`
// BYOC: необязательные произвольные креды. Если задано одно поле —
// оба обязательны.
AccessKey string `json:"access_key,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
} }
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании) // tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
@@ -323,23 +363,7 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"}) json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
return 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 { if err != nil {
log.Errorf("admin: failed to create tenant: %v", err) log.Errorf("admin: failed to create tenant: %v", err)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -407,14 +431,15 @@ func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
return return
} }
// Удаляем все очереди тенанта из SyncQueues (через tenant-индекс, без скана всех очередей) // Удаляем все очереди тенанта из SyncQueues
prefix := t.AccessKey + ":"
deletedQueueKeys := make([]string, 0)
models.SyncQueues.Lock() models.SyncQueues.Lock()
tenantQueueNames := models.SyncQueues.TenantQueueNames(t.AccessKey) for key := range models.SyncQueues.Queues {
deletedQueueKeys := make([]string, 0, len(tenantQueueNames)) if strings.HasPrefix(key, prefix) {
for _, name := range tenantQueueNames {
key := t.AccessKey + ":" + name
deletedQueueKeys = append(deletedQueueKeys, key) deletedQueueKeys = append(deletedQueueKeys, key)
models.SyncQueues.RemoveQueue(key) delete(models.SyncQueues.Queues, key)
}
} }
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
for _, queueKey := range deletedQueueKeys { for _, queueKey := range deletedQueueKeys {
@@ -447,42 +472,30 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
RetentionPeriod int `json:"retention_period"` RetentionPeriod int `json:"retention_period"`
} }
queues := make([]queueInfo, 0) queues := make([]queueInfo, 0)
type nameQueue struct {
name string
q *models.Queue
}
models.SyncQueues.RLock() models.SyncQueues.RLock()
pairs := make([]nameQueue, 0) for key, q := range models.SyncQueues.Queues {
for _, name := range models.SyncQueues.TenantQueueNames(t.AccessKey) { if strings.HasPrefix(key, prefix) {
if q := models.SyncQueues.Queues[prefix+name]; q != nil { name := strings.TrimPrefix(key, prefix)
pairs = append(pairs, nameQueue{name, q})
}
}
models.SyncQueues.RUnlock()
for _, p := range pairs {
p.q.RLockMu()
visible := 0 visible := 0
notVisible := 0 notVisible := 0
for i := range p.q.Messages { for _, msg := range q.Messages {
if p.q.IsDead(i) { if msg.ReceiptHandle != "" {
continue
}
if p.q.Messages[i].ReceiptHandle != "" {
notVisible++ notVisible++
} else { } else {
visible++ visible++
} }
} }
queues = append(queues, queueInfo{ queues = append(queues, queueInfo{
Name: p.name, Name: name,
Messages: visible, Messages: visible,
NotVisible: notVisible, NotVisible: notVisible,
VisibilityTimeout: p.q.VisibilityTimeout, VisibilityTimeout: q.VisibilityTimeout,
MaxMessageSize: p.q.MaximumMessageSize, MaxMessageSize: q.MaximumMessageSize,
RetentionPeriod: p.q.MessageRetentionPeriod, RetentionPeriod: q.MessageRetentionPeriod,
}) })
p.q.RUnlockMu()
} }
}
models.SyncQueues.RUnlock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queues) json.NewEncoder(w).Encode(queues)
} }
@@ -510,12 +523,6 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusBadRequest, "name is required") jsonErr(w, http.StatusBadRequest, "name is required")
return 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 key := t.AccessKey + ":" + req.Name
models.SyncQueues.Lock() models.SyncQueues.Lock()
if _, exists := models.SyncQueues.Queues[key]; exists { if _, exists := models.SyncQueues.Queues[key]; exists {
@@ -523,18 +530,27 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusConflict, "queue already exists") jsonErr(w, http.StatusConflict, "queue already exists")
return return
} }
// Проверяем лимит очередей тенанта (через tenant-индекс, O(1)) // Проверяем лимит очередей тенанта
count := models.SyncQueues.CountTenantQueues(t.AccessKey) count := 0
for k := range models.SyncQueues.Queues {
if strings.HasPrefix(k, t.AccessKey+":") {
count++
}
}
if t.MaxQueues > 0 && count >= t.MaxQueues { if t.MaxQueues > 0 && count >= t.MaxQueues {
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
jsonErr(w, http.StatusForbidden, "queue limit exceeded") jsonErr(w, http.StatusForbidden, "queue limit exceeded")
return return
} }
// Очередь создаётся через общий билдер — те же URL/ARN/дефолты и FIFO-детект, models.SyncQueues.Queues[key] = &models.Queue{
// что и в SQS CreateQueue (единый путь, без расхождения поведения UI и API). Name: req.Name,
newQueue := gosqs.BuildQueueForTenant(t, req.Name) VisibilityTimeout: 30,
models.SyncQueues.AddQueue(key, newQueue) MaximumMessageSize: 262144,
persistence.SaveQueue(key, newQueue) MessageRetentionPeriod: 345600,
Messages: []models.SqsMessage{},
Duplicates: make(map[string]time.Time),
}
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID) log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -559,7 +575,7 @@ func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusNotFound, "queue not found") jsonErr(w, http.StatusNotFound, "queue not found")
return return
} }
models.SyncQueues.RemoveQueue(key) delete(models.SyncQueues.Queues, key)
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
persistence.DeleteQueue(key) persistence.DeleteQueue(key)
log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID) log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID)
@@ -608,16 +624,12 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
q.RLockMu() models.SyncQueues.RLock()
result := make([]peekMessageItem, 0, limit) result := make([]peekMessageItem, 0, len(q.Messages))
for i := range q.Messages { for i, msg := range q.Messages {
if len(result) >= limit { if i >= limit {
break break
} }
if q.IsDead(i) {
continue
}
msg := &q.Messages[i]
result = append(result, peekMessageItem{ result = append(result, peekMessageItem{
ID: msg.Uuid, ID: msg.Uuid,
Body: msg.MessageBody, Body: msg.MessageBody,
@@ -627,7 +639,7 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
InFlight: msg.ReceiptHandle != "", InFlight: msg.ReceiptHandle != "",
}) })
} }
q.RUnlockMu() models.SyncQueues.RUnlock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(result)
} }
@@ -658,30 +670,17 @@ func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusBadRequest, "body is required") jsonErr(w, http.StatusBadRequest, "body is required")
return return
} }
// Сообщение строится через общий билдер (UUID + MD5), + проверка размера и msg := models.SqsMessage{
// лимита — как в SQS SendMessage (иначе UI обходил бы OOM-защиту). MessageBody: req.Body,
q.LockMu() Uuid: uuid.NewString(),
if q.MaximumMessageSize > 0 && len(req.Body) > q.MaximumMessageSize { SentTime: time.Now(),
q.UnlockMu()
jsonErr(w, http.StatusBadRequest, "message too big")
return
} }
if q.LiveCount() >= gosqs.MaxMessagesForQueue(q.IsFIFO) { models.SyncQueues.Lock()
q.UnlockMu() models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
jsonErr(w, http.StatusTooManyRequests, "queue is full") // Персистим одно сообщение отдельно
return persistence.SaveMessage(key, &msg)
} persistence.SaveQueue(key, models.SyncQueues.Queues[key])
msg := gosqs.BuildSqsMessage(req.Body) models.SyncQueues.Unlock()
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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"}) json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"})
@@ -702,12 +701,12 @@ func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusNotFound, "queue not found") jsonErr(w, http.StatusNotFound, "queue not found")
return return
} }
q.LockMu() models.SyncQueues.Lock()
q.Reset() models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0]
// Удаляем все сообщения из Redis одной командой // Удаляем все сообщения из Redis одной командой
persistence.PurgeMessagesPersist(key) persistence.PurgeMessagesPersist(key)
persistence.SaveQueue(key, q) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
q.UnlockMu() models.SyncQueues.Unlock()
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID) log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -722,7 +721,6 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
// adminHealthDetail — ответ GET /admin/health // adminHealthDetail — ответ GET /admin/health
type adminHealthDetail struct { type adminHealthDetail struct {
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"`
TenantCount int `json:"tenant_count"` TenantCount int `json:"tenant_count"`
QueueCount int `json:"queue_count"` QueueCount int `json:"queue_count"`
MessageCount int `json:"message_count"` MessageCount int `json:"message_count"`
@@ -735,24 +733,17 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
queueCount := 0 queueCount := 0
msgCount := 0 msgCount := 0
models.SyncQueues.RLock() models.SyncQueues.RLock()
uiQueues := make([]*models.Queue, 0)
for key, q := range models.SyncQueues.Queues { for key, q := range models.SyncQueues.Queues {
if strings.HasPrefix(key, prefix) { if strings.HasPrefix(key, prefix) {
queueCount++ queueCount++
uiQueues = append(uiQueues, q) msgCount += len(q.Messages)
} }
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
for _, q := range uiQueues {
q.RLockMu()
msgCount += q.LiveCount()
q.RUnlockMu()
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{ json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok", Status: "ok",
Version: models.Version,
TenantCount: 1, TenantCount: 1,
QueueCount: queueCount, QueueCount: queueCount,
MessageCount: msgCount, MessageCount: msgCount,
@@ -762,22 +753,15 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
tenants := h.store.List() tenants := h.store.List()
models.SyncQueues.RLock() models.SyncQueues.RLock()
queueCount := len(models.SyncQueues.Queues) queueCount := len(models.SyncQueues.Queues)
allQueues := make([]*models.Queue, 0, queueCount) msgCount := 0
for _, q := range models.SyncQueues.Queues { for _, q := range models.SyncQueues.Queues {
allQueues = append(allQueues, q) msgCount += len(q.Messages)
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
msgCount := 0
for _, q := range allQueues {
q.RLockMu()
msgCount += q.LiveCount()
q.RUnlockMu()
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{ json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok", Status: "ok",
Version: models.Version,
TenantCount: len(tenants), TenantCount: len(tenants),
QueueCount: queueCount, QueueCount: queueCount,
MessageCount: msgCount, MessageCount: msgCount,
+26 -5
View File
@@ -1,5 +1,7 @@
// app/admin/admin_test.go // app/admin/admin_test.go
// Focused tests for UI auth and scoping // 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 package admin
import ( import (
@@ -13,18 +15,37 @@ import (
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
// TestJWTAuth_RejectsMissingToken verifies jwtAuth rejects a request without a token. // TestJWTAuth_AllowsDemoToken verifies that the public demo token authenticates into the seeded demo tenant.
func TestJWTAuth_RejectsMissingToken(t *testing.T) { 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")
store := tenant.NewTenantStore() 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") h := NewHandler(store, "admin-token")
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{}`)) req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{"token":"demo-token-for-test"}`))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.jwtAuth(w, req) h.jwtAuth(w, req)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusOK {
t.Fatalf("jwtAuth() status = %d, want 400", w.Code) 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)
} }
} }
+1 -19
View File
@@ -10,7 +10,6 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json"
"encoding/xml" "encoding/xml"
"errors" "errors"
"fmt" "fmt"
@@ -22,7 +21,6 @@ import (
"strings" "strings"
"time" "time"
"shared-sqs/app/persistence"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
@@ -61,17 +59,6 @@ func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler {
} }
t, ok := store.GetByAccessKey(accessKeyID) 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 { if !ok || !t.Active {
writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid") writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid")
return return
@@ -163,14 +150,9 @@ func verifyHeaderSigV4(r *http.Request, t *tenant.Tenant, authHeader string) err
if err != nil { if err != nil {
return err return err
} }
actualHash := sha256Hex(body)
payloadHash := r.Header.Get("X-Amz-Content-Sha256") payloadHash := r.Header.Get("X-Amz-Content-Sha256")
if payloadHash == "" { if payloadHash == "" {
payloadHash = actualHash payloadHash = sha256Hex(body)
} 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) canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, true)
-120
View File
@@ -1,120 +0,0 @@
// app/auth/cache.go
// Кэш успешной валидации JWT-токенов (в памяти процесса).
// Мотивация: Nubes-шлюз нестабильно отвечает на запросы с IP платформы
// (то 200, то 401/403), из-за чего повторная валидация на каждый запрос
// консоли роняла UI. Кэш: одна успешная проверка → TTL без обращений к шлюзу.
// Безопасность: отозванный токен перестаёт работать максимум через TTL;
// явный 401/403 сбрасывает запись немедленно.
// Created: 2026-08-14
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sync"
"time"
)
// cacheTTL — срок жизни записи успешной валидации.
const cacheTTL = 5 * time.Minute
// cacheGraceTTL — продление при сетевой ошибке шлюза для ранее валидного токена.
const cacheGraceTTL = 2 * time.Minute
// cacheCleanupInterval — период фоновой очистки протухших записей.
const cacheCleanupInterval = 5 * time.Minute
type tokenCacheEntry struct {
validUntil time.Time
graceDeadline time.Time // потолок grace от последней УСПЕШНОЙ проверки (не продлевается)
}
var (
tokenCacheMu sync.RWMutex
tokenCache = map[string]tokenCacheEntry{}
)
// tokenCacheKey — компактный ключ кэша: hex(SHA256(token)).
func tokenCacheKey(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
// PingNubesAPICached — валидация токена через шлюз с кэшем.
// HIT (запись валидна) → nil без обращения к шлюзу.
// MISS → свежая проверка: успех → запись с TTL; 401/403 → удаление записи и ошибка.
// Сетевая ошибка: если запись была (пусть и протухшая) → grace-продление и nil,
// иначе — ошибка (шлюз недоступен, кэша нет — справедливый отказ).
func PingNubesAPICached(ctx context.Context, endpoint, token string) error {
key := tokenCacheKey(token)
now := time.Now()
tokenCacheMu.RLock()
entry, ok := tokenCache[key]
tokenCacheMu.RUnlock()
if ok && now.Before(entry.validUntil) {
return nil // cache HIT
}
err := PingNubesAPI(ctx, endpoint, token)
if errors.Is(err, ErrTokenRejected) {
tokenCacheMu.Lock()
delete(tokenCache, key)
tokenCacheMu.Unlock()
return err
}
if err != nil {
// Сетевая ошибка шлюза: если токен ранее был валиден — даём grace, но
// НЕ дальше graceDeadline. Раньше validUntil сдвигали вперёд на каждый
// сетевой сбой — отозванный токен жил бы вечно при повторных ошибках.
if ok && now.Before(entry.graceDeadline) {
return nil
}
return err
}
tokenCacheMu.Lock()
tokenCache[key] = tokenCacheEntry{
validUntil: now.Add(cacheTTL),
graceDeadline: now.Add(cacheGraceTTL),
}
tokenCacheMu.Unlock()
return nil
}
// WarmTokenCache — записывает токен в кэш как валидный без вызова шлюза.
// Используется после успешной свежей проверки при логине (jwtAuth).
func WarmTokenCache(token string) {
now := time.Now()
tokenCacheMu.Lock()
tokenCache[tokenCacheKey(token)] = tokenCacheEntry{
validUntil: now.Add(cacheTTL),
graceDeadline: now.Add(cacheGraceTTL),
}
tokenCacheMu.Unlock()
}
var tokenCacheCleanupOnce sync.Once
// StartTokenCacheCleanup — фоновая очистка протухших записей. Идемпотентна (sync.Once).
func StartTokenCacheCleanup() {
tokenCacheCleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(cacheCleanupInterval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
tokenCacheMu.Lock()
for k, e := range tokenCache {
if now.After(e.validUntil) {
delete(tokenCache, k)
}
}
tokenCacheMu.Unlock()
}
}()
})
}
+21 -58
View File
@@ -7,9 +7,9 @@ package auth
import ( import (
"context" "context"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -59,78 +59,41 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
if claims.Sub == "" { if claims.Sub == "" {
return nil, fmt.Errorf("JWT missing 'sub' claim") return nil, fmt.Errorf("JWT missing 'sub' claim")
} }
if claims.Exp == 0 { if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
return nil, fmt.Errorf("JWT missing 'exp' claim")
}
if claims.Exp < time.Now().Unix() {
return nil, fmt.Errorf("JWT token expired") return nil, fmt.Errorf("JWT token expired")
} }
return &claims, nil return &claims, nil
} }
// ErrTokenRejected — шлюз явно отклонил токен (401/403). // TenantIDFromSub — вычисляет tenant ID из JWT subject (sub claim).
// Sentinel, чтобы отличать отказ от сетевых ошибок (см. cache.go). // Алгоритм совместим с sless: SHA256(sub) → первые 8 байт → hex → "sless-{16hex}".
var ErrTokenRejected = errors.New("token rejected by nubes API") // Длина: 6 + 16 = 22 символа. Детерминирован, необратим.
// Почему "sless-" а не "ssq-": единый namespace для всех сервисов (IoT, funcs, SQS).
// nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен. func TenantIDFromSub(sub string) string {
// Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял. hash := sha256.Sum256([]byte(sub))
// Безопасность: только prod-шлюз. Тестовый/dev-шлюзы принимают токены своих return fmt.Sprintf("sless-%x", hash[:8])
// стендов, и при их наличии токен тестового стенда считался бы валидным на проде.
// Специфичный endpoint можно переопределить через NUBES_ENDPOINT.
var nubesAPIEndpoints = []string{
"https://lk-api-gateway.ngcloud.ru/api/v1/svc",
} }
// nubesHTTPClient — общий клиент для валидации токенов: переиспользует // PingNubesAPI — проверяет валидность токена запросом к nubes API.
// соединения (keep-alive) вместо создания нового клиента на каждый запрос. // endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1").
var nubesHTTPClient = &http.Client{Timeout: 10 * time.Second} // Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен.
// Любой другой HTTP статус → токен принят.
// PingNubesAPI — проверяет валидность токена перебором стендов Nubes API.
// endpoint (если задан через NUBES_ENDPOINT) пробуется первым.
// Логика: токен принимается ТОЛЬКО при явном HTTP 2xx от стенда.
// 401/403 → токен отклонён ЭТИМ стендом (ErrTokenRejected), пробуем следующий.
// Любой другой статус (4xx/5xx) → стенд НЕ подтвердил токен, пробуем следующий.
func PingNubesAPI(ctx context.Context, endpoint, token string) error { func PingNubesAPI(ctx context.Context, endpoint, token string) error {
candidates := make([]string, 0, len(nubesAPIEndpoints)+1) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if endpoint != "" {
candidates = append(candidates, endpoint)
}
for _, ep := range nubesAPIEndpoints {
if ep != endpoint {
candidates = append(candidates, ep)
}
}
var lastErr error
for _, ep := range candidates {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
if err != nil { if err != nil {
lastErr = fmt.Errorf("build nubes ping request: %w", err) return fmt.Errorf("build nubes ping request: %w", err)
continue
} }
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := nubesHTTPClient.Do(req) client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil { if err != nil {
lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err) return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err)
continue
} }
resp.Body.Close() defer 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 { if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode) return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode)
continue
} }
lastErr = fmt.Errorf("nubes API %s returned unexpected status HTTP %d", ep, resp.StatusCode) return nil
}
if lastErr == nil {
lastErr = fmt.Errorf("nubes API rejected token")
}
return lastErr
} }
-221
View File
@@ -1,221 +0,0 @@
// app/billing/billing.go
// Модуль учёта использования SQS-операций для биллинга.
// Записывает каждую успешную SQS-операцию в PostgreSQL: tenant_id, operation, msg_count, msg_bytes.
// Если PostgreSQL не сконфигурирован — billing отключён, SQS работает как раньше.
// Created: 2026-04-12
package billing
import (
"database/sql"
"fmt"
"os"
"sync"
// PostgreSQL драйвер — регистрируется в database/sql через init()
_ "github.com/lib/pq"
log "github.com/sirupsen/logrus"
)
// db — подключение к PostgreSQL для записи usage-данных.
// 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,
// BILLING_PG_PASSWORD, BILLING_PG_SSLMODE.
// Если BILLING_PG_HOST не задан — billing отключён, сервис работает без него.
func Init() {
host := os.Getenv("BILLING_PG_HOST")
if host == "" {
log.Info("billing: BILLING_PG_HOST not set, usage tracking disabled")
return
}
port := os.Getenv("BILLING_PG_PORT")
if port == "" {
port = "5432"
}
dbname := os.Getenv("BILLING_PG_DATABASE")
user := os.Getenv("BILLING_PG_USER")
password := os.Getenv("BILLING_PG_PASSWORD")
sslmode := os.Getenv("BILLING_PG_SSLMODE")
if sslmode == "" {
sslmode = "require"
}
dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
host, port, dbname, user, password, sslmode)
var err error
d, err := sql.Open("postgres", dsn)
if err != nil {
log.Errorf("billing: failed to open PostgreSQL: %v", err)
return
}
// Проверяем реальное подключение (Open не подключается)
if err = d.Ping(); err != nil {
log.Errorf("billing: failed to connect to PostgreSQL: %v", err)
d.Close()
return
}
// Ограничиваем пул — billing не должен отжирать коннекты у основной БД
d.SetMaxOpenConns(5)
d.SetMaxIdleConns(2)
setDB(d)
if err = autoMigrate(); err != nil {
log.Errorf("billing: failed to create table: %v", err)
setDB(nil)
d.Close()
return
}
log.Infof("billing: connected to PostgreSQL %s:%s/%s, usage tracking enabled", host, port, dbname)
}
// autoMigrate — создаёт таблицу и индекс если не существуют.
// Идемпотентно — безопасно вызывать при каждом старте.
func autoMigrate() error {
_, err := getDB().Exec(`
CREATE TABLE IF NOT EXISTS sqs_usage_records (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
operation TEXT NOT NULL,
queue_name TEXT NOT NULL DEFAULT '',
msg_count INTEGER DEFAULT 1,
msg_bytes BIGINT DEFAULT 0,
recorded_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sqs_usage_tenant_time
ON sqs_usage_records(tenant_id, recorded_at);
-- Миграция: добавляем queue_name если таблица уже существует без него
DO $$ BEGIN
ALTER TABLE sqs_usage_records ADD COLUMN queue_name TEXT NOT NULL DEFAULT '';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
`)
return err
}
// usageRecord — одна запись биллинга, накапливается в буфере воркера.
type usageRecord struct {
tenantID string
operation string
queueName string
msgCount int
msgBytes int64
}
// 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)`,
rec.tenantID, rec.operation, rec.queueName, rec.msgCount, rec.msgBytes,
)
if err != nil {
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 getDB() != nil
}
// 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")
}
+19 -120
View File
@@ -7,18 +7,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"shared-sqs/app/billing"
"shared-sqs/app/conf" "shared-sqs/app/conf"
"shared-sqs/app/gosqs" "shared-sqs/app/gosqs"
"shared-sqs/app/leader"
"shared-sqs/app/metrics"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence" "shared-sqs/app/persistence"
"shared-sqs/app/router" "shared-sqs/app/router"
@@ -31,24 +27,14 @@ func main() {
var configFile string var configFile string
var adminToken string var adminToken string
var port string var port string
var controlPort string
var debug bool var debug bool
var loglevel string var loglevel string
var leaderElection bool
var leaderTTL time.Duration
var leaderInterval time.Duration
flag.StringVar(&configFile, "config", "", "config file location") flag.StringVar(&configFile, "config", "", "config file location")
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token") flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
flag.StringVar(&port, "port", "4100", "data-plane listen port") flag.StringVar(&port, "port", "4100", "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.BoolVar(&debug, "debug", false, "set debug log level")
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)") 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() flag.Parse()
log.SetFormatter(&log.JSONFormatter{}) log.SetFormatter(&log.JSONFormatter{})
@@ -101,15 +87,12 @@ func main() {
redisPass := os.Getenv("REDIS_PASSWORD") redisPass := os.Getenv("REDIS_PASSWORD")
if redisAddr != "" { if redisAddr != "" {
if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil { if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil {
// Fail-fast: при явно заданном REDIS_ADDR, но недоступном Redis, log.Warnf("Не удалось подключиться к Redis: %v — работаем в memory-only режиме", err)
// продолжать опасно — SaveQueue перезапишет Redis пустотой, read-through
// и failover сломаются. Падаем, чтобы systemd перезапустил.
log.Fatalf("Не удалось подключиться к Redis (%s): %v", redisAddr, err)
} }
} }
// Восстановление состояния из Redis (тенанты + очереди) // Восстановление состояния из Redis (тенанты + очереди)
if persistence.Client.Load() != nil { if persistence.Client != nil {
// Загружаем тенантов // Загружаем тенантов
tenantsRaw, err := persistence.LoadAllTenantsRaw() tenantsRaw, err := persistence.LoadAllTenantsRaw()
if err != nil { if err != nil {
@@ -127,112 +110,41 @@ func main() {
// Загружаем очереди // Загружаем очереди
queues, err := persistence.LoadAllQueues() queues, err := persistence.LoadAllQueues()
if err != nil { if err != nil {
log.Fatalf("Ошибка загрузки очередей из Redis: %v", err) log.Warnf("Ошибка загрузки очередей из Redis: %v", err)
} else { } else {
models.SyncQueues.Lock() models.SyncQueues.Lock()
for k, q := range queues { for k, q := range queues {
models.SyncQueues.AddQueue(k, q) models.SyncQueues.Queues[k] = q
} }
models.SyncQueues.Unlock() 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 // Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" { if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
seedDemoData(tenantStore) seedDemoData(tenantStore)
} }
// Роутер с tenant auth и admin API
// Billing: подключение к PostgreSQL для учёта использования. r := router.New(tenantStore, adminToken)
// Если BILLING_PG_HOST не задан — billing отключён, SQS работает без него.
billing.Init()
// Разбиение 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 // PeriodicTasks — visibility timeout, DLQ, deduplication
quit := make(chan bool) quit := make(chan bool)
go gosqs.PeriodicTasks(1*time.Second, quit) go gosqs.PeriodicTasks(1*time.Second, quit)
// Metrics: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд // HTTP сервер с таймаутами
metrics.StartGaugeUpdater(15*time.Second, quit) 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, Addr: "0.0.0.0:" + port,
Handler: dataRouter, Handler: r,
ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 35 * time.Second, WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
IdleTimeout: 90 * time.Second, IdleTimeout: 60 * 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 // Запуск в горутине для graceful shutdown
serverErr := make(chan error, 2) serverErr := make(chan error, 1)
go func() { go func() {
log.Infof("shared-sqs data-plane listening on 0.0.0.0:%s", port) log.Infof("shared-sqs listening on 0.0.0.0:%s", port)
if err := dataSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.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 serverErr <- err
} }
}() }()
@@ -251,24 +163,11 @@ func main() {
// Остановить PeriodicTasks // Остановить PeriodicTasks
close(quit) close(quit)
// Отпустить лидерский лок (если были лидером) до закрытия слушателей,
// чтобы пассивный взял его быстрее, чем через TTL.
if elector != nil {
elector.Stop()
}
// Дать 10 секунд на завершение текущих HTTP запросов // Дать 10 секунд на завершение текущих HTTP запросов
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := dataSrv.Shutdown(ctx); err != nil { if err := srv.Shutdown(ctx); err != nil {
log.Errorf("Data-plane shutdown error: %v", err) log.Errorf("Server 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") log.Info("shared-sqs stopped")
} }
+23 -28
View File
@@ -1,11 +1,12 @@
// app/cmd/seed.go // app/cmd/seed.go
// Created: 2026-04-09
// Updated: 2026-04-09 — фиксированные credentials для demo-tenant (BYOC)
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true. // Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
// Создаёт тенанта demo-service со случайными кредами (генерируются через // Создаёт тенанта demo-service с 5 очередями и демо-сообщениями.
// tenant.Create → crypto/rand) с 5 очередями и демо-сообщениями.
// //
// DEMO CREDENTIALS — только для тестового стенда.
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к // Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
// admin API и к очередям других тенантов. Креды генерируются случайно и печатаются // admin API и к очередям других тенантов. Credentials открыты намеренно — стенд публичный.
// в лог при старте (только при явном включении SHARED_SQS_SEED_DEMO).
package main package main
import ( import (
@@ -16,36 +17,30 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"shared-sqs/app/gosqs"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
// Фиксированные credentials демо-тенанта.
// Открыты намеренно — тестовый стенд.
// Тенант ограничен 10 очередями и не имеет прав admin API.
const ( const (
demoTenantID = "t-demo-shared-sqs-ngcloud"
demoAccessKey = "SSAK-demo-shared-sqs"
demoSecretKey = "demo-secret-key-shared-sqs-ngcloud-2026"
demoTenantName = "demo-service" demoTenantName = "demo-service"
demoMaxQueues = 10 demoMaxQueues = 10
) )
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями. // seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера. // Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
// Креды генерируются случайно (tenant.Create) — никаких захардкоженных
// AccessKey/SecretKey в исходнике.
func seedDemoData(store *tenant.TenantStore) { func seedDemoData(store *tenant.TenantStore) {
// Идемпотентность: если демо-тенант уже есть (восстановлен из Redis) — не плодим копии. t, err := store.CreateFixed(demoTenantName, demoMaxQueues, demoTenantID, demoAccessKey, demoSecretKey)
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 { if err != nil {
log.Warnf("seed: не удалось создать demo-tenant: %v", err) log.Warnf("seed: не удалось создать demo-tenant: %v", err)
return return
} }
log.Infof("seed: создан тенант %s (AccessKey=%s, SecretKey=%s)", t.ID, t.AccessKey, t.SecretKey) log.Infof("seed: создан тенант %s (AccessKey=%s)", t.ID, t.AccessKey)
// Демо-очереди с набором сообщений // Демо-очереди с набором сообщений
queues := []struct { queues := []struct {
@@ -92,9 +87,14 @@ func seedDemoData(store *tenant.TenantStore) {
// URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN // URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN
// Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью. // Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью.
// URL — единый источник (gosqs.TenantQueueURL), учитывает ExternalURLBase. env := models.CurrentEnvironment
queueURL := gosqs.TenantQueueURL(t, q.name) var queueURL string
queueARN := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + q.name 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
msgs := make([]models.SqsMessage, 0, len(q.msgs)) msgs := make([]models.SqsMessage, 0, len(q.msgs))
for _, body := range q.msgs { for _, body := range q.msgs {
@@ -108,7 +108,8 @@ func seedDemoData(store *tenant.TenantStore) {
}) })
} }
queue := &models.Queue{ models.SyncQueues.Lock()
models.SyncQueues.Queues[key] = &models.Queue{
Name: q.name, Name: q.name,
URL: queueURL, URL: queueURL,
Arn: queueARN, Arn: queueARN,
@@ -118,14 +119,8 @@ func seedDemoData(store *tenant.TenantStore) {
Messages: msgs, Messages: msgs,
Duplicates: make(map[string]time.Time), Duplicates: make(map[string]time.Time),
} }
models.SyncQueues.Lock()
models.SyncQueues.AddQueue(key, queue)
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
// Персистим очередь и сообщения в Redis (иначе после рестарта демо-очереди исчезают).
persistence.SaveQueue(key, queue)
persistence.SaveMessages(key, msgs)
log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs)) log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs))
} }
+2 -12
View File
@@ -90,9 +90,6 @@ func LoadYamlConfig(filename string, env string) []string {
queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" + queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" +
models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name 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 queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name
if queue.ReceiveMessageWaitTimeSeconds == 0 { if queue.ReceiveMessageWaitTimeSeconds == 0 {
@@ -108,7 +105,7 @@ func LoadYamlConfig(filename string, env string) []string {
queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod
} }
models.SyncQueues.AddQueue(queue.Name, &models.Queue{ models.SyncQueues.Queues[queue.Name] = &models.Queue{
Name: queue.Name, Name: queue.Name,
VisibilityTimeout: queue.VisibilityTimeout, VisibilityTimeout: queue.VisibilityTimeout,
Arn: queueArn, Arn: queueArn,
@@ -119,7 +116,7 @@ func LoadYamlConfig(filename string, env string) []string {
IsFIFO: utils.HasFIFOQueueName(queue.Name), IsFIFO: utils.HasFIFOQueueName(queue.Name),
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time), Duplicates: make(map[string]time.Time),
}) }
} }
// Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка // Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка
@@ -129,9 +126,6 @@ func LoadYamlConfig(filename string, env string) []string {
err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy) err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy)
if err != nil { if err != nil {
log.Errorf("err: %s", err) log.Errorf("err: %s", err)
// Разблокировать глобальный мьютекс перед выходом — иначе все
// операции с очередями зависнут навсегда (deadlock).
models.SyncQueues.Unlock()
return ports return ports
} }
} }
@@ -146,10 +140,6 @@ func LoadYamlConfig(filename string, env string) []string {
// AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты // AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты
// устанавливались при любом раннем return (например, если конфиг не найден). // устанавливались при любом раннем return (например, если конфиг не найден).
func applyEnvironmentDefaults() { 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 { if models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout <= 0 {
models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30 models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30
} }
+166
View File
@@ -0,0 +1,166 @@
package conf
import (
"testing"
"shared-sqs/app/models"
"github.com/stretchr/testify/assert"
)
func TestConfig_NoQueuesOrTopics(t *testing.T) {
env := "NoQueuesOrTopics"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4200 but got %s\n", port)
}
numQueues := len(envs[env].Queues)
if numQueues != 0 {
t.Errorf("Expected zero queues to be in the environment but got %d\n", numQueues)
}
numQueues = len(models.SyncQueues.Queues)
if numQueues != 0 {
t.Errorf("Expected zero queues to be in the sqs topics but got %d\n", numQueues)
}
numTopics := len(envs[env].Topics)
if numTopics != 0 {
t.Errorf("Expected zero topics to be in the environment but got %d\n", numTopics)
}
numTopics = len(models.SyncTopics.Topics)
if numTopics != 0 {
t.Errorf("Expected zero topics to be in the sns topics but got %d\n", numTopics)
}
}
func TestConfig_CreateQueuesTopicsAndSubscriptions(t *testing.T) {
env := "Local"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4100 but got %s\n", port)
}
numQueues := len(envs[env].Queues)
if numQueues != 4 {
t.Errorf("Expected three queues to be in the environment but got %d\n", numQueues)
}
numQueues = len(models.SyncQueues.Queues)
if numQueues != 6 {
t.Errorf("Expected five queues to be in the sqs topics but got %d\n", numQueues)
}
numTopics := len(envs[env].Topics)
if numTopics != 2 {
t.Errorf("Expected two topics to be in the environment but got %d\n", numTopics)
}
numTopics = len(models.SyncTopics.Topics)
if numTopics != 2 {
t.Errorf("Expected two topics to be in the sns topics but got %d\n", numTopics)
}
}
func TestConfig_QueueAttributes(t *testing.T) {
var emptyQueue *models.Queue
env := "Local"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4100 but got %s\n", port)
}
assert.Equal(t, 10, models.SyncQueues.Queues["local-queue1"].ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 10, models.SyncQueues.Queues["local-queue1"].VisibilityTimeout)
assert.Equal(t, 1024, models.SyncQueues.Queues["local-queue1"].MaximumMessageSize)
assert.Equal(t, emptyQueue, models.SyncQueues.Queues["local-queue1"].DeadLetterQueue)
assert.Equal(t, 0, models.SyncQueues.Queues["local-queue1"].MaxReceiveCount)
assert.Equal(t, 345600, models.SyncQueues.Queues["local-queue1"].MessageRetentionPeriod)
assert.Equal(t, 100, models.SyncQueues.Queues["local-queue3"].MaxReceiveCount)
assert.Equal(t, "local-queue3-dlq", models.SyncQueues.Queues["local-queue3"].DeadLetterQueue.Name)
assert.Equal(t, 128, models.SyncQueues.Queues["local-queue2"].MaximumMessageSize)
assert.Equal(t, 150, models.SyncQueues.Queues["local-queue2"].VisibilityTimeout)
assert.Equal(t, 245600, models.SyncQueues.Queues["local-queue2"].MessageRetentionPeriod)
}
func TestConfig_NoQueueAttributeDefaults(t *testing.T) {
env := "NoQueueAttributeDefaults"
LoadYamlConfig("./mock-data/mock-config.yaml", env)
receiveWaitTime := models.SyncQueues.Queues["local-queue1"].ReceiveMessageWaitTimeSeconds
if receiveWaitTime != 0 {
t.Errorf("Expected local-queue1 Queue to be configured with ReceiveMessageWaitTimeSeconds: 0 but got %d\n", receiveWaitTime)
}
timeoutSecs := models.SyncQueues.Queues["local-queue1"].VisibilityTimeout
if timeoutSecs != 30 {
t.Errorf("Expected local-queue1 Queue to be configured with VisibilityTimeout: 30 but got %d\n", timeoutSecs)
}
receiveWaitTime = models.SyncQueues.Queues["local-queue2"].ReceiveMessageWaitTimeSeconds
if receiveWaitTime != 20 {
t.Errorf("Expected local-queue2 Queue to be configured with ReceiveMessageWaitTimeSeconds: 20 but got %d\n", receiveWaitTime)
}
messageRetentionPeriod := models.SyncQueues.Queues["local-queue1"].MessageRetentionPeriod
if messageRetentionPeriod != 345600 {
t.Errorf("Expected local-queue2 Queue to be configured with VisibilityTimeout: 150 but got %d\n", timeoutSecs)
}
}
func TestConfig_invalid_config_resorts_to_default_queue_attributes(t *testing.T) {
env := "missing"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4100 but got %s\n", port)
}
assert.Equal(t, 262144, models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize)
assert.Equal(t, 345600, models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod)
assert.Equal(t, 0, models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 30, models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout)
}
func TestConfig_LoadYamlConfig_finds_default_config(t *testing.T) {
expectedQueues := []string{
"local-queue1",
"local-queue2",
"local-queue3",
"local-queue3-dlq",
"local-queue4",
}
expectedTopics := []string{
"local-topic1",
"local-topic2",
"local-topic3",
"local-topic4",
}
env := "Local"
LoadYamlConfig("", env)
queues := models.SyncQueues.Queues
topics := models.SyncTopics.Topics
for _, expectedName := range expectedQueues {
_, ok := queues[expectedName]
assert.True(t, ok)
}
for _, expectedName := range expectedTopics {
_, ok := topics[expectedName]
assert.True(t, ok)
}
}
func TestConfig_LoadYamlConfig_missing_config_loads_nothing(t *testing.T) {
models.CurrentEnvironment = models.Environment{}
ports := LoadYamlConfig("/garbage", "Local")
assert.Equal(t, []string{"4100"}, ports)
assert.Equal(t, models.CurrentEnvironment, models.Environment{})
}
func TestConfig_LoadYamlConfig_invalid_config_loads_nothing(t *testing.T) {
models.CurrentEnvironment = models.Environment{}
ports := LoadYamlConfig("../common/common.go", "Local")
assert.Equal(t, []string{"4100"}, ports)
assert.Equal(t, models.CurrentEnvironment, models.Environment{})
}
+15 -1
View File
@@ -1,10 +1,13 @@
Local: # Environment name that can be passed on the command line Local: # Environment name that can be passed on the command line
# (i.e.: ./goaws [Local | Dev] -- defaults to 'Local') # (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) 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. Port: 4100 # port to listen on.
# SqsPort: 9324 # alterante Sqs Port
# SnsPort: 9292 # alternate Sns Port
Region: us-east-1 Region: us-east-1
AccountId: "100010001000" AccountId: "100010001000"
ExternalURLBase: "" # публичный адрес очередей (напр. https://sqs.sinefo.ru); пусто → http://{region}.{host}:{port}
LogToFile: false # Log messages (true/false) LogToFile: false # Log messages (true/false)
LogFile: .st/goaws_messages.log # Log filename (for message logging LogFile: .st/goaws_messages.log # Log filename (for message logging
EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId
@@ -14,6 +17,7 @@ Local: # Environment name that can be passed on the
MaximumMessageSize: 262144 # maximum message size (bytes) MaximumMessageSize: 262144 # maximum message size (bytes)
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented # MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
Queues: [] # No default queues — created via Admin API / AWS CLI by tenants 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 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. Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied.
Max: 0 # Desired latency in milliseconds Max: 0 # Desired latency in milliseconds
@@ -21,10 +25,20 @@ Local: # Environment name that can be passed on the
Dev: # Another environment Dev: # Another environment
Host: localhost Host: localhost
Port: 4100 Port: 4100
# SqsPort: 9324
# SnsPort: 9292
AccountId: "794373491471" AccountId: "794373491471"
LogToFile: false LogToFile: false
LogFile: ./goaws_messages.log LogFile: ./goaws_messages.log
Queues: Queues:
- Name: dev-queue1 - Name: dev-queue1
- Name: dev-queue2 - Name: dev-queue2
Topics:
- Name: dev-topic1
Subscriptions:
- QueueName: dev-queue3
Raw: false
- QueueName: dev-queue4
Raw: true
- Name: dev-topic2
-43
View File
@@ -1,43 +0,0 @@
// 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(),
}
}
+37 -21
View File
@@ -47,43 +47,59 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
queue := models.SyncQueues.GetLocked(key) if _, ok := models.SyncQueues.Queues[key]; !ok {
if queue == nil {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
models.SyncQueues.Lock()
messageFound := false messageFound := false
var changedMsgUuid string var changedMsgUuid string
var msgRemoved bool var msgRemoved bool
var dlqMsg *models.SqsMessage for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ {
// O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза. queue := models.SyncQueues.Queues[key]
if i, ok := queue.IndexOfReceipt(receiptHandle); ok { msgs := queue.Messages
if msgs[i].ReceiptHandle == receiptHandle {
timeout := models.SyncQueues.Queues[key].VisibilityTimeout
if visibilityTimeout == 0 { if visibilityTimeout == 0 {
// Возврат видимости (+ DLQ-перенос при исчерпании MaxReceiveCount) — msgs[i].ReceiptTime = time.Now().UTC()
// единый путь с батчевой версией. msgs[i].ReceiptHandle = ""
changedMsgUuid, msgRemoved, dlqMsg = queue.ResetVisibilityAndTryDLQ(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
} else { } else {
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second) changedMsgUuid = msgs[i].Uuid
changedMsgUuid = queue.Messages[i].Uuid }
} else {
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
changedMsgUuid = msgs[i].Uuid
} }
messageFound = true messageFound = true
break
} }
// Персистим изменения в Redis под Lock (сетевые записи асинхронны). }
// Персистим изменения в Redis под Lock
if messageFound { if messageFound {
if msgRemoved { if msgRemoved {
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis исходной // Сообщение удалено (перемещено в DLQ) — удаляем из Redis
// очереди и сохраняем в DLQ, иначе после рестарта оно пропадёт из DLQ. persistence.DeleteMessagePersist(key, changedMsgUuid)
_ = persistence.DeleteMessagePersist(key, changedMsgUuid) } else {
if dlqMsg != nil {
persistence.SaveMessage(dlqKeyFor(key, queue.DeadLetterQueue.Name), dlqMsg)
}
} else if i, ok := queue.IndexOfUUID(changedMsgUuid); ok {
// Сообщение обновлено — перезаписываем в Redis // Сообщение обновлено — перезаписываем в Redis
persistence.SaveMessage(key, &queue.Messages[i]) for i := range models.SyncQueues.Queues[key].Messages {
if models.SyncQueues.Queues[key].Messages[i].Uuid == changedMsgUuid {
persistence.SaveMessage(key, &models.SyncQueues.Queues[key].Messages[i])
break
} }
persistence.SaveQueue(key, queue)
} }
}
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
}
models.SyncQueues.Unlock()
if !messageFound { if !messageFound {
return utils.CreateErrorResponseV1("MessageNotInFlight", true) return utils.CreateErrorResponseV1("MessageNotInFlight", true)
} }
+18 -23
View File
@@ -42,6 +42,10 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if len(requestBody.Entries) == 0 { if len(requestBody.Entries) == 0 {
return utils.CreateErrorResponseV1("EmptyBatchRequest", true) return utils.CreateErrorResponseV1("EmptyBatchRequest", true)
} }
@@ -59,11 +63,8 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
ids[entry.Id] = true ids[entry.Id] = true
} }
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.Lock()
if queue == nil { defer models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
defer queue.UnlockMu()
successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0) successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0)
failedEntries := make([]models.BatchResultErrorEntry, 0) failedEntries := make([]models.BatchResultErrorEntry, 0)
@@ -80,29 +81,23 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
} }
messageFound := false messageFound := false
// O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза. queue := models.SyncQueues.Queues[key]
if i, ok := queue.IndexOfReceipt(entry.ReceiptHandle); ok { for i := 0; i < len(queue.Messages); i++ {
if queue.Messages[i].ReceiptHandle == entry.ReceiptHandle {
if entry.VisibilityTimeout == 0 { if entry.VisibilityTimeout == 0 {
// Сброс + DLQ-перенос при исчерпании MaxReceiveCount — единый путь // Сброс: сообщение снова видимо, счётчик retry++
// с одиночной версией (раньше батч лишь инкрементировал Retry и не queue.Messages[i].ReceiptTime = time.Now().UTC()
// переносил в DLQ — счётчик и перенос могли расходиться). queue.Messages[i].ReceiptHandle = ""
uuid, moved, dlqMsg := queue.ResetVisibilityAndTryDLQ(i, entry.ReceiptHandle) queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second)
if moved { queue.Messages[i].Retry++
// Сообщение перенесено в 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 { } else {
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second) queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second)
}
// Персистим изменённое сообщение отдельно // Персистим изменённое сообщение отдельно
persistence.SaveMessage(key, &queue.Messages[i]) persistence.SaveMessage(key, &queue.Messages[i])
}
messageFound = true messageFound = true
break
}
} }
if messageFound { if messageFound {
@@ -119,7 +114,7 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
// Персистим изменения в Redis под Lock // Персистим изменения в Redis под Lock
if len(successEntries) > 0 { if len(successEntries) > 0 {
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
} }
respStruct := models.ChangeMessageVisibilityBatchResponse{ respStruct := models.ChangeMessageVisibilityBatchResponse{
@@ -0,0 +1,75 @@
package gosqs
import (
"net/http"
"testing"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/models"
"github.com/stretchr/testify/assert"
)
func TestChangeMessageVisibility_success(t *testing.T) {
// create a queue
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
// The default value for the VisibilityTimeout is the zero value of time.Time
assert.Zero(t, q.Messages[0].VisibilityTimeout)
_, r := test.GenerateRequestInfo("POST", "/", models.ChangeMessageVisibilityRequest{
QueueUrl: "http://localhost:4100/queue/testing",
ReceiptHandle: "123",
VisibilityTimeout: 0,
}, true)
status, _ := ChangeMessageVisibilityV1(r)
assert.Equal(t, status, http.StatusOK)
// Changing the message visibility increments the time.Time by N seconds
// from the current time.
//
// Given that the current time is relative between calling the endpoint and
// the time being set, we can't reliably assert an exact value. So assert
// that the time.Time value is no longer the default zero value.
assert.NotZero(t, q.Messages[0].VisibilityTimeout)
assert.NotZero(t, q.Messages[0].ReceiptTime)
assert.Equal(t, "", q.Messages[0].ReceiptHandle)
assert.Equal(t, 1, q.Messages[0].Retry)
}
func TestChangeMessageVisibility_success_adds_to_existing_visibility_timeout(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_success_transfers_to_dead_letter_queue(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_request_transformer_error(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_visibility_timeout_too_large(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_missing_queue(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_missing_message(t *testing.T) {
// TODO - mismatch receipt handle
}
+16 -56
View File
@@ -1,3 +1,6 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// CreateQueueV1 — создаёт очередь для тенанта из request context.
// Ключ в SyncQueues: "{tenantAccessKey}:{queueName}" для изоляции между тенантами.
package gosqs package gosqs
import ( import (
@@ -12,21 +15,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// CreateQueueV1 — создаёт очередь для тенанта из request context.
//
// Логическая схема:
// 1. Разбор тела запроса (Query/JSON) → CreateQueueRequest.
// 2. Тенант из контекста (SigV4 → AccessKey).
// 3. Валидация имени очереди по AWS-правилам: до 80 символов, [a-zA-Z0-9_-],
// опциональный суффикс .fifo (ValidateQueueName).
// 4. Под Lock:
// - если очередь уже есть — просто возвращаем её URL (идемпотентность AWS);
// - проверка лимита очередей тенанта (MaxQueues) → LimitExceeded;
// - создание очереди с дефолтами и применение переданных атрибутов
// (setQueueAttributesV1 с валидацией диапазонов);
// - вставка в map с tenant-scoped ключом "{tenantAccessKey}:{queueName}".
// 5. Сохранение очереди в Redis (асинхронно, маршалинг под Lock).
// 6. Ответ: QueueUrl.
func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) { func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewCreateQueueRequest() requestBody := models.NewCreateQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -49,9 +37,14 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
queueUrl := tenantQueueURL(t, queueName) queueUrl := tenantQueueURL(t, queueName)
queueArn := tenantQueueARN(t, queueName) queueArn := tenantQueueARN(t, queueName)
// Готовим очередь и применяем атрибуты ВНЕ глобального лока: setQueueAttributesV1 models.SyncQueues.Lock()
// берёт SyncQueues.RLock (поиск DLQ), а RWMutex не реентрантен — вызов под if _, exists := models.SyncQueues.Queues[key]; !exists {
// уже захваченным SyncQueues.Lock дал бы дедлок. // Проверка лимита очередей тенанта
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)
queue := &models.Queue{ queue := &models.Queue{
Name: queueName, Name: queueName,
URL: queueUrl, URL: queueUrl,
@@ -59,49 +52,16 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
IsFIFO: utils.HasFIFOQueueName(queueName), IsFIFO: utils.HasFIFOQueueName(queueName),
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time), 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-валидации if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil {
// (Query-протокол: Attribute.N.Name; JSON: nil — проверка пропускается). models.SyncQueues.Unlock()
var provided map[string]string
if req.Header.Get("Content-Type") != "application/x-amz-json-1.0" {
provided = utils.ExtractQueueAttributes(req.PostForm)
}
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
return utils.CreateErrorResponseV1(err.Error(), true) return utils.CreateErrorResponseV1(err.Error(), true)
} }
models.SyncQueues.Queues[key] = queue
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
} }
// Сохраняем очередь в Redis пока держим Lock — консистентный снапшот
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock() 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{ respStruct := models.CreateQueueResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
Result: models.CreateQueueResult{QueueUrl: queueUrl}, Result: models.CreateQueueResult{QueueUrl: queueUrl},
+254
View File
@@ -0,0 +1,254 @@
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)
}
+16 -24
View File
@@ -42,38 +42,30 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle) log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle)
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.Lock()
if queue == nil { defer models.SyncQueues.Unlock()
log.Warning("Queue not found") if _, ok := models.SyncQueues.Queues[key]; ok {
return utils.CreateErrorResponseV1("MessageDoesNotExist", true) for i, msg := range models.SyncQueues.Queues[key].Messages {
} if msg.ReceiptHandle == receiptHandle {
defer queue.UnlockMu() msgUuid := msg.Uuid
models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID)
// O(1)-поиск сообщения по индексу ReceiptHandle → индекс в Messages (P1.2). models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...)
// Раньше — линейный скан всего среза + сдвиг остатка (O(N) на большое тело). delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID)
if i, ok := queue.IndexOfReceipt(receiptHandle); ok { // Удаляем одно сообщение из Redis — O(1)
msg := queue.Messages[i] // копия ДО мутации: RemoveIndex может сдвинуть срез persistence.DeleteMessagePersist(key, msgUuid)
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) // Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.DeleteMessageResponse{ respStruct := models.DeleteMessageResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata, Metadata: models.BaseResponseMetadata,
} }
return 200, &respStruct return 200, &respStruct
} }
}
log.Warning("Receipt Handle not found") log.Warning("Receipt Handle not found")
} else {
log.Warning("Queue not found")
}
return utils.CreateErrorResponseV1("MessageDoesNotExist", true) return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
} }
+22 -45
View File
@@ -40,6 +40,10 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if len(requestBody.Entries) == 0 { if len(requestBody.Entries) == 0 {
return utils.CreateErrorResponseV1("EmptyBatchRequest", true) return utils.CreateErrorResponseV1("EmptyBatchRequest", true)
} }
@@ -54,72 +58,45 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true) return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true)
} }
ids[v.Id] = true ids[v.Id] = true
// Пустой ReceiptHandle опасен: в JSON-протоколе (x-amz-json-1.0) он не
// отсекается парсером и совпадает с ReceiptHandle НЕвыданных сообщений (""),
// что удаляло бы все такие сообщения очереди одним запросом.
if v.ReceiptHandle == "" {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
} }
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.Lock()
if queue == nil { defer models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
defer queue.UnlockMu()
// Ключ — Id, а НЕ ReceiptHandle: два entry с одинаковым ReceiptHandle и разными deleteMessageMap := make(map[string]*deleteEntry)
// Id не должны перезаписывать друг друга (первый исчезал бы и не попадал ни в
// Successful, ни в Failed — ревью 2026-10-11). entriesByHandle — обратный индекс.
entriesByID := make(map[string]*deleteEntry)
entriesByHandle := make(map[string][]*deleteEntry)
for _, entry := range requestBody.Entries { for _, entry := range requestBody.Entries {
de := &deleteEntry{ deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{
Id: entry.Id, Id: entry.Id,
ReceiptHandle: entry.ReceiptHandle, ReceiptHandle: entry.ReceiptHandle,
Deleted: false, Deleted: false,
} }
entriesByID[entry.Id] = de
entriesByHandle[entry.ReceiptHandle] = append(entriesByHandle[entry.ReceiptHandle], de)
} }
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0) deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
deletedUuids := make([]string, 0) deletedUuids := make([]string, 0)
remainingMessages := make([]models.SqsMessage, 0, len(models.SyncQueues.Queues[key].Messages))
// Идём по индексам, не пересобирая срез (P1.2): удаление = tombstone за O(1), for _, message := range models.SyncQueues.Queues[key].Messages {
// индексы не сдвигаются, поэтому итерация остаётся корректной. if de, found := deleteMessageMap[message.ReceiptHandle]; found {
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) log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID)
// Читаем поля ДО RemoveIndex: компакция может перезаписать слот. models.SyncQueues.Queues[key].UnlockGroup(message.GroupID)
groupID, uuid := message.GroupID, message.Uuid delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID)
queue.ClearInFlight(i, message.ReceiptHandle)
queue.UnlockGroup(groupID)
// Duplicates НЕ чистим — dedup-окно 5 минут живёт независимо от удаления (AWS).
for _, de := range des {
de.Deleted = true de.Deleted = true
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id}) 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)
} }
models.SyncQueues.Queues[key].Messages = remainingMessages
// Удаляем сообщения из Redis отдельно — O(batch_size)
persistence.DeleteMessagesPersist(key, deletedUuids)
// Метаданные очереди (duplicates, FIFO state) // Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
notFoundEntries := make([]models.BatchResultErrorEntry, 0) notFoundEntries := make([]models.BatchResultErrorEntry, 0)
for _, de := range entriesByID { for _, de := range deleteMessageMap {
if !de.Deleted { if !de.Deleted {
notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{ notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{
Code: "1", Code: "1",
+329
View File
@@ -0,0 +1,329 @@
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)
}
+38
View File
@@ -0,0 +1,38 @@
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)
}
-101
View File
@@ -1,101 +0,0 @@
// 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")
}
}
}
+4 -19
View File
@@ -1,3 +1,5 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу.
package gosqs package gosqs
import ( import (
@@ -12,17 +14,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу.
//
// Логическая схема:
// 1. Разбор тела запроса → DeleteQueueRequest.
// 2. Тенант из контекста (SigV4 → AccessKey).
// 3. Имя очереди из QueueUrl (последний сегмент) + tenant-scoped ключ.
// 4. Под Lock: проверка существования очереди → QueueNotFound (фикс: раньше
// удаление несуществующей очереди возвращало 200, как будто она удалена);
// удаление очереди из in-memory map.
// 5. Асинхронное удаление метаданных и всех сообщений очереди из Redis
// (persistence.DeleteQueue — защищено write-seq от гонки со SaveQueue).
func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) { func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewDeleteQueueRequest() requestBody := models.NewDeleteQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -43,14 +34,8 @@ func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
log.Infof("Deleting Queue: %s (tenant: %s)", queueName, t.ID) log.Infof("Deleting Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Lock() models.SyncQueues.Lock()
defer models.SyncQueues.Unlock() delete(models.SyncQueues.Queues, key)
// Проверка существования: AWS возвращает NonExistentQueue, а не 200 models.SyncQueues.Unlock()
// (раньше удаление несуществующей очереди молча «успевало»).
if _, ok := models.SyncQueues.Queues[key]; !ok {
log.Warnf("Delete Queue: %s does not exist (tenant: %s)", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
models.SyncQueues.RemoveQueue(key)
// Удаляем из Redis асинхронно // Удаляем из Redis асинхронно
persistence.DeleteQueue(key) persistence.DeleteQueue(key)
+91
View File
@@ -0,0 +1,91 @@
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)
}
+8 -28
View File
@@ -7,7 +7,6 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"shared-sqs/app/interfaces" "shared-sqs/app/interfaces"
"shared-sqs/app/models" "shared-sqs/app/models"
@@ -65,12 +64,13 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID) log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID)
queueAttributes := make([]models.Attribute, 0) queueAttributes := make([]models.Attribute, 0)
queue := models.SyncQueues.GetRLocked(key) models.SyncQueues.RLock()
if queue == nil { defer models.SyncQueues.RUnlock()
queue, ok := models.SyncQueues.Queues[key]
if !ok {
log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID) log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.RUnlockMu()
if shouldInclude("DelaySeconds") { if shouldInclude("DelaySeconds") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)})
@@ -87,24 +87,17 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
if shouldInclude("VisibilityTimeout") { if shouldInclude("VisibilityTimeout") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.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") { if shouldInclude("ApproximateNumberOfMessages") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(queue.LiveCount())}) queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))})
} }
if shouldInclude("ApproximateNumberOfMessagesNotVisible") { 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") { if shouldInclude("CreatedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: epochSeconds(queue.CreatedAt)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"})
} }
if shouldInclude("LastModifiedTimestamp") { if shouldInclude("LastModifiedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: epochSeconds(queue.LastModifiedAt)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"})
} }
if shouldInclude("QueueArn") { if shouldInclude("QueueArn") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn}) queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn})
@@ -115,10 +108,6 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn), 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{ respStruct := models.GetQueueAttributesResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
@@ -127,12 +116,3 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
} }
return http.StatusOK, respStruct 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)
}
+180
View File
@@ -0,0 +1,180 @@
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)
}
+104
View File
@@ -0,0 +1,104 @@
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)
}
+23 -139
View File
@@ -5,7 +5,6 @@ import (
"time" "time"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -14,110 +13,16 @@ func init() {
models.SyncQueues.Queues = make(map[string]*models.Queue) models.SyncQueues.Queues = make(map[string]*models.Queue)
} }
// PeriodicTasks — фоновая обработка очередей, выполняется раз в `d` (1 секунда).
//
// Задачи на каждый такт (для КАЖДОЙ очереди, см. processQueueTick):
// 1. Очистка просроченных записей deduplication-окна (FIFO).
// 2. Возврат сообщений в видимое состояние по истечении VisibilityTimeout.
// 3. Перенос сообщений в DeadLetterQueue при достижении MaxReceiveCount.
//
// ВАЖНО (фикс производительности): раньше глобальный SyncQueues.Lock удерживался
// на время обхода ВСЕХ очередей — при большом числе очередей HTTP-обработчики
// блокировались на секунды (наблюдались ReadTimeout и latency до 54 секунд).
// Теперь блокировка захватывается ПО ОДНОЙ очереди: между обработкой двух
// очередей другие горутины могут вклиниться.
func PeriodicTasks(d time.Duration, quit chan bool) { func PeriodicTasks(d time.Duration, quit chan bool) {
ticker := time.NewTicker(d) ticker := time.NewTicker(d)
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
// Снапшот (key, указатель) очередей под коротким RLock. models.SyncQueues.Lock()
// Указатели остаются валидными после снятия RLock: очереди из map for qName := range models.SyncQueues.Queues {
// не удаляются во время итерации, а объекты живут в куче. queue := models.SyncQueues.Queues[qName]
models.SyncQueues.RLock()
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 — блокировка одной // Reset deduplication period
// очереди не мешает обработке остальных и HTTP-запросам.
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()
return
}
}
}
// processQueueTick — обработка ОДНОЙ очереди за один такт.
//
// ВЫЗЫВАТЬ под per-queue блокировкой этой очереди (queue.LockMu).
//
// Логическая схема:
// 0. Эвикция сообщений по MessageRetentionPeriod (старше retention удаляются).
// 1. Deduplication-окно (FIFO): удаляем записи старше DeduplicationPeriod.
// 2. Проходим по сообщениям очереди:
// - пустой ReceiptHandle (не in-flight) — пропускаем;
// - VisibilityTimeout ещё не истёк — пропускаем;
// - иначе: возвращаем видимость (сброс ReceiptHandle, разблокировка
// FIFO-группы, обновление ReceiptTime), инкрементируем Retry;
// - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение
// в DLQ и удаляем из исходной очереди.
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 { for dedupId, startTime := range queue.Duplicates {
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) { if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId) log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId)
@@ -125,60 +30,39 @@ func processQueueTick(key string, queue *models.Queue) {
} }
} }
// 2. Возврат видимости истёкших сообщений + перенос в DLQ. log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
// Итерируем ТОЛЬКО по in-flight (receiptIdx), а не по всему срезу — стоимость for i := 0; i < len(queue.Messages); i++ {
// пропорциональна числу сообщений «в полёте», а не размеру очереди (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
}
msg := &queue.Messages[i] msg := &queue.Messages[i]
rh := msg.ReceiptHandle
if rh == "" {
continue // уже возвращено в видимость
}
if msg.VisibilityTimeout.After(now) {
continue // таймаут видимости ещё действует
}
log.Debugf("Making message visible again %s", rh) if msg.ReceiptHandle != "" {
queue.ClearInFlight(i, rh) if msg.VisibilityTimeout.Before(time.Now()) {
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
queue.UnlockGroup(msg.GroupID) queue.UnlockGroup(msg.GroupID)
msg.ReceiptHandle = "" msg.ReceiptHandle = ""
msg.ReceiptTime = time.Now().UTC() msg.ReceiptTime = time.Now().UTC()
msg.Retry++ msg.Retry++
// 3. Перенос в DLQ при превышении MaxReceiveCount.
if queue.MaxReceiveCount > 0 && if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil && queue.DeadLetterQueue != nil &&
msg.Retry >= queue.MaxReceiveCount { msg.Retry >= queue.MaxReceiveCount {
dlqMsg := *msg queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
msgUuid := msg.Uuid queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
dlqName := queue.DeadLetterQueue.Name i--
queue.DeadLetterQueue.LockMu() }
queue.DeadLetterQueue.AppendMessage(dlqMsg) }
queue.DeadLetterQueue.UnlockMu() }
queue.RemoveIndex(i) }
// Персист переноса: иначе после рестарта сообщение «воскреснет» }
// в исходной очереди (не удалено из Redis) и пропадёт из DLQ. models.SyncQueues.Unlock()
_ = persistence.DeleteMessagePersist(key, msgUuid) case <-quit:
persistence.SaveMessage(dlqKeyFor(key, dlqName), &dlqMsg) ticker.Stop()
return
} }
} }
// 4. Компакция tombstone-слотов (амортизированно): срез не растёт бесконечно.
queue.CompactIfNeeded()
} }
func numberOfHiddenMessagesInQueue(queue *models.Queue) int { func numberOfHiddenMessagesInQueue(queue models.Queue) int {
num := 0 num := 0
for i := range queue.Messages { for _, m := 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)) { if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) {
num++ num++
} }
+668
View File
@@ -0,0 +1,668 @@
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
}
}
+5 -3
View File
@@ -40,11 +40,13 @@ func ListQueueTagsV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
queue := models.SyncQueues.GetRLocked(key) models.SyncQueues.RLock()
if queue == nil { defer models.SyncQueues.RUnlock()
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.RUnlockMu()
tags := queue.Tags tags := queue.Tags
if tags == nil { if tags == nil {
+116
View File
@@ -0,0 +1,116 @@
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)
}
-37
View File
@@ -1,37 +0,0 @@
// 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
}
+8 -30
View File
@@ -1,3 +1,5 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// PurgeQueueV1 — очищает все сообщения в очереди тенанта.
package gosqs package gosqs
import ( import (
@@ -13,21 +15,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// PurgeQueueV1 — очищает ВСЕ сообщения очереди тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса → PurgeQueueRequest.
// 2. Тенант из контекста (SigV4 → AccessKey).
// 3. Имя очереди из QueueUrl (последний сегмент).
// 4. Под Lock:
// - проверка существования очереди → QueueNotFound;
// - защита от повторного purge (фикс): повторный вызов в течение 60 секунд
// → PurgeQueueInProgress (требование AWS). Раньше purge можно было
// вызывать без ограничений;
// - очистка in-memory сообщений и dedup-записей;
// - фиксация LastPurgeTime;
// - асинхронная очистка Redis (DEL хэша сообщений) + SaveQueue.
// 5. Ответ: пустой PurgeQueueResponse.
func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) { func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewPurgeQueueRequest() requestBody := models.NewPurgeQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -45,29 +32,20 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
queueName := uriSegments[len(uriSegments)-1] queueName := uriSegments[len(uriSegments)-1]
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.Lock()
if queue == nil { defer models.SyncQueues.Unlock()
if _, ok := models.SyncQueues.Queues[key]; !ok {
log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID) log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
// AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь.
// Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает.
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) log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID)
queue.Reset() models.SyncQueues.Queues[key].Messages = nil
queue.Duplicates = make(map[string]time.Time) models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
queue.LastPurgeTime = time.Now()
// Удаляем все сообщения из Redis одной командой DEL // Удаляем все сообщения из Redis одной командой DEL
persistence.PurgeMessagesPersist(key) persistence.PurgeMessagesPersist(key)
// Сохраняем пустые метаданные очереди // Сохраняем пустые метаданные очереди
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.PurgeQueueResponse{ respStruct := models.PurgeQueueResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
+126
View File
@@ -0,0 +1,126 @@
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)
}
+17 -97
View File
@@ -1,125 +1,45 @@
package gosqs package gosqs
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"shared-sqs/app/models" "shared-sqs/app/models"
) )
// setQueueAttributesV1 — применяет атрибуты очереди с валидацией по AWS-спецификации. // TODO - Support:
// // - attr.MessageRetentionPeriod
// Логическая схема: // - attr.Policy
// 1. Whitelist имён: неизвестное имя атрибута → InvalidAttributeName. // - attr.RedriveAllowPolicy
// Список имён (provided) доступен для Query-протокола (form-параметры func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error {
// Attribute.N.Name); для JSON-протокола provided == nil и проверка пропускается // AWS-совместимые лимиты: clamp значений к допустимым диапазонам
// (структура уже разобрана из тела, неизвестные поля отброшены). if attr.DelaySeconds >= 0 {
// 2. Применяются ТОЛЬКО явно переданные атрибуты (значение != 0). q.DelaySeconds = ClampInt(attr.DelaySeconds.Int(), 0, MaxDelaySeconds)
// ВАЖНО (фикс): раньше условие `attr.X >= 0` срабатывало и для НЕ переданных
// полей (zero-value = 0) — вызов SetQueueAttributes с одним атрибутом
// обнулял все остальные атрибуты очереди.
// 3. Значения вне диапазонов AWS → ошибка InvalidParameterValue
// (раньше значения молча клэмпились в допустимый диапазон).
// 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] {
return fmt.Errorf("InvalidAttributeName")
} }
if attr.MaximumMessageSize >= 0 {
q.MaximumMessageSize = ClampInt(attr.MaximumMessageSize.Int(), MinMessageSizeLimit, MaxMessageSizeLimit)
} }
if attr.MessageRetentionPeriod > 0 {
// Шаг 2: применяем только явно переданные атрибуты с проверкой диапазонов. q.MessageRetentionPeriod = ClampInt(attr.MessageRetentionPeriod.Int(), MinMessageRetentionPeriod, MaxMessageRetentionPeriod)
// Диапазоны AWS: DelaySeconds 0–900, MaximumMessageSize 1024–262144,
// MessageRetentionPeriod 60–1209600, ReceiveMessageWaitTimeSeconds 0–20,
// VisibilityTimeout 0–43200.
if attr.DelaySeconds != 0 {
if attr.DelaySeconds.Int() < 0 || attr.DelaySeconds.Int() > MaxDelaySeconds {
return fmt.Errorf("InvalidParameterValue")
} }
q.DelaySeconds = attr.DelaySeconds.Int() if attr.ReceiveMessageWaitTimeSeconds > 0 {
q.ReceiveMessageWaitTimeSeconds = ClampInt(attr.ReceiveMessageWaitTimeSeconds.Int(), 0, MaxReceiveMessageWaitTimeSeconds)
} }
if attr.MaximumMessageSize != 0 { if attr.VisibilityTimeout >= 0 {
if attr.MaximumMessageSize.Int() < MinMessageSizeLimit || attr.MaximumMessageSize.Int() > MaxMessageSizeLimit { q.VisibilityTimeout = ClampInt(attr.VisibilityTimeout.Int(), 0, MaxVisibilityTimeout)
return fmt.Errorf("InvalidParameterValue")
} }
q.MaximumMessageSize = attr.MaximumMessageSize.Int()
}
if attr.MessageRetentionPeriod != 0 {
if attr.MessageRetentionPeriod.Int() < MinMessageRetentionPeriod || attr.MessageRetentionPeriod.Int() > MaxMessageRetentionPeriod {
return fmt.Errorf("InvalidParameterValue")
}
q.MessageRetentionPeriod = attr.MessageRetentionPeriod.Int()
}
if attr.ReceiveMessageWaitTimeSeconds != 0 {
if attr.ReceiveMessageWaitTimeSeconds.Int() < 0 || attr.ReceiveMessageWaitTimeSeconds.Int() > MaxReceiveMessageWaitTimeSeconds {
return fmt.Errorf("InvalidParameterValue")
}
q.ReceiveMessageWaitTimeSeconds = attr.ReceiveMessageWaitTimeSeconds.Int()
}
if attr.VisibilityTimeout != 0 {
if attr.VisibilityTimeout.Int() < 0 || attr.VisibilityTimeout.Int() > MaxVisibilityTimeout {
return fmt.Errorf("InvalidParameterValue")
}
q.VisibilityTimeout = attr.VisibilityTimeout.Int()
}
// Шаг 3: RedrivePolicy (dead-letter queue).
if attr.RedrivePolicy != (models.RedrivePolicy{}) { if attr.RedrivePolicy != (models.RedrivePolicy{}) {
arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":") arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":")
queueName := arnArray[len(arnArray)-1] queueName := arnArray[len(arnArray)-1]
// DLQ ищем по tenant-scoped ключу — как хранятся все очереди. deadLetterQueue, ok := models.SyncQueues.Queues[queueName]
dlqKey := tenantAccessKey + ":" + queueName
models.SyncQueues.RLock()
deadLetterQueue, ok := models.SyncQueues.Queues[dlqKey]
models.SyncQueues.RUnlock()
if !ok { if !ok {
log.Error("Invalid RedrivePolicy Attribute") log.Error("Invalid RedrivePolicy Attribute")
return fmt.Errorf("InvalidAttributeValue") 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.DeadLetterQueue = deadLetterQueue
q.MaxReceiveCount = attr.RedrivePolicy.MaxReceiveCount.Int() 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 return nil
} }
+123
View File
@@ -0,0 +1,123 @@
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)
}
+75 -155
View File
@@ -19,27 +19,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ReceiveMessageV1 — получает до MaxNumberOfMessages сообщений из очереди тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса (Query-форма или JSON) → ReceiveMessageRequest.
// 2. Аутентифицированный тенант из контекста (SigV4 → AccessKey).
// 3. Валидация входных параметров (AWS-совместимая):
// - MaxNumberOfMessages: 1–10; 0 (не задан) = 1. Выход за диапазон — ошибка
// InvalidParameterValue (раньше значение молча клэмпилось).
// - VisibilityTimeout: 0–43200 секунд, иначе InvalidParameterValue.
// - WaitTimeSeconds: 0–20 секунд; 0 = брать атрибут очереди
// ReceiveMessageWaitTimeSeconds.
// 4. Имя очереди: из QueueUrl (последний сегмент) или из пути /{account}/{queueName}.
// 5. Выборка сообщений — АТОМАРНО под одним Lock (см. receiveMessagesUnderLock):
// устранена гонка TOCTOU, когда два параллельных receive видели одни и те же
// сообщения, но один из них получал пустой ответ («потери» сообщений).
// 6. Long polling: при WaitTimeSeconds > 0 ждём сообщений тиками по 100 мс до
// истечения deadline; прерываемся по отмене HTTP-запроса (клиент отвалился).
//
// Возвращает HTTP 200 и ReceiveMessageResponse (пустой Result, если сообщений нет).
func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) { func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
// --- Шаг 1: разбор тела запроса ---
requestBody := models.NewReceiveMessageRequest() requestBody := models.NewReceiveMessageRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
if !ok { if !ok {
@@ -47,31 +27,18 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// --- Шаг 2: тенант из контекста ---
t := getTenantFromContext(req) t := getTenantFromContext(req)
if t == nil { if t == nil {
return utils.CreateErrorResponseV1("InvalidClientTokenId", true) return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
} }
// --- Шаг 3: валидация параметров ---
// MaxNumberOfMessages: AWS допускает 1–10; 0 (параметр не задан) означает 1.
// Выход за диапазон — ошибка, а не молчаливый clamp (так делает AWS).
maxNumberOfMessages := requestBody.MaxNumberOfMessages maxNumberOfMessages := requestBody.MaxNumberOfMessages
if maxNumberOfMessages == 0 { if maxNumberOfMessages == 0 {
maxNumberOfMessages = MinNumberOfMessagesLimit maxNumberOfMessages = 1
}
if maxNumberOfMessages < MinNumberOfMessagesLimit || maxNumberOfMessages > MaxNumberOfMessagesLimit {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// Fix #8: clamp MaxNumberOfMessages к AWS лимиту 1–10
maxNumberOfMessages = ClampInt(maxNumberOfMessages, MinNumberOfMessagesLimit, MaxNumberOfMessagesLimit)
// VisibilityTimeout: AWS допускает 0–43200.
if requestBody.VisibilityTimeout < 0 || requestBody.VisibilityTimeout > MaxVisibilityTimeout {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// --- Шаг 4: имя очереди ---
// QueueUrl не задан → берём из пути /{account}/{queueName} (старый стиль клиентов).
// QueueUrl задан → последний сегмент URL = имя очереди.
queueName := "" queueName := ""
if requestBody.QueueUrl == "" { if requestBody.QueueUrl == "" {
vars := mux.Vars(req) vars := mux.Vars(req)
@@ -83,156 +50,118 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
// ОДИН RLock на проверку существования И чтение атрибута очереди: if _, ok := models.SyncQueues.Queues[key]; !ok {
// раньше было два отдельных RLock — между ними очередь могла быть удалена
// (nil-deref при чтении ReceiveMessageWaitTimeSeconds).
queue := models.SyncQueues.GetRLocked(key)
if queue == nil {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
var messages []*models.ResultMessage
respStruct := models.ReceiveMessageResponse{}
// Валидация VisibilityTimeout: AWS SQS допускает 0–43200
if requestBody.VisibilityTimeout < 0 || requestBody.VisibilityTimeout > MaxVisibilityTimeout {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
waitTimeSeconds := requestBody.WaitTimeSeconds waitTimeSeconds := requestBody.WaitTimeSeconds
if waitTimeSeconds == 0 { if waitTimeSeconds == 0 {
waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds models.SyncQueues.RLock()
waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds
models.SyncQueues.RUnlock()
} }
queue.RUnlockMu() // Валидация WaitTimeSeconds: AWS SQS допускает 0–20, иначе ошибка
if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds { if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds {
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// --- Шаги 6–7: выборка сообщений (атомарно) + long polling ---
if waitTimeSeconds > 0 { if waitTimeSeconds > 0 {
deadline := time.Now().Add(time.Duration(waitTimeSeconds) * time.Second) deadline := time.Now().Add(time.Duration(waitTimeSeconds) * time.Second)
pollTicker := time.NewTicker(100 * time.Millisecond) pollTicker := time.NewTicker(100 * time.Millisecond)
defer pollTicker.Stop() defer pollTicker.Stop()
for { for {
// Атомарная проверка+выборка: между проверкой и выдачей другой models.SyncQueues.RLock()
// получатель не может «съесть» сообщения — они помечаются queue, queueFound := models.SyncQueues.Queues[key]
// ReceiptHandle в том же критическом участке (см. receiveMessagesUnderLock). if !queueFound {
messages, found := receiveMessagesUnderLock(key, requestBody.VisibilityTimeout, maxNumberOfMessages) models.SyncQueues.RUnlock()
if found { return utils.CreateErrorResponseV1("QueueNotFound", true)
return http.StatusOK, buildReceiveResponse(messages)
} }
if time.Now().After(deadline) { messageFound := queueHasReceivableMessages(queue)
return http.StatusOK, buildReceiveResponse(nil) models.SyncQueues.RUnlock()
if messageFound || time.Now().After(deadline) {
break
} }
select { select {
case <-req.Context().Done(): case <-req.Context().Done():
// Клиент разорвал соединение — завершаем без ошибки. return http.StatusOK, models.ReceiveMessageResponse{
return http.StatusOK, buildReceiveResponse(nil) Xmlns: models.BaseXmlns,
Result: models.ReceiveMessageResult{},
Metadata: models.BaseResponseMetadata,
}
case <-pollTicker.C: case <-pollTicker.C:
} }
} }
} }
log.Debugf("Getting Message from Queue:%s (tenant: %s)", queueName, t.ID)
messages, _ := receiveMessagesUnderLock(key, requestBody.VisibilityTimeout, maxNumberOfMessages) models.SyncQueues.Lock()
return http.StatusOK, buildReceiveResponse(messages) defer models.SyncQueues.Unlock()
}
// receiveMessagesUnderLock — АТОМАРНАЯ проверка и выборка сообщений очереди. if len(models.SyncQueues.Queues[key].Messages) > 0 {
//
// ВЫЗЫВАТЬ БЕЗ удержания SyncQueues-блокировок.
// Возвращает:
// - messages — готовые к ответу ResultMessage (уже помечены in-flight);
// - found=false — пригодных сообщений нет (очередь пуста, всё in-flight,
// всё на задержке доставки или все FIFO-группы заблокированы).
//
// Почему весь цикл под одним Lock (фикс гонки TOCTOU):
// раньше проверка queueHasReceivableMessages выполнялась под RLock, а выборка —
// под Lock отдельно. Два параллельных receive оба проходили проверку, затем
// первый забирал все сообщения, а второй получал пустой ответ — сообщения
// «терялись» для второго клиента. Теперь проверка и пометка ReceiptHandle
// выполняются в одном критическом участке.
//
// Для каждого выбранного сообщения:
// - генерируется ReceiptHandle = {Uuid}#{случайный id} — уникален для каждой
// доставки (два разных receive не имеют одинакового хендла);
// - проставляется VisibilityTimeout (параметр запроса или атрибут очереди);
// - инкрементируется NumberOfReceives — счётчик доставок
// (ApproximateReceiveCount в ответе);
// - для FIFO-очереди блокируется группа (LockGroup) до DeleteMessage/таймаута.
func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages int) ([]*models.ResultMessage, bool) {
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
}
// Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
if !queueHasReceivableMessages(queue) {
return nil, false
}
messages := make([]*models.ResultMessage, 0)
numMsg := 0 numMsg := 0
for i := range queue.Messages { messages = make([]*models.ResultMessage, 0)
for i := range models.SyncQueues.Queues[key].Messages {
if numMsg >= maxNumberOfMessages { if numMsg >= maxNumberOfMessages {
break break
} }
if queue.IsDead(i) {
continue // tombstone: сообщение логически удалено (P1.2) if models.SyncQueues.Queues[key].Messages[i].ReceiptHandle != "" {
} continue
msg := &queue.Messages[i]
if msg.ReceiptHandle != "" {
continue // сообщение уже в полёте у другого получателя
}
if !msg.IsReadyForReceipt() {
continue // задержка доставки (DelaySeconds / random latency) ещё не истекла
}
if queue.IsFIFO {
if queue.IsLocked(msg.GroupID) {
continue // FIFO-группа заблокирована предыдущим in-flight сообщением
}
queue.LockGroup(msg.GroupID)
} }
// Пометка in-flight: уникальный ReceiptHandle + таймаут видимости. msg := &models.SyncQueues.Queues[key].Messages[i]
msg.ReceiptHandle = msg.Uuid + "#" + uuid.NewString() if !msg.IsReadyForReceipt() {
msg.ReceiptTime = time.Now().UTC() continue
if visibilityTimeout != 0 { }
msg.VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
} else { if models.SyncQueues.Queues[key].IsFIFO {
msg.VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second) if models.SyncQueues.Queues[key].IsLocked(msg.GroupID) {
continue
}
models.SyncQueues.Queues[key].LockGroup(msg.GroupID)
}
randomId := uuid.NewString()
msg.ReceiptHandle = msg.Uuid + "#" + randomId
msg.ReceiptTime = time.Now().UTC()
if requestBody.VisibilityTimeout != 0 {
msg.VisibilityTimeout = time.Now().Add(time.Duration(requestBody.VisibilityTimeout) * time.Second)
} else {
msg.VisibilityTimeout = time.Now().Add(time.Duration(models.SyncQueues.Queues[key].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)) messages = append(messages, buildResultMessage(msg))
numMsg++ numMsg++
} }
if numMsg == 0 { respStruct = models.ReceiveMessageResponse{
return nil, false "http://queue.amazonaws.com/doc/2012-11-05/",
models.ReceiveMessageResult{Messages: messages},
models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"},
} }
return messages, true } else {
log.Warning("No messages in Queue:", queueName)
respStruct = models.ReceiveMessageResponse{
Xmlns: "http://queue.amazonaws.com/doc/2012-11-05/",
Result: models.ReceiveMessageResult{},
Metadata: models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"},
}
}
return http.StatusOK, respStruct
} }
// buildReceiveResponse — формирует успешный ответ ReceiveMessage.
// При messages == nil Result содержит пустой список (как AWS: без <Message>).
func buildReceiveResponse(messages []*models.ResultMessage) models.ReceiveMessageResponse {
return models.ReceiveMessageResponse{
Xmlns: models.BaseXmlns,
Result: models.ReceiveMessageResult{Messages: messages},
Metadata: models.BaseResponseMetadata,
}
}
// buildResultMessage — конвертирует SqsMessage в ResultMessage (XML/JSON ответ).
//
// Атрибуты AWS-совместимы:
// - ApproximateFirstReceiveTimestamp — время первой выдачи (ReceiptTime);
// - ApproximateReceiveCount — число доставок (NumberOfReceives + 1);
// - SentTimestamp — реальное время отправки (SentTime), НЕ время выдачи.
//
// MD5 тела берётся из кэша (посчитан при SendMessage) — без пересчёта.
func buildResultMessage(m *models.SqsMessage) *models.ResultMessage { func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
return &models.ResultMessage{ return &models.ResultMessage{
MessageId: m.Uuid, MessageId: m.Uuid,
@@ -244,23 +173,14 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
Attributes: map[string]string{ Attributes: map[string]string{
"ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)), "ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)),
"SenderId": models.CurrentEnvironment.AccountID, "SenderId": models.CurrentEnvironment.AccountID,
"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives), "ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives+1),
"SentTimestamp": fmt.Sprintf("%d", m.SentTime.UnixNano()/int64(time.Millisecond)), // Фикс: реальное время отправки "SentTimestamp": fmt.Sprintf("%d", m.SentTime.UnixNano()/int64(time.Millisecond)), // Фикс: реальное время отправки
}, },
} }
} }
// queueHasReceivableMessages — проверяет, есть ли в очереди хотя бы ОДНО готовое
// к выдаче сообщение: не in-flight, задержка доставки истекла, FIFO-группа не
// заблокирована. Используется как быстрая предпроверка перед выборкой.
//
// ВАЖНО: вызывается ТОЛЬКО под удержанием блокировки (Lock или RLock) —
// читает общее состояние очереди.
func queueHasReceivableMessages(queue *models.Queue) bool { func queueHasReceivableMessages(queue *models.Queue) bool {
for i := range queue.Messages { for i := range queue.Messages {
if queue.IsDead(i) {
continue // tombstone (P1.2)
}
msg := &queue.Messages[i] msg := &queue.Messages[i]
if msg.ReceiptHandle != "" { if msg.ReceiptHandle != "" {
continue continue
+404
View File
@@ -0,0 +1,404 @@
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
+22 -77
View File
@@ -1,3 +1,7 @@
// Изменено: 2026-04-11 — убрано логирование тела сообщения (perf + security)
// SendMessageV1 — добавляет сообщение в очередь тенанта.
// Ловушка #6: queueName извлекается как ПОСЛЕДНИЙ сегмент URL — при URL вида
// http://host/tenantID/queueName последний сегмент = queueName (правильно).
package gosqs package gosqs
import ( import (
@@ -17,27 +21,6 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
// SendMessageV1 — добавляет сообщение в очередь тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса (Query/JSON) → SendMessageRequest.
// 2. Аутентифицированный тенант из контекста (SigV4 → AccessKey).
// 3. Валидации:
// - MessageBody обязателен (пустой → MissingParameter);
// - DeduplicationId и GroupId ≤ 128 символов;
// - MessageAttributes ≤ 10;
// - FIFO-очередь БЕЗ MessageGroupId → MissingParameter (AWS требует GroupId
// для каждой отправки в FIFO);
// - размер тела ≤ MaximumMessageSize очереди → MessageTooBig;
// - число сообщений < лимита очереди (OOM-защита) → OverLimit.
// 4. Построение SqsMessage: MD5 тела и атрибутов, UUID, метки времени, задержка.
// 5. Атомарно под Lock:
// - FIFO: порядковый номер группы (NextSequenceNumber);
// - проверка дубликата по deduplicationId (дубль НЕ добавляется);
// - добавление в очередь + SaveMessage/SaveQueue в Redis.
// Примечание: сетевые Redis-записи асинхронны (asyncWrite), под Lock
// выполняется только json.Marshal — блокировка не удерживается на сетевом I/O.
// 6. Ответ: MessageId, MD5OfMessageBody/MD5OfMessageAttributes, SequenceNumber (FIFO).
func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) { func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewSendMessageRequest() requestBody := models.NewSendMessageRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -83,30 +66,17 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.RLock()
if queue == nil { queue, exists := models.SyncQueues.Queues[key]
if !exists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
maxMessageSize := queue.MaximumMessageSize maxMessageSize := queue.MaximumMessageSize
currentMsgCount := queue.LiveCount() currentMsgCount := len(queue.Messages)
queueIsFIFO := queue.IsFIFO queueIsFIFO := queue.IsFIFO
contentBasedDedup := queue.ContentBasedDeduplication
delaySecs := queue.DelaySeconds delaySecs := queue.DelaySeconds
models.SyncQueues.RUnlock()
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
// Раньше сообщение просто попадало в группу с пустым именем.
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 { if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
return utils.CreateErrorResponseV1("MessageTooBig", true) return utils.CreateErrorResponseV1("MessageTooBig", true)
@@ -117,9 +87,6 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
return utils.CreateErrorResponseV1("OverLimit", true) return utils.CreateErrorResponseV1("OverLimit", true)
} }
if requestBody.DelaySeconds < 0 || requestBody.DelaySeconds > 900 {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
if requestBody.DelaySeconds != 0 { if requestBody.DelaySeconds != 0 {
delaySecs = requestBody.DelaySeconds delaySecs = requestBody.DelaySeconds
} }
@@ -137,46 +104,24 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
msg.SentTime = time.Now() msg.SentTime = time.Now()
msg.DelaySecs = delaySecs msg.DelaySecs = delaySecs
// Повторная проверка лимита под per-queue Lock (между снимком выше и текущим models.SyncQueues.Lock()
// моментом другие горутины могли добавить сообщения — мягкий OOM-лимит). fifoSeqNumber := ""
if queue.LiveCount() >= MaxMessagesForQueue(queue.IsFIFO) { if models.SyncQueues.Queues[key].IsFIFO {
return utils.CreateErrorResponseV1("OverLimit", true) fifoSeqNumber = models.SyncQueues.Queues[key].NextSequenceNumber(messageGroupID)
}
// Глобальный предохранитель памяти (HeapAlloc) — защита от OOM суммой тенантов.
if heapOverLimit() {
return utils.CreateErrorResponseV1("OverLimit", true)
} }
fifoSeqNumber := "" if !models.SyncQueues.Queues[key].IsDuplicate(messageDeduplicationID) {
persistErr := false models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
if !queue.IsDuplicate(messageDeduplicationID) { // Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size)
idx := queue.AppendMessage(msg) persistence.SaveMessage(key, &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)
}
// Метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue)
}
} else { } else {
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName) log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
} }
if persistErr { models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID)
return utils.CreateErrorResponseV1("InternalError", true) // Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
} persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock()
// Логируем только метаданные — тело сообщения не логируется (perf + security) // Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody)) log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody))
+19 -62
View File
@@ -43,14 +43,16 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
queue := models.SyncQueues.GetRLocked(key) models.SyncQueues.RLock()
if queue == nil { queue, exists := models.SyncQueues.Queues[key]
if !exists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
maxMsgSize := queue.MaximumMessageSize maxMsgSize := queue.MaximumMessageSize
currentMsgCount := queue.LiveCount() currentMsgCount := len(queue.Messages)
queueIsFIFO := queue.IsFIFO queueIsFIFO := queue.IsFIFO
queue.RUnlockMu() models.SyncQueues.RUnlock()
sendEntries := requestBody.Entries sendEntries := requestBody.Entries
@@ -102,23 +104,9 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
return utils.CreateErrorResponseV1("OverLimit", true) return utils.CreateErrorResponseV1("OverLimit", true)
} }
queue.LockMu() models.SyncQueues.Lock()
defer queue.UnlockMu() queue = models.SyncQueues.Queues[key]
// Повторная проверка лимита под 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)) newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
appendedIdx := make([]int, 0, len(sendEntries))
appendedDedupIDs := make([]string, 0, len(sendEntries))
for _, sendEntry := range sendEntries { for _, sendEntry := range sendEntries {
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody} msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
if len(sendEntry.MessageAttributes) > 0 { if len(sendEntry.MessageAttributes) > 0 {
@@ -127,41 +115,20 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
} }
msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody) msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody)
msg.GroupID = sendEntry.MessageGroupId msg.GroupID = sendEntry.MessageGroupId
msg.DelaySecs = sendEntry.DelaySeconds msg.DeduplicationID = sendEntry.MessageDeduplicationId
// 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.Uuid = uuid.NewString()
msg.SentTime = time.Now() 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 := "" fifoSeqNumber := ""
if queue.IsFIFO { if queue.IsFIFO {
fifoSeqNumber = queue.NextSequenceNumber(sendEntry.MessageGroupId) fifoSeqNumber = queue.NextSequenceNumber(sendEntry.MessageGroupId)
} }
// Регистрируем dedupId сразу — второй entry с тем же id увидит его. if !queue.IsDuplicate(sendEntry.MessageDeduplicationId) {
queue.InitDuplicatation(dedupID) queue.Messages = append(queue.Messages, msg)
appendedIdx = append(appendedIdx, queue.AppendMessage(msg)) } else {
newMsgs = append(newMsgs, msg) log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", sendEntry.MessageDeduplicationId, queueName)
appendedDedupIDs = append(appendedDedupIDs, dedupID) }
queue.InitDuplicatation(sendEntry.MessageDeduplicationId)
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{ sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
Id: sendEntry.Id, Id: sendEntry.Id,
@@ -170,25 +137,15 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
MD5OfMessageAttributes: msg.MD5OfMessageAttributes, MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
SequenceNumber: fifoSeqNumber, SequenceNumber: fifoSeqNumber,
}) })
newMsgs = append(newMsgs, msg)
// Логируем только метаданные — тело сообщения не логируется (perf + security) // Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody)) log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody))
} }
// Персистим СИНХРОННО до ответа (как одиночный SendMessage): иначе краш в окне // Персистим новые сообщения отдельно — O(batch_size*msg_size) вместо O(N*msg_size)
// между 200 OK и async-записью потеряет подтверждённые сообщения. persistence.SaveMessages(key, newMsgs)
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 // Метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock()
respStruct := models.SendMessageBatchResponse{ respStruct := models.SendMessageBatchResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
+346
View File
@@ -0,0 +1,346 @@
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)
}
+220
View File
@@ -0,0 +1,220 @@
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)
}
+8 -24
View File
@@ -1,3 +1,6 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// SetQueueAttributesV1 — устанавливает атрибуты очереди тенанта.
// Ловушка #9: при RedrivePolicy парсим ARN DLQ и DLQ тоже должна принадлежать тому же тенанту.
package gosqs package gosqs
import ( import (
@@ -12,17 +15,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// SetQueueAttributesV1 — устанавливает атрибуты очереди тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса (Query/JSON) → SetQueueAttributesRequest.
// 2. QueueUrl обязателен → InvalidParameterValue.
// 3. Тенант из контекста (SigV4 → AccessKey).
// 4. Имя очереди из QueueUrl (последний сегмент) + tenant-scoped ключ.
// 5. Под Lock: проверка существования очереди → QueueNotFound;
// применение атрибутов с валидацией (setQueueAttributesV1);
// сохранение очереди в Redis (асинхронно, маршалинг под Lock).
// 6. Ответ: пустой SetQueueAttributesResponse (AWS так и отвечает).
func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) { func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewSetQueueAttributesRequest() requestBody := models.NewSetQueueAttributesRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -45,22 +37,14 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID) log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID)
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.Lock()
if queue == nil { defer models.SyncQueues.Unlock()
queue, ok := models.SyncQueues.Queues[key]
if !ok {
log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID) log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu() if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil {
// Список имён переданных атрибутов — для whitelist-валидации.
// Query-протокол: имена видны в form (Attribute.N.Name).
// JSON-протокол: структура уже разобрана из тела, список имён недоступен → nil
// (whitelist-проверка пропускается, неизвестные поля отброшены парсером).
var provided map[string]string
if req.Header.Get("Content-Type") != "application/x-amz-json-1.0" {
provided = utils.ExtractQueueAttributes(req.PostForm)
}
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
return utils.CreateErrorResponseV1(err.Error(), true) return utils.CreateErrorResponseV1(err.Error(), true)
} }
// Сохраняем атрибуты в Redis пока держим Lock (через defer) // Сохраняем атрибуты в Redis пока держим Lock (через defer)
+169
View File
@@ -0,0 +1,169 @@
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)
}
+5 -3
View File
@@ -41,11 +41,13 @@ func TagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.Lock()
if queue == nil { defer models.SyncQueues.Unlock()
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
// Инициализируем Tags если nil (для старых очередей, созданных без Tags) // Инициализируем Tags если nil (для старых очередей, созданных без Tags)
if queue.Tags == nil { if queue.Tags == nil {
+16 -26
View File
@@ -4,12 +4,11 @@
package gosqs package gosqs
import ( import (
"net/http" "net/http"
"strings"
"shared-sqs/app/auth" "shared-sqs/app/auth"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
// tenantQueueKey — внутренний ключ очереди в SyncQueues в формате "{accessKey}:{queueName}". // tenantQueueKey — внутренний ключ очереди в SyncQueues в формате "{accessKey}:{queueName}".
@@ -18,15 +17,8 @@ func tenantQueueKey(tenantAccessKey, queueName string) string {
return tenantAccessKey + ":" + queueName return tenantAccessKey + ":" + queueName
} }
// dlqKeyFor — ключ DLQ по ключу исходной очереди (формат "{accessKey}:{name}") // getTenantFromContext — извлекает тенанта из request context.
// и имени DLQ. Используется для персиста перенесённых в DLQ сообщений. // Возвращает nil если тенант не найден (не должно быть — auth middleware должен это поймать раньше).
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 { func getTenantFromContext(r *http.Request) *tenant.Tenant {
t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant) t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant)
return t return t
@@ -35,10 +27,6 @@ return t
// tenantQueueURL — формирует URL очереди для тенанта. // tenantQueueURL — формирует URL очереди для тенанта.
// Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive. // Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive.
func tenantQueueURL(t *tenant.Tenant, queueName string) string { 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 host := models.CurrentEnvironment.Host
port := models.CurrentEnvironment.Port port := models.CurrentEnvironment.Port
region := models.CurrentEnvironment.Region region := models.CurrentEnvironment.Region
@@ -48,19 +36,21 @@ return "http://" + region + "." + host + ":" + port + "/" + t.ID + "/" + queueNa
return "http://" + host + ":" + port + "/" + t.ID + "/" + queueName 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 очереди для тенанта. // tenantQueueARN — формирует ARN очереди для тенанта.
func tenantQueueARN(t *tenant.Tenant, queueName string) string { func tenantQueueARN(t *tenant.Tenant, queueName string) string {
return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName
} }
// countTenantQueues — считает количество очередей тенанта (через tenant-индекс, O(1)). // countTenantQueues — считает количество очередей тенанта в SyncQueues.
// Используется для проверки лимита MaxQueues. // Используется для проверки лимита MaxQueues.
// Вызывать под SyncQueues.RLock()/Lock(). // Вызывать под SyncQueues.RLock().
func countTenantQueues(tenantAccessKey string) int { func countTenantQueues(tenantAccessKey string) int {
return models.SyncQueues.CountTenantQueues(tenantAccessKey) prefix := tenantAccessKey + ":"
count := 0
for key := range models.SyncQueues.Queues {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
count++
}
}
return count
} }
-35
View File
@@ -1,35 +0,0 @@
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)
}
}
+5 -3
View File
@@ -41,11 +41,13 @@ func UntagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
queue := models.SyncQueues.GetLocked(key) models.SyncQueues.Lock()
if queue == nil { defer models.SyncQueues.Unlock()
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
if queue.Tags != nil { if queue.Tags != nil {
for _, tagKey := range requestBody.TagKeys { for _, tagKey := range requestBody.TagKeys {
-151
View File
@@ -1,151 +0,0 @@
// 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()
}
-69
View File
@@ -1,69 +0,0 @@
// app/metrics/gauge_updater.go
// Периодическое обновление gauge-метрик (количество очередей и сообщений per tenant).
// Запускается как горутина из main, обновляет каждые 15 секунд.
// Не блокирует SQS-запросы — читает через RLock.
// Created: 2026-04-12
package metrics
import (
"strings"
"time"
"shared-sqs/app/models"
log "github.com/sirupsen/logrus"
)
// StartGaugeUpdater — запускает горутину, которая каждые interval секунд
// пересчитывает количество очередей и сообщений по тенантам для Prometheus gauge.
// Останавливается при закрытии канала quit.
func StartGaugeUpdater(interval time.Duration, quit <-chan bool) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
updateGauges()
case <-quit:
log.Debug("metrics: gauge updater stopped")
return
}
}
}()
log.Infof("metrics: gauge updater started, interval=%s", interval)
}
// updateGauges — пересчитывает sqs_queues_count и sqs_messages_count per tenant.
// Формат ключа очереди: "{accessKey}:{queueName}" — берём часть до первого ":".
func updateGauges() {
// Снимок указателей под коротким 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 snapshot {
// Ключ формата "accessKey:queueName" — tenant определяется по accessKey
parts := strings.SplitN(key, ":", 2)
tenantKey := parts[0]
tenantQueues[tenantKey]++
queue.RLockMu()
tenantMessages[tenantKey] += queue.LiveCount()
queue.RUnlockMu()
}
// Сбрасываем старые значения и записываем новые
QueuesGauge.Reset()
MessagesGauge.Reset()
for tenant, count := range tenantQueues {
QueuesGauge.WithLabelValues(tenant).Set(float64(count))
}
for tenant, count := range tenantMessages {
MessagesGauge.WithLabelValues(tenant).Set(float64(count))
}
}
-71
View File
@@ -1,71 +0,0 @@
// app/metrics/metrics.go
// Prometheus-совместимые метрики shared-sqs для Victoria Metrics / Grafana.
// Отдаёт метрики на /metrics в стандартном формате Prometheus.
// VMAgent в кластере скрейпит этот endpoint через VMServiceScrape.
// Created: 2026-04-12
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// RequestsTotal — счётчик SQS-запросов по тенанту и операции.
// Пример: sqs_requests_total{tenant="t-abc123", operation="SendMessage"} = 42
var RequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "sqs_requests_total",
Help: "Total number of SQS API requests by tenant and operation",
},
[]string{"tenant", "operation"},
)
// RequestBytesTotal — суммарный объём тел запросов (Content-Length) по тенанту и операции.
// Для биллинга по трафику.
var RequestBytesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "sqs_request_bytes_total",
Help: "Total request body bytes by tenant and operation",
},
[]string{"tenant", "operation"},
)
// RequestDuration — гистограмма latency SQS-операций в секундах.
// Бакеты подобраны для типичного SQS: от 1ms до 30s (long polling).
var RequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "sqs_request_duration_seconds",
Help: "SQS API request duration in seconds",
Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 20, 30},
},
[]string{"operation"},
)
// ErrorsTotal — счётчик ошибочных SQS-ответов (HTTP >= 400) по операции.
var ErrorsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "sqs_errors_total",
Help: "Total number of SQS API error responses by operation",
},
[]string{"operation"},
)
// QueuesGauge — текущее количество очередей по тенанту.
// Обновляется периодически, не на каждый запрос.
var QueuesGauge = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "sqs_queues_count",
Help: "Current number of queues per tenant",
},
[]string{"tenant"},
)
// MessagesGauge — текущее количество сообщений по тенанту.
// Обновляется периодически, не на каждый запрос.
var MessagesGauge = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "sqs_messages_count",
Help: "Current number of messages per tenant",
},
[]string{"tenant"},
)
-1
View File
@@ -23,7 +23,6 @@ Port string
SqsPort string SqsPort string
Region string Region string
AccountID string AccountID string
ExternalURLBase string
LogToFile bool LogToFile bool
LogFile string LogFile string
EnableDuplicates bool EnableDuplicates bool
-4
View File
@@ -4,10 +4,6 @@ import (
"time" "time"
) )
// Version — версия сервиса. Переопределяется при сборке:
// -ldflags "-X shared-sqs/app/models.Version=<версия>"
var Version = "dev"
var BaseXmlns = "http://queue.amazonaws.com/doc/2012-11-05/" var BaseXmlns = "http://queue.amazonaws.com/doc/2012-11-05/"
var BaseResponseMetadata = ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"} var BaseResponseMetadata = ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"}
-28
View File
@@ -34,31 +34,3 @@ func (s *StringToInt) UnmarshalJSON(data []byte) error {
func (s *StringToInt) Int() int { func (s *StringToInt) Int() int {
return int(*s) 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)
}
+75
View File
@@ -0,0 +1,75 @@
package models
import (
"encoding/json"
"testing"
"shared-sqs/app/test"
"github.com/stretchr/testify/assert"
)
type StringToIntStruct struct {
Field1 StringToInt `json:"Field1"`
Field2 StringToInt `json:"Field2"`
}
func TestStringToInt_unmarshalJSON_int(t *testing.T) {
body := struct {
Field1 int `json:"Field1"`
Field2 int `json:"Field2"`
}{
Field1: 1,
Field2: 2,
}
_, r := test.GenerateRequestInfo("POST", "/", body, true)
result := &StringToIntStruct{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(result)
assert.Nil(t, err)
assert.Equal(t, StringToInt(1), result.Field1)
assert.Equal(t, StringToInt(2), result.Field2)
}
func TestStringToInt_unmarshalJSON_string(t *testing.T) {
body := struct {
Field1 string `json:"Field1"`
Field2 string `json:"Field2"`
}{
Field1: "1",
Field2: "2",
}
_, r := test.GenerateRequestInfo("POST", "/", body, true)
result := &StringToIntStruct{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(result)
assert.Nil(t, err)
assert.Equal(t, StringToInt(1), result.Field1)
assert.Equal(t, StringToInt(2), result.Field2)
}
func TestStringToInt_unmarshalJSON_invalid_type_returns_error(t *testing.T) {
body := struct {
Field1 bool `json:"Field1"`
Field2 bool `json:"Field2"`
}{
Field1: true,
Field2: false,
}
_, r := test.GenerateRequestInfo("POST", "/", body, true)
result := &StringToIntStruct{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(result)
assert.Error(t, err)
}
func TestStringToInt_int_returns_int_type(t *testing.T) {
s := StringToInt(1)
assert.Equal(t, int(1), s.Int())
}
-8
View File
@@ -24,14 +24,6 @@ func init() {
"LimitExceeded": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."}, "LimitExceeded": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."},
// MissingParameter — обязательный параметр отсутствует (например, пустой MessageBody) // MissingParameter — обязательный параметр отсутствует (например, пустой MessageBody)
"MissingParameter": {HttpError: http.StatusBadRequest, Type: "MissingParameter", Code: "MissingParameter", Message: "The request must contain the parameter MessageBody."}, "MissingParameter": {HttpError: http.StatusBadRequest, Type: "MissingParameter", Code: "MissingParameter", Message: "The request must contain the parameter MessageBody."},
// InvalidAttributeName — передан атрибут с неизвестным именем (SetQueueAttributes)
"InvalidAttributeName": {HttpError: http.StatusBadRequest, Type: "InvalidAttributeName", Code: "AWS.SimpleQueueService.InvalidAttributeName", Message: "Unknown Attribute."},
// PurgeQueueInProgress — повторный PurgeQueue на очереди ранее чем через 60 секунд
"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."},
} }
} }
+2 -102
View File
@@ -1,7 +1,6 @@
package models package models
import ( import (
"strings"
"sync" "sync"
) )
@@ -18,106 +17,7 @@ var CurrentEnvironment = Environment{
var LogMessages bool var LogMessages bool
var LogFile string var LogFile string
// QueueStore — хранилище очередей: блокировка + map ключ→очередь + индекс tenant→очереди. var SyncQueues = struct {
//
// Ключ очереди в Queues — "{accessKey}:{queueName}" (tenant-scoped изоляция).
// Индекс tenantQueues (accessKey → набор имён очередей) позволяет считать и
// перечислять очереди тенанта за O(число его очередей), без скана всех очередей сервиса.
//
// ВАЖНО: методы AddQueue/RemoveQueue/CountTenantQueues/TenantQueueNames НЕ берут
// блокировку — вызывать под уже захваченным SyncQueues.Lock()/RLock()
// (соответствует существующей дисциплине блокировок в коде).
type QueueStore struct {
sync.RWMutex sync.RWMutex
Queues map[string]*Queue Queues map[string]*Queue
tenantQueues map[string]map[string]struct{} }{Queues: make(map[string]*Queue)}
}
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{})
}
-1
View File
@@ -16,7 +16,6 @@ func ResetApp() {
func ResetResources() { func ResetResources() {
SyncQueues.Lock() SyncQueues.Lock()
SyncQueues.Queues = make(map[string]*Queue) SyncQueues.Queues = make(map[string]*Queue)
SyncQueues.ResetQueueIndex()
SyncQueues.Unlock() SyncQueues.Unlock()
} }
-163
View File
@@ -1,163 +0,0 @@
// 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())
}
}
+12 -370
View File
@@ -2,7 +2,6 @@ package models
import ( import (
"strconv" "strconv"
"sync"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -47,11 +46,6 @@ func (m *SqsMessage) IsReadyForReceipt() bool {
} }
type Queue struct { type Queue struct {
// mu — per-queue блокировка содержимого очереди (Messages, Duplicates, FIFO-
// состояние, LastPurgeTime). Заменяет удержание глобального SyncQueues.Lock
// на время операций с одной очередью. sync.RWMutex сериализации не подлежит.
mu sync.RWMutex `json:"-"`
Name string Name string
URL string URL string
Arn string Arn string
@@ -59,28 +53,9 @@ type Queue struct {
ReceiveMessageWaitTimeSeconds int ReceiveMessageWaitTimeSeconds int
DelaySeconds int DelaySeconds int
MaximumMessageSize int MaximumMessageSize int
MessageRetentionPeriod int // seconds (реализовано в processQueueTick) MessageRetentionPeriod int // seconds // TODO - not used in the code yet
Messages []SqsMessage 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 DeadLetterQueue *Queue
// DeadLetterQueueName — имя DLQ. В Redis-метаданных хранится ТОЛЬКО имя
// (а не вложенный объект): после загрузки указатель DeadLetterQueue
// релинкуется на объект из общей map (см. persistence.LoadAllQueues).
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
MaxReceiveCount int MaxReceiveCount int
IsFIFO bool IsFIFO bool
FIFOMessages map[string]int FIFOMessages map[string]int
@@ -88,84 +63,40 @@ type Queue struct {
EnableDuplicates bool EnableDuplicates bool
Duplicates map[string]time.Time Duplicates map[string]time.Time
Tags map[string]string Tags map[string]string
// LastPurgeTime — время последнего успешного PurgeQueue.
// Используется для запрета повторного 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-группы.
//
// Логическая схема: каждый вызов инкрементирует счётчик группы и возвращает
// его строковое представление (порядковые номера в FIFO должны расти монотонно).
//
// ВАЖНО (фикс): раньше при ПЕРВОМ обращении к группе map пересоздавалась целиком
// (q.FIFOSequenceNumbers = map[string]int{groupId: 0}) — счётчики ВСЕХ остальных
// групп терялись, и порядковые номера начинали дублироваться. Теперь map только
// инициализируется один раз (если nil), а счётчик конкретной группы инкрементируется.
func (q *Queue) NextSequenceNumber(groupId string) string { func (q *Queue) NextSequenceNumber(groupId string) string {
if q.FIFOSequenceNumbers == nil { if _, ok := q.FIFOSequenceNumbers[groupId]; !ok {
q.FIFOSequenceNumbers = make(map[string]int) q.FIFOSequenceNumbers = map[string]int{
groupId: 0,
} }
}
q.FIFOSequenceNumbers[groupId]++ q.FIFOSequenceNumbers[groupId]++
return strconv.Itoa(q.FIFOSequenceNumbers[groupId]) return strconv.Itoa(q.FIFOSequenceNumbers[groupId])
} }
// IsLocked — проверяет, заблокирована ли FIFO-группа.
// Группа блокируется при выдаче её сообщения в ReceiveMessage и остаётся
// заблокированной, пока сообщение in-flight (до DeleteMessage или таймаута
// видимости) — это гарантирует порядок обработки внутри группы.
func (q *Queue) IsLocked(groupId string) bool { func (q *Queue) IsLocked(groupId string) bool {
_, ok := q.FIFOMessages[groupId] _, ok := q.FIFOMessages[groupId]
return ok return ok
} }
// LockGroup — блокирует FIFO-группу (помечает как занятую).
//
// ВАЖНО (фикс): раньше при ПЕРВОМ обращении map пересоздавалась целиком
// (q.FIFOMessages = map[string]int{groupId: 0}) — блокировки ВСЕХ остальных
// групп терялись, и их сообщения могли выдаваться одновременно. Теперь map
// только инициализируется один раз, а для группы проставляется ключ.
func (q *Queue) LockGroup(groupId string) { func (q *Queue) LockGroup(groupId string) {
if q.FIFOMessages == nil { if _, ok := q.FIFOMessages[groupId]; !ok {
q.FIFOMessages = make(map[string]int) q.FIFOMessages = map[string]int{
groupId: 0,
}
} }
q.FIFOMessages[groupId] = 0
} }
// UnlockGroup — снимает блокировку FIFO-группы.
// Вызывается после удаления сообщения или при истечении таймаута видимости
// (см. PeriodicTasks → processQueueTick).
func (q *Queue) UnlockGroup(groupId string) { func (q *Queue) UnlockGroup(groupId string) {
if _, ok := q.FIFOMessages[groupId]; ok { if _, ok := q.FIFOMessages[groupId]; ok {
delete(q.FIFOMessages, groupId) delete(q.FIFOMessages, groupId)
} }
} }
// IsDuplicate — проверяет, было ли сообщение с таким deduplicationId уже
// добавлено в очередь в пределах deduplication-окна (5 минут).
// Дедупликация — НЕОТЪЕМЛЕМОЕ свойство FIFO-очереди (AWS): раньше она зависела
// от env-флага EnableDuplicates=false по умолчанию, и FIFO-очереди не
// дедуплицировали вообще. Теперь — только по IsFIFO и непустому id.
func (q *Queue) IsDuplicate(deduplicationId string) bool { func (q *Queue) IsDuplicate(deduplicationId string) bool {
if !q.IsFIFO || deduplicationId == "" { if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
return false return false
} }
@@ -174,301 +105,12 @@ func (q *Queue) IsDuplicate(deduplicationId string) bool {
return ok return ok
} }
// InitDuplicatation — регистрирует deduplicationId с текущим временем.
// Эта отметка — начало deduplication-окна; просроченные записи удаляются
// фоновой задачей PeriodicTasks (deduplication period expired).
// Для не-FIFO очередей или пустого deduplicationId — no-op.
func (q *Queue) InitDuplicatation(deduplicationId string) { func (q *Queue) InitDuplicatation(deduplicationId string) {
if !q.IsFIFO || deduplicationId == "" { if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
return return
} }
// Ленивая инициализация: очередь, собранная литералом или загруженная из
// Redis до появления поля, может иметь nil-map — иначе запись паникует.
if q.Duplicates == nil {
q.Duplicates = make(map[string]time.Time)
}
if _, ok := q.Duplicates[deduplicationId]; !ok { if _, ok := q.Duplicates[deduplicationId]; !ok {
q.Duplicates[deduplicationId] = time.Now() 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
}
+73
View File
@@ -0,0 +1,73 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFilterPolicy_IsSatisfiedBy(t *testing.T) {
var tests = []struct {
filterPolicy *FilterPolicy
messageAttributes map[string]MessageAttribute
expected bool
}{
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "bar"}},
true,
},
{
&FilterPolicy{"foo": {"bar", "xyz"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "xyz"}},
true,
},
{
&FilterPolicy{"foo": {"bar", "xyz"}, "abc": {"def"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "xyz"},
"abc": {DataType: "String", StringValue: "def"}},
true,
},
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "baz"}},
false,
},
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{},
false,
},
{
&FilterPolicy{"foo": {"bar"}, "abc": {"def"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "bar"}},
false,
},
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{"foo": {DataType: "Binary", BinaryValue: "bar"}},
false,
},
}
for i, tt := range tests {
actual := tt.filterPolicy.IsSatisfiedBy(tt.messageAttributes)
if tt.filterPolicy.IsSatisfiedBy(tt.messageAttributes) != tt.expected {
t.Errorf("#%d FilterPolicy: expected %t, actual %t", i, tt.expected, actual)
}
}
}
func TestMessage_IsReadyForReceipt(t *testing.T) {
CurrentEnvironment.RandomLatency.Min = 100
CurrentEnvironment.RandomLatency.Max = 100
msg := SqsMessage{
SentTime: time.Now(),
}
assert.False(t, msg.IsReadyForReceipt())
duration, _ := time.ParseDuration("105ms")
time.Sleep(duration)
assert.True(t, msg.IsReadyForReceipt())
}
+1 -102
View File
@@ -113,20 +113,6 @@ func (r *CreateQueueRequest) SetAttributesFromForm(values url.Values) {
continue continue
} }
r.Attributes.RedriveAllowPolicy = tmp 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 return
@@ -395,20 +381,6 @@ func (r *SetQueueAttributesRequest) SetAttributesFromForm(values url.Values) {
continue continue
} }
r.Attributes.RedriveAllowPolicy = tmp 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 return
@@ -421,85 +393,12 @@ type QueueAttributes struct {
DelaySeconds StringToInt `json:"DelaySeconds"` DelaySeconds StringToInt `json:"DelaySeconds"`
MaximumMessageSize StringToInt `json:"MaximumMessageSize"` MaximumMessageSize StringToInt `json:"MaximumMessageSize"`
MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented
Policy map[string]interface{} `json:"Policy"` // хранится, не исполняется Policy map[string]interface{} `json:"Policy"` // NOTE: not implemented
ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"` ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"`
VisibilityTimeout StringToInt `json:"VisibilityTimeout"` VisibilityTimeout StringToInt `json:"VisibilityTimeout"`
// Dead Letter Queues Only // Dead Letter Queues Only
RedrivePolicy RedrivePolicy `json:"RedrivePolicy"` RedrivePolicy RedrivePolicy `json:"RedrivePolicy"`
RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented 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.
// Используется для валидации в ОБОИХ протоколах:
// - Query (form Attribute.N.Name) — в app/gosqs/queue_attributes.go;
// - JSON — в UnmarshalJSON ниже (стандартный парсер молча отбрасывает
// неизвестные поля, поэтому проверка выполняется вручную).
//
// Policy/RedriveAllowPolicy — допустимы для совместимости, но не реализованы (no-op).
var AttrNameWhitelist = map[string]bool{
"DelaySeconds": true,
"MaximumMessageSize": true,
"MessageRetentionPeriod": true,
"Policy": true,
"ReceiveMessageWaitTimeSeconds": true,
"VisibilityTimeout": true,
"RedrivePolicy": true,
"RedriveAllowPolicy": true,
// FIFO-атрибуты: FifoQueue у нас выводится из суффикса .fifo имени очереди,
// но клиенты присылают эти атрибуты в CreateQueue — они допустимы (no-op).
"FifoQueue": true,
"ContentBasedDeduplication": true,
}
// UnmarshalJSON — строгая разборка QueueAttributes для JSON-протокола.
//
// Логическая схема:
// 1. Разбираем тело в map[string]json.RawMessage, чтобы увидеть ВСЕ ключи.
// 2. Каждый ключ проверяем по AttrNameWhitelist: неизвестное имя → ошибка
// (раньше парсер молча отбрасывал чужие поля и SetQueueAttributes принимал
// любой атрибут — «неверное имя атрибута принято» из нагрузочного теста).
// 3. Разбираем штатно: все известные поля попадают в структуру, вложенный
// RedrivePolicy использует собственный UnmarshalJSON.
//
// Ошибка приводит к TransformRequest=false → InvalidParameterValue в handler.
func (q *QueueAttributes) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
for name := range raw {
if !AttrNameWhitelist[name] {
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(normalized, (*plain)(q))
} }
type RedrivePolicy struct { type RedrivePolicy struct {
+739
View File
@@ -0,0 +1,739 @@
package models
import (
"encoding/json"
"fmt"
"math/rand"
"net/url"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestNewCreateQueueRequest(t *testing.T) {
CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize = 262144
CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod = 345600
CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds = 10
CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30
defer func() {
ResetApp()
}()
expectedCreateQueueRequest := &CreateQueueRequest{
Attributes: QueueAttributes{
DelaySeconds: 0,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
result := NewCreateQueueRequest()
assert.Equal(t, expectedCreateQueueRequest, result)
}
func TestCreateQueueRequest_SetAttributesFromForm_success(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "new-queue")
form.Add("Version", "2012-11-05")
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "1")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "2")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "3")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\"}")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "4")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "5")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "{\"i-am\":\"the-redrive-allow-policy\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(2), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(3), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}{"i-am": "the-policy"}, cqr.Attributes.Policy)
assert.Equal(t, StringToInt(4), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(5), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}{"i-am": "the-redrive-allow-policy"}, cqr.Attributes.RedriveAllowPolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_int(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_string(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": \"100\", \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_default_unparsable_redrive_recieve_count(t *testing.T) {
defaultRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 10,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": null, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, defaultRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_skips_invalid_values(t *testing.T) {
form := url.Values{}
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "garbage")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "garbage")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "garbage")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "garbage")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "garbage")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "garbage")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "garbage")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "garbage")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(262144), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(345600), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.Policy)
assert.Equal(t, StringToInt(10), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(30), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, RedrivePolicy{}, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.RedriveAllowPolicy)
}
func TestRedrivePolicy_UnmarshalJSON_handles_nested_json(t *testing.T) {
request := struct {
MaxReceiveCount int `json:"maxReceiveCount"`
DeadLetterTargetArn string `json:"deadLetterTargetArn"`
}{
MaxReceiveCount: 100,
DeadLetterTargetArn: "arn:redrive-queue",
}
b, _ := json.Marshal(request)
var r = RedrivePolicy{}
err := r.UnmarshalJSON(b)
assert.Nil(t, err)
assert.Equal(t, StringToInt(100), r.MaxReceiveCount)
assert.Equal(t, fmt.Sprintf("%s:%s", "arn", "redrive-queue"), r.DeadLetterTargetArn)
}
func TestRedrivePolicy_UnmarshalJSON_handles_escaped_string(t *testing.T) {
request := `{"maxReceiveCount":"100","deadLetterTargetArn":"arn:redrive-queue"}`
b, _ := json.Marshal(request)
var r = RedrivePolicy{}
err := r.UnmarshalJSON(b)
assert.Nil(t, err)
assert.Equal(t, StringToInt(100), r.MaxReceiveCount)
assert.Equal(t, fmt.Sprintf("%s:%s", "arn", "redrive-queue"), r.DeadLetterTargetArn)
}
func TestRedrivePolicy_UnmarshalJSON_invalid_json_request_returns_error(t *testing.T) {
request := fmt.Sprintf(`{\"maxReceiveCount\":\"100\",\"deadLetterTargetArn\":\"arn:redrive-queue\"}`)
var r = RedrivePolicy{}
err := r.UnmarshalJSON([]byte(request))
assert.Error(t, err)
assert.Equal(t, StringToInt(0), r.MaxReceiveCount)
assert.Equal(t, "", r.DeadLetterTargetArn)
}
func TestRedrivePolicy_UnmarshalJSON_invalid_type_returns_error(t *testing.T) {
request := `{"maxReceiveCount":true,"deadLetterTargetArn":"arn:redrive-queue"}`
b, _ := json.Marshal(request)
var r = RedrivePolicy{}
err := r.UnmarshalJSON(b)
assert.Error(t, err)
assert.Equal(t, StringToInt(0), r.MaxReceiveCount)
assert.Equal(t, "", r.DeadLetterTargetArn)
}
func TestNewListQueuesRequest_SetAttributesFromForm(t *testing.T) {
form := url.Values{}
form.Add("MaxResults", "1")
form.Add("NextToken", "next-token")
form.Add("QueueNamePrefix", "queue-name-prefix")
lqr := &ListQueueRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, 1, lqr.MaxResults)
assert.Equal(t, "next-token", lqr.NextToken)
assert.Equal(t, "queue-name-prefix", lqr.QueueNamePrefix)
}
func TestListQueuesRequest_SetAttributesFromForm_invalid_max_results(t *testing.T) {
form := url.Values{}
form.Add("MaxResults", "1.0")
form.Add("NextToken", "next-token")
form.Add("QueueNamePrefix", "queue-name-prefix")
lqr := &ListQueueRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, 0, lqr.MaxResults)
assert.Equal(t, "next-token", lqr.NextToken)
assert.Equal(t, "queue-name-prefix", lqr.QueueNamePrefix)
}
func TestGetQueueAttributesRequest_SetAttributesFromForm(t *testing.T) {
form := url.Values{}
form.Add("QueueUrl", "queue-url")
form.Add("AttributeName.1", "attribute-1")
form.Add("AttributeName.2", "attribute-2")
lqr := &GetQueueAttributesRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, "queue-url", lqr.QueueUrl)
assert.Equal(t, 2, len(lqr.AttributeNames))
assert.Contains(t, lqr.AttributeNames, "attribute-1")
assert.Contains(t, lqr.AttributeNames, "attribute-2")
}
func TestGetQueueAttributesRequest_SetAttributesFromForm_skips_invalid_key_sequence(t *testing.T) {
form := url.Values{}
form.Add("QueueUrl", "queue-url")
form.Add("AttributeName.1", "attribute-1")
form.Add("AttributeName.3", "attribute-3")
lqr := &GetQueueAttributesRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, "queue-url", lqr.QueueUrl)
assert.Equal(t, 1, len(lqr.AttributeNames))
assert.Contains(t, lqr.AttributeNames, "attribute-1")
}
func TestSendMessageRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("MessageAttribute.1.Name", "Attr1")
form.Add("MessageAttribute.1.Value.DataType", "String")
form.Add("MessageAttribute.1.Value.StringValue", "Value1")
form.Add("MessageAttribute.2.Name", "Attr2")
form.Add("MessageAttribute.2.Value.DataType", "Binary")
form.Add("MessageAttribute.2.Value.BinaryValue", "VmFsdWUy")
form.Add("MessageAttribute.3.Name", "")
form.Add("MessageAttribute.3.Value.DataType", "String")
form.Add("MessageAttribute.3.Value.StringValue", "Value")
form.Add("MessageAttribute.4.Name", "Attr4")
form.Add("MessageAttribute.4.Value.DataType", "")
form.Add("MessageAttribute.4.Value.StringValue", "Value4")
r := &SendMessageRequest{
MessageAttributes: make(map[string]MessageAttribute),
MessageSystemAttributes: make(map[string]MessageAttribute),
}
r.SetAttributesFromForm(form)
assert.Equal(t, 2, len(r.MessageAttributes))
assert.NotNil(t, r.MessageAttributes["Attr1"])
attr1 := r.MessageAttributes["Attr1"]
assert.Equal(t, "String", attr1.DataType)
assert.Equal(t, "Value1", attr1.StringValue)
assert.Empty(t, attr1.BinaryValue)
assert.NotNil(t, r.MessageAttributes["Attr2"])
attr2 := r.MessageAttributes["Attr2"]
assert.Equal(t, "Binary", attr2.DataType)
assert.Empty(t, attr2.StringValue)
assert.Equal(t, "VmFsdWUy", attr2.BinaryValue)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "new-queue")
form.Add("Version", "2012-11-05")
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "1")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "2")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "3")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\"}")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "4")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "5")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "{\"i-am\":\"the-redrive-allow-policy\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(2), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(3), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}{"i-am": "the-policy"}, cqr.Attributes.Policy)
assert.Equal(t, StringToInt(4), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(5), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}{"i-am": "the-redrive-allow-policy"}, cqr.Attributes.RedriveAllowPolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_int(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_string(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": \"100\", \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_default_unparsable_redrive_recieve_count(t *testing.T) {
defaultRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 10,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": null, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, defaultRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_skips_invalid_values(t *testing.T) {
form := url.Values{}
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "garbage")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "garbage")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "garbage")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "garbage")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "garbage")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "garbage")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "garbage")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "garbage")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(262144), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(345600), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.Policy)
assert.Equal(t, StringToInt(10), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(30), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, RedrivePolicy{}, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.RedriveAllowPolicy)
}
func TestNewCreateTopicRequest(t *testing.T) {
defer func() {
ResetApp()
}()
result := NewCreateTopicRequest()
assert.Equal(t, false, result.Attributes.FifoTopic)
assert.Equal(t, StringToInt(1), result.Attributes.SignatureVersion)
assert.Equal(t, "Active", result.Attributes.TracingConfig)
assert.Equal(t, false, result.Attributes.ContentBasedDeduplication)
}
func TestCreateTopicRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "new-queue")
form.Add("Version", "2012-11-05")
form.Add("Attribute.1.Name", "DeliveryPolicy")
form.Add("Attribute.1.Value", "{\"i-am\":\"the-policy\", \"name\":\"delivery-policy\"}")
form.Add("Attribute.2.Name", "DisplayName")
form.Add("Attribute.2.Value", "Foo")
form.Add("Attribute.3.Name", "FifoTopic")
form.Add("Attribute.3.Value", "true")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\", \"name\":\"policy\"}")
form.Add("Attribute.5.Name", "SignatureVersion")
form.Add("Attribute.5.Value", "99")
form.Add("Attribute.6.Name", "TracingConfig")
form.Add("Attribute.6.Value", "PassThrough")
form.Add("Attribute.7.Name", "KmsMasterKeyId")
form.Add("Attribute.7.Value", "1234abcd-12ab-34cd-56ef-1234567890ab")
form.Add("Attribute.8.Name", "ArchivePolicy")
form.Add("Attribute.8.Value", "{\"i-am\":\"the-policy\", \"name\":\"archive-policy\"}")
form.Add("Attribute.9.Name", "BeginningArchiveTime")
form.Add("Attribute.9.Value", "2024-07-01T23:59:59+09:00")
form.Add("Attribute.10.Name", "ContentBasedDeduplication")
form.Add("Attribute.10.Value", "true")
ctr := &CreateTopicRequest{}
ctr.SetAttributesFromForm(form)
assert.Equal(t, 2, len(ctr.Attributes.DeliveryPolicy))
assert.Equal(t, "the-policy", ctr.Attributes.DeliveryPolicy["i-am"])
assert.Equal(t, "delivery-policy", ctr.Attributes.DeliveryPolicy["name"])
assert.Equal(t, "Foo", ctr.Attributes.DisplayName)
assert.Equal(t, true, ctr.Attributes.FifoTopic)
assert.Equal(t, 2, len(ctr.Attributes.Policy))
assert.Equal(t, "the-policy", ctr.Attributes.Policy["i-am"])
assert.Equal(t, "policy", ctr.Attributes.Policy["name"])
assert.Equal(t, StringToInt(99), ctr.Attributes.SignatureVersion)
assert.Equal(t, "PassThrough", ctr.Attributes.TracingConfig)
assert.Equal(t, "1234abcd-12ab-34cd-56ef-1234567890ab", ctr.Attributes.KmsMasterKeyId)
assert.Equal(t, 2, len(ctr.Attributes.ArchivePolicy))
assert.Equal(t, "the-policy", ctr.Attributes.ArchivePolicy["i-am"])
assert.Equal(t, "archive-policy", ctr.Attributes.ArchivePolicy["name"])
assert.Equal(t, "2024-07-01T23:59:59+09:00", ctr.Attributes.BeginningArchiveTime)
assert.Equal(t, true, ctr.Attributes.ContentBasedDeduplication)
}
func TestSubscribeRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("Attributes.entry.1.key", "RawMessageDelivery")
form.Add("Attributes.entry.1.value", "true")
form.Add("Attributes.entry.2.key", "FilterPolicy")
form.Add("Attributes.entry.2.value", "{\"filter\": [\"policy\"]}")
cqr := &SubscribeRequest{
Attributes: SubscriptionAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.True(t, cqr.Attributes.RawMessageDelivery)
assert.Equal(t, FilterPolicy{"filter": []string{"policy"}}, cqr.Attributes.FilterPolicy)
}
func TestSubscribeRequest_SetAttributesFromForm_skips_invalid_values(t *testing.T) {
form := url.Values{}
form.Add("Attributes.entry.1.key", "RawMessageDelivery")
form.Add("Attributes.entry.1.value", "garbage")
form.Add("Attributes.entry.2.key", "FilterPolicy")
form.Add("Attributes.entry.2.value", "also-garbage")
cqr := &SubscribeRequest{
Attributes: SubscriptionAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.False(t, cqr.Attributes.RawMessageDelivery)
assert.Equal(t, FilterPolicy(nil), cqr.Attributes.FilterPolicy)
}
func TestSubscribeRequest_SetAttributesFromForm_stops_if_attributes_not_numbered_sequentially(t *testing.T) {
form := url.Values{}
form.Add("Attributes.entry.2.key", "RawMessageDelivery")
form.Add("Attributes.entry.2.value", "garbage")
form.Add("Attributes.entry.3.key", "FilterPolicy")
form.Add("Attributes.entry.3.value", "also-garbage")
cqr := &SubscribeRequest{
Attributes: SubscriptionAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.False(t, cqr.Attributes.RawMessageDelivery)
assert.Equal(t, FilterPolicy(nil), cqr.Attributes.FilterPolicy)
}
func Test_DeleteMessageBatchRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1")
form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1")
form.Add("DeleteMessageBatchRequestEntry.2.Id", "message-id-2")
form.Add("DeleteMessageBatchRequestEntry.2.ReceiptHandle", "receipt-handle-2")
form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3")
form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3")
dmbr := &DeleteMessageBatchRequest{}
dmbr.SetAttributesFromForm(form)
assert.Len(t, dmbr.Entries, 3)
assert.Equal(t, "message-id-1", dmbr.Entries[0].Id)
assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle)
assert.Equal(t, "message-id-2", dmbr.Entries[1].Id)
assert.Equal(t, "receipt-handle-2", dmbr.Entries[1].ReceiptHandle)
assert.Equal(t, "message-id-3", dmbr.Entries[2].Id)
assert.Equal(t, "receipt-handle-3", dmbr.Entries[2].ReceiptHandle)
}
func Test_DeleteMessageBatchRequest_SetAttributesFromForm_stops_at_non_sequential_keys(t *testing.T) {
form := url.Values{}
form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1")
form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1")
form.Add("DeleteMessageBatchRequestEntry.4.Id", "message-id-2")
form.Add("DeleteMessageBatchRequestEntry.4.ReceiptHandle", "receipt-handle-2")
form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3")
form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3")
dmbr := &DeleteMessageBatchRequest{}
dmbr.SetAttributesFromForm(form)
assert.Len(t, dmbr.Entries, 1)
assert.Equal(t, "message-id-1", dmbr.Entries[0].Id)
assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle)
}
func Test_DeleteMessageBatchRequest_SetAttributesFromForm_stops_at_invalid_keys(t *testing.T) {
form := url.Values{}
form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1")
form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1")
form.Add("INVALID_DeleteMessageBatchRequestEntry.2.Id", "message-id-2")
form.Add("DeleteMessageBatchRequestEntry.2.ReceiptHandle", "receipt-handle-2")
form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3")
form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3")
dmbr := &DeleteMessageBatchRequest{}
dmbr.SetAttributesFromForm(form)
assert.Len(t, dmbr.Entries, 1)
assert.Equal(t, "message-id-1", dmbr.Entries[0].Id)
assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle)
}
func TestPublishRequest_SetAttributesFromForm_success_concurrent(t *testing.T) {
form := url.Values{}
form.Add("MessageAttributes.entry.1.Name", "test1")
form.Add("MessageAttributes.entry.1.Value.DataType", "String")
form.Add("MessageAttributes.entry.1.Value.StringValue", "sample-string")
form.Add("MessageAttributes.entry.2.Name", "test2")
form.Add("MessageAttributes.entry.2.Value.DataType", "Binary")
form.Add("MessageAttributes.entry.2.Value.BinaryValue", "YmluYXJ5LXZhbHVl")
// if the code is not thread-safe, repeated runs increase the chance of detecting a race.
for r := 0; r < 10; r++ {
var wg sync.WaitGroup
goroutineCount := 40
// launch goroutines in parallel to simulate concurrent access.
for g := 0; g < goroutineCount; g++ {
wg.Add(1)
go func() {
defer wg.Done()
// introduce a random delay to encourage goroutine interleaving
time.Sleep(time.Duration(rand.Intn(5)) * time.Millisecond)
cqr := &PublishRequest{
MessageAttributes: make(map[string]MessageAttribute),
}
cqr.SetAttributesFromForm(form)
// validate the expected DataType values
assert.Equal(t, "String", cqr.MessageAttributes["test1"].DataType)
assert.Equal(t, "Binary", cqr.MessageAttributes["test2"].DataType)
}()
}
wg.Wait()
}
}
func TestParseMessageAttributes(t *testing.T) {
for _, tc := range []struct {
description string
values url.Values
keyPrefix string
want map[string]MessageAttribute
}{
{
description: "empty",
values: url.Values{},
keyPrefix: "foo",
want: nil,
},
{
description: "simple",
values: url.Values{
"MessageAttribute.1.Name": []string{"Attr1"},
"MessageAttribute.1.Value.DataType": []string{"String"},
"MessageAttribute.1.Value.StringValue": []string{"Value1"},
"MessageAttribute.2.Name": []string{"Attr2"},
"MessageAttribute.2.Value.DataType": []string{"Binary"},
"MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"},
},
keyPrefix: "MessageAttribute",
want: map[string]MessageAttribute{
"Attr1": {
DataType: "String",
StringValue: "Value1",
BinaryValue: "",
},
"Attr2": {
DataType: "Binary",
BinaryValue: "VmFsdWUy",
},
},
},
{
description: "attributes after empty name ignored",
values: url.Values{
"MessageAttribute.1.Name": []string{""},
"MessageAttribute.1.Value.DataType": []string{"String"},
"MessageAttribute.1.Value.StringValue": []string{"Value4"},
"MessageAttribute.2.Name": []string{"Attr2"},
"MessageAttribute.2.Value.DataType": []string{"Binary"},
"MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"},
},
keyPrefix: "MessageAttribute",
want: nil,
},
{
description: "attributes after missing number ignored",
values: url.Values{
// Note starting from 2
"MessageAttribute.2.Name": []string{"Attr2"},
"MessageAttribute.2.Value.DataType": []string{"Binary"},
"MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"},
},
keyPrefix: "MessageAttribute",
want: nil,
},
{
description: "empty DataType ignored",
values: url.Values{
"MessageAttribute.1.Name": []string{"Attr4"},
"MessageAttribute.1.Value.DataType": []string{""},
"MessageAttribute.1.Value.StringValue": []string{"Value4"},
},
keyPrefix: "MessageAttribute",
want: nil,
},
} {
t.Run(tc.description, func(t *testing.T) {
got := parseMessageAttributes(tc.values, tc.keyPrefix)
assert.Equal(t, tc.want, got)
})
}
}
+87 -429
View File
@@ -18,9 +18,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -31,33 +29,17 @@ import (
// Client — глобальный Redis клиент. // Client — глобальный Redis клиент.
// nil означает режим "только память" — все функции тихо no-op. // nil означает режим "только память" — все функции тихо no-op.
// atomic.Pointer: reconnect() пишет новый клиент из health-check горутины, var Client *redis.Client
// а остальные горутины читают — без атомарности это data race.
var Client atomic.Pointer[redis.Client]
// Параметры подключения — сохраняются для переподключения после обрыва.
var (
redisAddr string
redisUsername string
redisPassword string
)
var ( var (
seqMu sync.Mutex seqMu sync.Mutex
queueWriteSeq = map[string]uint64{} queueWriteSeq = map[string]uint64{}
tenantWriteSeq = map[string]uint64{} tenantWriteSeq = map[string]uint64{}
// msgWriteSeq — порядковая нумерация async-записей ОТДЕЛЬНЫХ сообщений
// (ключ "{queueKey}|{uuid}"). Без неё два SaveMessage одного сообщения из
// разных горутин могли завершиться вразнобой: старое состояние перезаписало
// бы новое. Записи удаляются при удалении сообщения (DeleteMessagePersist и др.).
msgWriteSeq = map[string]uint64{}
) )
const ( const (
// redisHashTenants — HASH: tenantID → JSON тенанта // redisHashTenants — HASH: tenantID → JSON тенанта
redisHashTenants = "ssq:tenants" redisHashTenants = "ssq:tenants"
// redisHashTenantAK — HASH: accessKey → JSON тенанта (индекс read-through в auth)
redisHashTenantAK = "ssq:tenant_ak"
// redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2) // redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2)
redisHashQueues = "ssq:queues" redisHashQueues = "ssq:queues"
// redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage) // redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage)
@@ -71,10 +53,6 @@ func Connect(addr, username, password string) error {
log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме") log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме")
return nil return nil
} }
redisAddr = addr
redisUsername = username
redisPassword = password
rdb := redis.NewClient(&redis.Options{ rdb := redis.NewClient(&redis.Options{
Addr: addr, Addr: addr,
Username: username, Username: username,
@@ -86,57 +64,8 @@ func Connect(addr, username, password string) error {
if err := rdb.Ping(ctx).Err(); err != nil { if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("redis ping %s: %w", addr, err) return fmt.Errorf("redis ping %s: %w", addr, err)
} }
Client.Store(rdb) Client = rdb
log.Infof("persistence: подключились к Redis %s", addr) 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 return nil
} }
@@ -153,76 +82,23 @@ func asyncWrite(fn func()) {
}() }()
} }
// queueMetaDTO — сериализуемая копия очереди БЕЗ Messages (и без сообщений DLQ). // marshalQueueMeta — сериализует очередь БЕЗ Messages (и без Messages в DLQ).
// Отдельный тип, т.к. models.Queue содержит sync.RWMutex и его нельзя копировать/ // DeadLetterQueue сохраняется как ссылка (Name/URL/Arn), но без сообщений.
// сериализовать целиком, а временное зануление Messages мутировало бы живую очередь. // ВАЖНО: вызывать под SyncQueues.Lock() — временно nil'ит 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) { func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
return json.Marshal(queueToMetaDTO(queue)) 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
} }
// SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно. // SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно.
@@ -230,7 +106,7 @@ func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
// создаёт консистентный снапшот. Горутина только делает сетевой вызов. // создаёт консистентный снапшот. Горутина только делает сетевой вызов.
// Для сохранения сообщений используй SaveMessage/SaveMessages. // Для сохранения сообщений используй SaveMessage/SaveMessages.
func SaveQueue(key string, queue *models.Queue) { func SaveQueue(key string, queue *models.Queue) {
if Client.Load() == nil { if Client == nil {
return return
} }
// Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот // Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот
@@ -246,7 +122,7 @@ func SaveQueue(key string, queue *models.Queue) {
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.Load().HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil { if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet queue meta %q: %v", key, err) log.Errorf("persistence: HSet queue meta %q: %v", key, err)
} }
}) })
@@ -256,7 +132,7 @@ func SaveQueue(key string, queue *models.Queue) {
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage). // Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
// Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения. // Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения.
func SaveMessage(queueKey string, msg *models.SqsMessage) { func SaveMessage(queueKey string, msg *models.SqsMessage) {
if Client.Load() == nil { if Client == nil {
return return
} }
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
@@ -265,47 +141,19 @@ func SaveMessage(queueKey string, msg *models.SqsMessage) {
return return
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
msgKey := queueKey + "|" + msg.Uuid
writeSeq := nextMsgWriteSeq(msgKey)
asyncWrite(func() { asyncWrite(func() {
if !isLatestMsgWriteSeq(msgKey, writeSeq) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.Load().HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil { if err := Client.HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet message %s/%s: %v", queueKey, msg.Uuid, err) 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. // SaveMessages — сохраняет несколько сообщений в Redis одним pipeline.
// Вызывать под Lock. // Вызывать под Lock.
func SaveMessages(queueKey string, msgs []models.SqsMessage) { func SaveMessages(queueKey string, msgs []models.SqsMessage) {
if Client.Load() == nil || len(msgs) == 0 { if Client == nil || len(msgs) == 0 {
return return
} }
// Сериализуем все сообщения синхронно под Lock // Сериализуем все сообщения синхронно под Lock
@@ -330,116 +178,85 @@ func SaveMessages(queueKey string, msgs []models.SqsMessage) {
for k, v := range fields { for k, v := range fields {
args = append(args, k, v) args = append(args, k, v)
} }
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil { if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err) log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err)
} }
}) })
} }
// SaveMessagesSync — сохраняет несколько сообщений в Redis СИНХРОННО (pipeline). // DeleteMessagePersist — удаляет одно сообщение из Redis асинхронно.
// Используется на пути SendMessageBatch: ответ 200 выдаётся только после записи // Вызывать при DeleteMessage (после удаления из in-memory).
// всех сообщений (как в одиночном SendMessage). Возвращает ошибку для отката. func DeleteMessagePersist(queueKey string, msgUuid string) {
func SaveMessagesSync(queueKey string, msgs []models.SqsMessage) error { if Client == nil || msgUuid == "" {
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
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
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 СИНХРОННО
// (иначе удалённые сообщения воскреснут после рестарта пода).
func DeleteMessagesPersist(queueKey string, uuids []string) error {
if Client.Load() == nil || len(uuids) == 0 {
return nil
}
hashKey := redisMsgHashPrefix + queueKey
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
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 СИНХРОННО.
// Синхронность обязательна: при рестарте пода удалённые сообщения не должны
// воскреснуть (факт: асинхронное удаление очередей приводило к их возврату).
func PurgeMessagesPersist(queueKey string) {
if Client.Load() == nil {
return return
} }
hashKey := redisMsgHashPrefix + queueKey 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)
}
})
}
// DeleteMessagesPersist — удаляет несколько сообщений из Redis.
// Вызывать при DeleteMessageBatch.
func DeleteMessagesPersist(queueKey string, uuids []string) {
if Client == nil || len(uuids) == 0 {
return
}
hashKey := redisMsgHashPrefix + queueKey
// Копируем uuids — вызывающий может переиспользовать slice
ids := make([]string, len(uuids))
copy(ids, uuids)
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := Client.Load().Del(ctx, hashKey).Err(); err != nil { if err := Client.HDel(ctx, hashKey, ids...).Err(); err != nil {
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(ids), err)
}
})
}
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis (для PurgeQueue).
// Удаляет весь HASH ssq:msg:{queueKey}.
func PurgeMessagesPersist(queueKey string) {
if Client == 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 {
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err) log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
} }
clearAllMsgWriteSeq(queueKey) })
} }
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно. // DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
func DeleteQueue(key string) { func DeleteQueue(key string) {
if Client.Load() == nil { if Client == nil {
return return
} }
writeSeq := nextQueueWriteSeq(key) writeSeq := nextQueueWriteSeq(key)
msgHashKey := redisMsgHashPrefix + key msgHashKey := redisMsgHashPrefix + key
// СИНХРОННО: удаление очереди обязано попасть в Redis до ответа клиенту. asyncWrite(func() {
// Асинхронное удаление приводило к «воскрешению» удалённых очередей после
// рестарта пода (факт 2026-08-15: 50 мусорных очередей вернулись из Redis).
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !isLatestQueueWriteSeq(key, writeSeq) { if !isLatestQueueWriteSeq(key, writeSeq) {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Удаляем метаданные из общего HASH // Удаляем метаданные из общего HASH
if err := Client.Load().HDel(ctx, redisHashQueues, key).Err(); err != nil { if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
log.Errorf("persistence: HDel queue meta %q: %v", key, err) log.Errorf("persistence: HDel queue meta %q: %v", key, err)
} }
// Удаляем весь HASH с сообщениями // Удаляем весь HASH с сообщениями
if err := Client.Load().Del(ctx, msgHashKey).Err(); err != nil { if err := Client.Del(ctx, msgHashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %q: %v", key, err) log.Errorf("persistence: DEL messages hash %q: %v", key, err)
} }
})
} }
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса. // LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
@@ -447,14 +264,14 @@ func DeleteQueue(key string) {
// Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages, // Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages,
// они извлекаются в отдельный HASH и метаданные пересохраняются без Messages. // они извлекаются в отдельный HASH и метаданные пересохраняются без Messages.
func LoadAllQueues() (map[string]*models.Queue, error) { func LoadAllQueues() (map[string]*models.Queue, error) {
if Client.Load() == nil { if Client == nil {
return nil, nil return nil, nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
// 1. Загружаем метаданные очередей // 1. Загружаем метаданные очередей
raw, err := Client.Load().HGetAll(ctx, redisHashQueues).Result() raw, err := Client.HGetAll(ctx, redisHashQueues).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("redis HGetAll queues: %w", err) return nil, fmt.Errorf("redis HGetAll queues: %w", err)
} }
@@ -479,16 +296,6 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
q.FIFOSequenceNumbers = make(map[string]int) 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 — это старый формат // Миграция v1→v2: если в JSON есть Messages — это старый формат
if len(q.Messages) > 0 { if len(q.Messages) > 0 {
migrateKeys = append(migrateKeys, k) migrateKeys = append(migrateKeys, k)
@@ -514,7 +321,7 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
args = append(args, q.Messages[i].Uuid, string(data)) args = append(args, q.Messages[i].Uuid, string(data))
} }
if len(args) > 0 { if len(args) > 0 {
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil { if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: migrate HSet messages %q: %v", k, err) log.Errorf("persistence: migrate HSet messages %q: %v", k, err)
} }
} }
@@ -522,7 +329,7 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// Пересохраняем метаданные без Messages // Пересохраняем метаданные без Messages
metaData, err := marshalQueueMeta(q) metaData, err := marshalQueueMeta(q)
if err == nil { if err == nil {
if err := Client.Load().HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil { if err := Client.HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil {
log.Errorf("persistence: migrate HSet meta %q: %v", k, err) log.Errorf("persistence: migrate HSet meta %q: %v", k, err)
} }
} }
@@ -532,11 +339,10 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди // 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди
for k, q := range queues { for k, q := range queues {
hashKey := redisMsgHashPrefix + k hashKey := redisMsgHashPrefix + k
msgRaw, err := Client.Load().HGetAll(ctx, hashKey).Result() msgRaw, err := Client.HGetAll(ctx, hashKey).Result()
if err != nil { if err != nil {
// Fail-fast: скрытая пустая очередь при недоступных сообщениях log.Errorf("persistence: HGetAll messages %q: %v", k, err)
// хуже явной ошибки — сообщения остались бы невидимыми в Redis. continue
return nil, fmt.Errorf("redis HGetAll messages %q: %w", k, err)
} }
if len(msgRaw) > 0 { if len(msgRaw) > 0 {
msgs := make([]models.SqsMessage, 0, len(msgRaw)) msgs := make([]models.SqsMessage, 0, len(msgRaw))
@@ -553,27 +359,6 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// Пустая очередь — Messages уже nil, оставляем // Пустая очередь — Messages уже nil, оставляем
q.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)) log.Infof("persistence: загружено %d очередей из Redis (миграций: %d)", len(queues), len(migrateKeys))
@@ -584,17 +369,12 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// Принимает []byte чтобы избежать циклического импорта с пакетом tenant. // Принимает []byte чтобы избежать циклического импорта с пакетом tenant.
// Сериализацию делает вызывающий (tenant_store.go). // Сериализацию делает вызывающий (tenant_store.go).
func SaveTenantRaw(id string, jsonData []byte) { func SaveTenantRaw(id string, jsonData []byte) {
if Client.Load() == nil { if Client == nil {
return return
} }
// Копируем bytes — вызывающий может переиспользовать буфер // Копируем bytes — вызывающий может переиспользовать буфер
dataCopy := make([]byte, len(jsonData)) dataCopy := make([]byte, len(jsonData))
copy(dataCopy, jsonData) copy(dataCopy, jsonData)
// Извлекаем accessKey для индекса read-through (без импорта пакета tenant)
var meta struct {
AccessKey string
}
_ = json.Unmarshal(dataCopy, &meta)
writeSeq := nextTenantWriteSeq(id) writeSeq := nextTenantWriteSeq(id)
asyncWrite(func() { asyncWrite(func() {
if !isLatestTenantWriteSeq(id, writeSeq) { if !isLatestTenantWriteSeq(id, writeSeq) {
@@ -602,37 +382,15 @@ func SaveTenantRaw(id string, jsonData []byte) {
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.Load().HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil { if err := Client.HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil {
log.Errorf("persistence: HSet tenant %q: %v", id, err) 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 асинхронно. // DeleteTenant — удаляет тенанта из Redis асинхронно.
// Удаляет из ОБОИХ индексов: ssq:tenants (по id) и ssq:tenant_ak (по accessKey), func DeleteTenant(id string) {
// иначе LoadTenantByAccessKey вернёт удалённого тенанта и отзыв ключей не сработает. if Client == nil {
func DeleteTenant(id, accessKey string) {
if Client.Load() == nil {
return return
} }
writeSeq := nextTenantWriteSeq(id) writeSeq := nextTenantWriteSeq(id)
@@ -642,14 +400,9 @@ func DeleteTenant(id, accessKey string) {
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.Load().HDel(ctx, redisHashTenants, id).Err(); err != nil { if err := Client.HDel(ctx, redisHashTenants, id).Err(); err != nil {
log.Errorf("persistence: HDel tenant %q: %v", id, err) 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)
}
}
}) })
} }
@@ -666,41 +419,6 @@ func isLatestQueueWriteSeq(key string, seq uint64) bool {
return queueWriteSeq[key] == seq 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 { func nextTenantWriteSeq(id string) uint64 {
seqMu.Lock() seqMu.Lock()
defer seqMu.Unlock() defer seqMu.Unlock()
@@ -717,12 +435,12 @@ func isLatestTenantWriteSeq(id string, seq uint64) bool {
// LoadAllTenantsRaw — загружает всех тенантов из Redis при старте. // LoadAllTenantsRaw — загружает всех тенантов из Redis при старте.
// Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go. // Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go.
func LoadAllTenantsRaw() (map[string][]byte, error) { func LoadAllTenantsRaw() (map[string][]byte, error) {
if Client.Load() == nil { if Client == nil {
return nil, nil return nil, nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
raw, err := Client.Load().HGetAll(ctx, redisHashTenants).Result() raw, err := Client.HGetAll(ctx, redisHashTenants).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("redis HGetAll tenants: %w", err) return nil, fmt.Errorf("redis HGetAll tenants: %w", err)
} }
@@ -733,63 +451,3 @@ func LoadAllTenantsRaw() (map[string][]byte, error) {
log.Infof("persistence: загружено %d тенантов из Redis", len(result)) log.Infof("persistence: загружено %d тенантов из Redis", len(result))
return result, nil 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)
}
}
+28 -240
View File
@@ -4,37 +4,26 @@
package router package router
import ( import (
"crypto/subtle"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
"time"
"shared-sqs/app/admin" "shared-sqs/app/admin"
"shared-sqs/app/auth" "shared-sqs/app/auth"
"shared-sqs/app/billing"
sqs "shared-sqs/app/gosqs" sqs "shared-sqs/app/gosqs"
"shared-sqs/app/interfaces" "shared-sqs/app/interfaces"
"shared-sqs/app/metrics"
"shared-sqs/app/models"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
"shared-sqs/app/ui" "shared-sqs/app/ui"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// NewDataPlane — публичный SQS API (data-plane): SQS actions, /health, статика, описание. // New — создаёт HTTP router с tenant auth и admin API
// НЕ содержит админ-консоль, /metrics и /ui — они в control-plane (NewControlPlane). func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
//
// Разбиение data-plane/control-plane (Шаг 2): публично доступен только этот роутер.
// Пока — два листенера в одном процессе (общее in-memory состояние); отдельные процессы
// станут возможны после выноса состояния в Redis (Шаг 3).
func NewDataPlane(tenantStore *tenant.TenantStore) http.Handler {
r := mux.NewRouter() r := mux.NewRouter()
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB // Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB
@@ -44,68 +33,6 @@ func NewDataPlane(tenantStore *tenant.TenantStore) http.Handler {
// /health — публичный, без auth // /health — публичный, без auth
r.HandleFunc("/health", health).Methods("GET") r.HandleFunc("/health", health).Methods("GET")
// /ready — готовность инстанса (для failover/балансировщика)
r.HandleFunc("/ready", readyHandler).Methods("GET")
// /static — логотип, favicon для публичных страниц
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
// Страница примеров команд — публично
r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) {
ui.ExamplesHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
// r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
sqsAuth := auth.AuthMiddleware(tenantStore)
// Корень: GET без Action — публичная страница-описание сервиса;
// всё остальное (POST, или GET с Action/X-Amz-Target) — SQS API за tenant auth.
rootHandler := func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet && extractAction(req) == "" {
ui.InfoHandler(models.Version).ServeHTTP(w, req)
return
}
sqsAuth(http.HandlerFunc(actionHandler)).ServeHTTP(w, req)
}
r.HandleFunc("/", rootHandler).Methods("GET", "POST")
r.Handle("/{account}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/queue/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/{account}/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
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 // Admin API — Bearer token auth, регистрируется через AdminHandler
adminHandler := admin.NewHandler(tenantStore, adminToken) adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r) adminHandler.RegisterRoutes(r)
@@ -114,10 +41,19 @@ func NewControlPlane(tenantStore *tenant.TenantStore, adminToken string) http.Ha
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/* // ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
adminHandler.RegisterPublicRoutes(r) adminHandler.RegisterPublicRoutes(r)
// UI console — встроенный SPA // UI console — встроенный SPA, публичный доступ
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше // НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler())) 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 — ответы теряются. Поэтому используем явную обёртку.
sqsAuth := auth.AuthMiddleware(tenantStore)
r.Handle("/", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/{account}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/queue/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/{account}/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
return r return r
} }
@@ -132,35 +68,10 @@ func requestBodyLimitMiddleware(next http.Handler) http.Handler {
}) })
} }
// encodeResponse — сериализует ответ в формате протокола клиента.
//
// Логическая схема:
// - AwsJsonProtocol (чистый JSON-протокол): успешный ответ — JSON тела;
// ОШИБКА — спец-формат AWS-JSON {"__type": "com.amazonaws.sqs#<Code>",
// "message": "..."}. Раньше для ErrorResponse писался голый ErrorResult
// ({"Type":...,"Code":...,"Message":...}) — SDK не распознавал это как ошибку
// и возвращал Code=None.
// - AwsQueryProtocol: ответ всегда XML (в т.ч. <ErrorResponse> для ошибок).
func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) { func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
protocol := resolveProtocol(req) protocol := resolveProtocol(req)
switch protocol { switch protocol {
case AwsJsonProtocol: case AwsJsonProtocol:
// Ошибка в JSON-протоколе: __type + message — единственный формат,
// который botocore распознаёт как исключение с кодом.
if errResp, ok := body.(models.ErrorResponse); ok {
w.Header().Set("x-amzn-RequestId", errResp.RequestId)
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
w.WriteHeader(statusCode)
err := json.NewEncoder(w).Encode(map[string]string{
"__type": "com.amazonaws.sqs#" + errResp.Result.Code,
"message": errResp.Result.Message,
})
if err != nil {
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
}
return
}
// Успешный ответ: JSON тела (Result).
w.Header().Set("x-amzn-RequestId", body.GetRequestId()) w.Header().Set("x-amzn-RequestId", body.GetRequestId())
w.Header().Set("Content-Type", "application/x-amz-json-1.0") w.Header().Set("Content-Type", "application/x-amz-json-1.0")
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
@@ -206,79 +117,12 @@ var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractR
} }
func health(w http.ResponseWriter, req *http.Request) { func health(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200) w.WriteHeader(200)
fmt.Fprintf(w, `{"status":"ok","version":%q}`, models.Version) fmt.Fprint(w, "OK")
}
// 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) { func actionHandler(w http.ResponseWriter, req *http.Request) {
action := extractAction(req) action := extractAction(req)
start := time.Now()
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"action": action, "action": action,
"url": req.URL, "url": req.URL,
@@ -287,52 +131,11 @@ func actionHandler(w http.ResponseWriter, req *http.Request) {
if ok { if ok {
statusCode, responseBody := jsonFn(req) statusCode, responseBody := jsonFn(req)
encodeResponse(w, req, statusCode, responseBody) encodeResponse(w, req, statusCode, responseBody)
// Metrics: latency гистограмма для каждой операции
duration := time.Since(start).Seconds()
metrics.RequestDuration.WithLabelValues(action).Observe(duration)
if statusCode < 400 {
if t, _ := req.Context().Value(auth.TenantContextKey).(*tenant.Tenant); t != nil {
msgBytes := req.ContentLength
if msgBytes < 0 {
msgBytes = 0
}
// Извлекаем имя очереди: из URL path vars или из form-параметра QueueName (CreateQueue)
queueName := mux.Vars(req)["queueName"]
if queueName == "" {
queueName = req.FormValue("QueueName")
}
// 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))
}
} else {
// Prometheus: счётчик ошибок
metrics.ErrorsTotal.WithLabelValues(action).Inc()
}
return return
} }
// Неизвестный Action — отвечаем ошибкой в ФОРМАТЕ ПРОТОКОЛА клиента
// (XML ErrorResponse для Query, AWS-JSON для JSON-протокола).
// Раньше возвращался голый текст "Bad Request" (200/400 без AWS-XML) —
// SDK получал ClientError с Code=None и не мог понять причину.
log.Warnf("Bad Request - Action: %s", action) log.Warnf("Bad Request - Action: %s", action)
errType := models.SqsErrors["GeneralError"] w.WriteHeader(http.StatusBadRequest)
encodeResponse(w, req, errType.StatusCode(), models.ErrorResponse{ io.WriteString(w, "Bad Request")
Result: errType.Response(),
RequestId: "00000000-0000-0000-0000-000000000000",
})
} }
type AwsProtocol int type AwsProtocol int
@@ -342,39 +145,24 @@ const (
AwsQueryProtocol AwsProtocol = iota AwsQueryProtocol AwsProtocol = iota
) )
// extractAction — извлекает имя операции SQS из запроса. // extractAction — извлекает Action из запроса (Query Protocol или JSON Protocol)
//
// Логическая схема (не зависит от resolveProtocol):
// 1. Заголовок X-Amz-Target ("AmazonSQS.SendMessage") — используют клиенты
// JSON-протокола И AWS CLI v2 в query-mode. Извлекаем часть после точки.
// 2. Иначе — form-параметр Action (классический query-протокол).
//
// ВАЖНО: определение по X-Amz-Target в первую очередь обязательно — AWS CLI v2
// шлёт Content-Type: application/x-amz-json-1.0 + x-amzn-query-mode: true, но
// Action у него в заголовке, а НЕ в form-параметрах.
func extractAction(req *http.Request) string { func extractAction(req *http.Request) string {
if action := req.Header.Get("X-Amz-Target"); action != "" { protocol := resolveProtocol(req)
switch protocol {
case AwsJsonProtocol:
action := req.Header.Get("X-Amz-Target")
parts := strings.SplitN(action, ".", 2) parts := strings.SplitN(action, ".", 2)
if len(parts) == 2 && parts[1] != "" { if len(parts) != 2 || parts[1] == "" {
return ""
}
return parts[1] return parts[1]
} case AwsQueryProtocol:
}
return req.FormValue("Action") return req.FormValue("Action")
}
return ""
} }
// resolveProtocol — определяет протокол ОТВЕТА по заголовкам запроса. // resolveProtocol — определяет протокол по Content-Type
//
// Логическая схема:
// 1. Content-Type: application/x-amz-json-1.0 → JSON-протокол (ответ JSON).
// Сюда входят: чистые JSON-клиенты (SDK) И AWS CLI v2/boto3 в режиме
// «query-compatible JSON» (x-amzn-query-mode: true). ВАЖНО (эмпирика):
// query-mode клиент парсит именно JSON-ответы — при XML-ответе список
// очередей распознаётся как пустой (None), при JSON — корректно.
// 2. Всё остальное (form-urlencoded от классических клиентов) — query/XML.
//
// Ошибки в JSON-протоколе оформляются как {"__type":"...","message":"..."}
// (см. encodeResponse) — только такой формат botocore распознаёт как ошибку
// с кодом (раньше отдавался голый ErrorResult → Code=None).
func resolveProtocol(req *http.Request) AwsProtocol { func resolveProtocol(req *http.Request) AwsProtocol {
if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" { if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" {
return AwsJsonProtocol return AwsJsonProtocol
+251
View File
@@ -0,0 +1,251 @@
package router
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
af "shared-sqs/app/fixtures"
"shared-sqs/app/mocks"
"shared-sqs/app/interfaces"
sqs "shared-sqs/app/gosqs"
"github.com/stretchr/testify/assert"
"shared-sqs/app/test"
)
func TestIndexServerhandler_POST_BadRequest(t *testing.T) {
// Create a request to pass to our handler. We don't have any query parameters for now, so we'll
// pass 'nil' as the third parameter.
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "BadRequest")
req.PostForm = form
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
rr := httptest.NewRecorder()
// Our handlers satisfy http.Handler, so we can call their ServeHTTP method
// directly and pass in our Request and ResponseRecorder.
New().ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestIndexServerhandler_POST_GoodRequest(t *testing.T) {
// Create a request to pass to our handler. We don't have any query parameters for now, so we'll
// pass 'nil' as the third parameter.
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "ListTopics")
req.PostForm = form
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
rr := httptest.NewRecorder()
// Our handlers satisfy http.Handler, so we can call their ServeHTTP method
// directly and pass in our Request and ResponseRecorder.
New().ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestIndexServerhandler_POST_GoodRequest_With_URL(t *testing.T) {
req, err := http.NewRequest("POST", "/100010001000/local-queue1", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "local-queue1")
req.PostForm = form
rr := httptest.NewRecorder()
New().ServeHTTP(rr, req)
form = url.Values{}
form.Add("Action", "GetQueueAttributes")
form.Add("QueueUrl", fmt.Sprintf("%s/local-queue1", af.BASE_URL))
req.PostForm = form
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
rr = httptest.NewRecorder()
// Our handlers satisfy http.Handler, so we can call their ServeHTTP method
// directly and pass in our Request and ResponseRecorder.
New().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestIndexServerhandler_POST_GoodRequest_With_URL_And_Aws_Json_Protocol(t *testing.T) {
json, _ := json.Marshal(map[string]string{
"QueueName": "local-queue1",
})
req, err := http.NewRequest("POST", "/100010001000/local-queue1", bytes.NewBuffer(json))
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Amz-Target", "AmazonSQS.CreateQueue")
req.Header.Set("Content-Type", "application/x-amz-json-1.0")
rr := httptest.NewRecorder()
New().ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestIndexServerhandler_GET_GoodRequest_Pem_cert(t *testing.T) {
req, err := http.NewRequest("GET", "/SimpleNotificationService/100010001000.pem", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
New().ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestEncodeResponse_success_xml(t *testing.T) {
w, r := test.GenerateRequestInfo("POST", "/url", nil, false)
encodeResponse(w, r, http.StatusOK, mocks.BaseResponse{Message: "test"})
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
xml.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "test"}, tmp)
}
func TestEncodeResponse_success_skips_nil_body_xml(t *testing.T) {
w, r := test.GenerateRequestInfo("POST", "/url", nil, false)
encodeResponse(w, r, http.StatusOK, nil)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, &bytes.Buffer{}, w.Body)
}
func TestEncodeResponse_success_json(t *testing.T) {
w, r := test.GenerateRequestInfo("POST", "/url", nil, true)
encodeResponse(w, r, http.StatusOK, mocks.BaseResponse{Message: "test"})
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
json.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "test"}, tmp)
}
func TestEncodeResponse_success_skips_malformed_body_json(t *testing.T) {
mock := mocks.BaseResponse{
Message: "test",
}
mock.MockGetResult = func() interface{} {
return make(chan int)
}
w, r := test.GenerateRequestInfo("POST", "/url", nil, true)
encodeResponse(w, r, http.StatusOK, mock)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "General Error", strings.TrimSpace(string(w.Body.Bytes())))
}
func TestActionHandler_v1_json(t *testing.T) {
defer func() {
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": sqs.CreateQueueV1,
}
}()
mockCalled := false
mockFunction := func(req *http.Request) (int, interfaces.AbstractResponseBody) {
mockCalled = true
return http.StatusOK, mocks.BaseResponse{Message: "response-body"}
}
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": mockFunction,
}
w, r := test.GenerateRequestInfo("POST", "/url", nil, true)
r.Header.Set("X-Amz-Target", "QueueService.CreateQueue")
actionHandler(w, r)
assert.True(t, mockCalled)
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
json.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "response-body"}, tmp)
}
func TestActionHandler_v1_xml(t *testing.T) {
defer func() {
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": sqs.CreateQueueV1,
}
}()
mockCalled := false
mockFunction := func(req *http.Request) (int, interfaces.AbstractResponseBody) {
mockCalled = true
return http.StatusOK, mocks.BaseResponse{Message: "response-body"}
}
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": mockFunction,
}
w, r := test.GenerateRequestInfo("POST", "/url", nil, false)
form := url.Values{}
form.Add("Action", "CreateQueue")
r.PostForm = form
actionHandler(w, r)
assert.True(t, mockCalled)
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
xml.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "response-body"}, tmp)
}
+37 -84
View File
@@ -7,7 +7,6 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
@@ -17,15 +16,10 @@ import (
// MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12) // MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12)
const MaxTenantsGlobal = 1000 const MaxTenantsGlobal = 1000
// DefaultTenantMaxQueues — лимит очередей для JWT-тенантов (auto-provisioning).
// Используется в app/admin при логине; при каждом логине лимит существующего
// тенанта обновляется до этого значения (см. CreateFromJWT).
const DefaultTenantMaxQueues = 50
// Tenant — модель тенанта shared-sqs. // Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header. // AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct { type Tenant struct {
ID string // уникальный идентификатор тенанта (t-<hex>: случайный, генерируется через crypto/rand) ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
Name string // человекочитаемое имя Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>) AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars) SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
@@ -37,12 +31,12 @@ type Tenant struct {
} }
// TenantStore — потокобезопасное in-memory хранилище тенантов. // TenantStore — потокобезопасное in-memory хранилище тенантов.
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по Email (JWT auth). // Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
type TenantStore struct { type TenantStore struct {
mu sync.RWMutex mu sync.RWMutex
byID map[string]*Tenant byID map[string]*Tenant
byAccessKey map[string]*Tenant byAccessKey map[string]*Tenant
byEmail map[string]*Tenant // индекс по Email (JWT claim). Тенанты без email сюда НЕ попадают. bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
} }
// NewTenantStore — создаёт пустое хранилище тенантов. // NewTenantStore — создаёт пустое хранилище тенантов.
@@ -50,7 +44,7 @@ func NewTenantStore() *TenantStore {
return &TenantStore{ return &TenantStore{
byID: make(map[string]*Tenant), byID: make(map[string]*Tenant),
byAccessKey: make(map[string]*Tenant), byAccessKey: make(map[string]*Tenant),
byEmail: make(map[string]*Tenant), bySub: make(map[string]*Tenant),
} }
} }
@@ -87,16 +81,10 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
} }
s.mu.Lock() 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.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t s.byAccessKey[t.AccessKey] = t
if t.Email != "" { if t.NubesSub != "" {
s.byEmail[t.Email] = t s.bySub[t.NubesSub] = t
} }
s.mu.Unlock() s.mu.Unlock()
@@ -110,23 +98,7 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo). // CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo).
// Используется только при инициализации демо-данных; не вызывается из user-facing API. // Используется только при инициализации демо-данных; не вызывается из 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) { 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() s.mu.Lock()
if _, exists := s.byID[tenantID]; exists { if _, exists := s.byID[tenantID]; exists {
s.mu.Unlock() s.mu.Unlock()
@@ -184,13 +156,13 @@ func (s *TenantStore) Delete(id string) bool {
} }
delete(s.byID, t.ID) delete(s.byID, t.ID)
delete(s.byAccessKey, t.AccessKey) delete(s.byAccessKey, t.AccessKey)
if t.Email != "" { if t.NubesSub != "" {
delete(s.byEmail, t.Email) delete(s.bySub, t.NubesSub)
} }
s.mu.Unlock() s.mu.Unlock()
// Удаляем из Redis асинхронно (оба индекса: по id и по accessKey) // Удаляем из Redis асинхронно
persistence.DeleteTenant(id, t.AccessKey) persistence.DeleteTenant(id)
return true return true
} }
@@ -201,80 +173,60 @@ func (s *TenantStore) LoadTenant(t *Tenant) {
s.mu.Lock() s.mu.Lock()
s.byID[t.ID] = t s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t s.byAccessKey[t.AccessKey] = t
if t.Email != "" { if t.NubesSub != "" {
s.byEmail[t.Email] = t s.bySub[t.NubesSub] = t
} }
s.mu.Unlock() s.mu.Unlock()
} }
// GetByEmail — поиск тенанта по Email (JWT claim). // GetBySub — поиск тенанта по NubesSub (JWT sub claim).
// Используется при JWT-авторизации через UI. // Используется при JWT-авторизации через UI.
func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) { func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
s.mu.RLock() s.mu.RLock()
t, ok := s.byEmail[email] t, ok := s.bySub[sub]
s.mu.RUnlock() s.mu.RUnlock()
return t, ok return t, ok
} }
// CreateFromJWT — auto-provisioning тенанта из JWT claims. // CreateFromJWT — auto-provisioning тенанта из JWT claims.
// Идентичность — email: тенант ищется/создаётся по email (индекс byEmail). // ID = TenantIDFromSub(sub) — совместим с sless namespace.
// Креды (ID/AccessKey/SecretKey) генерируются СЛУЧАЙНО через crypto/rand и // Если тенант с таким sub уже существует — возвращает его (идемпотентно).
// НЕ выводятся из email — иначе креды можно было бы вычислить по публичному email func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) {
// (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")
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() // Идемпотентность: если тенант с таким sub уже есть — возвращаем
if existing, ok := s.bySub[sub]; ok {
// Идемпотентность: тенант с таким email уже есть — возвращаем как есть, // Обновляем email если изменился
// креды НЕ пересчитываем и НЕ меняем (они случайны и неизменны). if email != "" && existing.Email != email {
if existing, ok := s.byEmail[email]; ok { existing.Email = email
// Персистим только если что-то реально изменилось: иначе лишние записи
// в Redis на каждый логин, а обновлённый NubesSub раньше вообще терялся
// (сохранялся только внутри ветки смены MaxQueues).
changed := false
if sub != "" && existing.NubesSub != sub {
existing.NubesSub = sub
changed = true
}
// Обновляем лимит очередей на актуальное значение из кода при каждом
// логине — иначе после рестарта пода из 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)
}
} }
s.mu.Unlock()
return existing, nil return existing, nil
} }
if len(s.byID) >= MaxTenantsGlobal { if len(s.byID) >= MaxTenantsGlobal {
s.mu.Unlock()
return nil, fmt.Errorf("global tenant limit reached (%d)", 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() accessKey, err := generateAccessKey()
if err != nil { if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate access key: %w", err) return nil, fmt.Errorf("generate access key: %w", err)
} }
secretKey, err := generateSecretKey() secretKey, err := generateSecretKey()
if err != nil { if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate secret key: %w", err) return nil, fmt.Errorf("generate secret key: %w", err)
} }
// Имя тенанта — email или sub (если email пустой)
name := email
if name == "" {
name = sub
}
t := &Tenant{ t := &Tenant{
ID: id, ID: tenantID,
Name: email, Name: name,
AccessKey: accessKey, AccessKey: accessKey,
SecretKey: secretKey, SecretKey: secretKey,
MaxQueues: maxQueues, MaxQueues: maxQueues,
@@ -286,7 +238,8 @@ func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant,
s.byID[t.ID] = t s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t s.byAccessKey[t.AccessKey] = t
s.byEmail[t.Email] = t s.bySub[t.NubesSub] = t
s.mu.Unlock()
// Сохраняем в Redis // Сохраняем в Redis
if data, err := json.Marshal(t); err == nil { if data, err := json.Marshal(t); err == nil {
-409
View File
@@ -1,409 +0,0 @@
<!DOCTYPE html>
<!-- app/ui/admin.html — админский UI shared-sqs (тенанты, лимиты). Только по admin-токену. -->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SQS Admin — Nubes</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.5;
min-height: 100vh;
}
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
.container { max-width: 1100px; margin: 0 auto; padding: 24px 16px 48px; }
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header { background: var(--brand-grey-light); padding: 12px; font-weight: 600; }
.card-body { padding: 16px; }
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar h2 { font-size: 20px; }
.tbl-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--brand-grey-light);
text-align: left;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.3px;
padding: 8px 10px;
border-right: 1px solid var(--brand-gray);
color: var(--text-muted);
font-weight: 600;
}
td { padding: 8px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:last-child td { border-bottom: none; }
td:last-child, th:last-child { border-right: none; }
tr:hover td { background: rgba(243,244,246,0.5); }
code, .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge-active { background: rgba(22,163,74,0.12); color: var(--success); }
.badge-inactive { background: rgba(220,38,38,0.1); color: var(--danger); }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--brand-gray);
background: #fff;
color: var(--text-primary);
}
.btn:hover { background: var(--brand-grey-light); }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); }
.btn-danger { background: #fff; border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: rgba(220,38,38,0.08); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.form-group input {
width: 100%;
padding: 9px 12px;
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
}
.form-group input:focus { outline: none; border-color: var(--brand-primary); }
.error { color: var(--danger); font-size: 13px; min-height: 20px; margin: 8px 0; }
.login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 32px;
width: 400px;
max-width: 92vw;
}
.login-box img { height: 28px; display: block; margin: 0 auto 12px; }
.login-box h1 { font-size: 20px; text-align: center; margin-bottom: 24px; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
padding: 28px;
width: 480px;
max-width: 92vw;
max-height: 88vh;
overflow-y: auto;
}
.modal h2 { font-size: 20px; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
.hidden { display: none !important; }
.hint {
font-size: 13px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 14px;
line-height: 1.6;
}
.hint .warn { color: var(--warning); font-weight: 600; }
.empty { text-align: center; color: var(--text-muted); padding: 28px; }
</style>
</head>
<body>
<div id="login-page" class="login-page">
<div class="login-box">
<img src="/static/logo.svg" alt="Nubes">
<h1>SQS Admin</h1>
<div class="form-group">
<label>Admin-токен</label>
<input id="admin-token" type="password" placeholder="Bearer admin token">
</div>
<div id="login-error" class="error"></div>
<button class="btn btn-primary" style="width:100%;justify-content:center" onclick="doLogin()">Войти</button>
</div>
</div>
<div id="app" class="hidden">
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">SQS Admin</span>
<span class="muted">· тенанты</span>
<span class="spacer"></span>
<span class="chip">{{VERSION}}</span>
<button class="btn btn-sm" onclick="doLogout()">Выйти</button>
</div>
<div class="container">
<div class="card">
<div class="toolbar" style="padding:14px 16px 0">
<h2>Тенанты</h2>
<div style="display:flex;gap:10px;align-items:center">
<button class="btn btn-sm" onclick="loadTenants()">⟳ Обновить</button>
<button class="btn btn-primary btn-sm" onclick="openCreateModal()">+ Создать</button>
</div>
</div>
<div class="card-body">
<div id="list-error" class="error"></div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>ID</th>
<th>Access Key</th>
<th>Макс. очередей</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tenants-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeCreateModal()">
<div class="modal">
<h2>Создать тенанта</h2>
<div class="form-group">
<label>Имя</label>
<input id="ct-name" placeholder="service-name">
</div>
<div class="form-group">
<label>Макс. очередей</label>
<input id="ct-queues" type="number" value="10" min="1" max="1000">
</div>
<div id="ct-error" class="error"></div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="createTenant()">Создать</button>
<button class="btn" onclick="closeCreateModal()">Отмена</button>
</div>
</div>
</div>
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
<div class="modal">
<h2>Тенант создан</h2>
<div class="hint">
<p class="warn">⚠ Передайте эти ключи владельцу тенанта.</p>
<p>Это креды для AWS CLI/SDK (подпись Signature V4).</p>
</div>
<div class="form-group">
<label>Access Key</label>
<input id="creds-ak" readonly onclick="copyField(this)">
</div>
<div class="form-group">
<label>Secret Key</label>
<input id="creds-sk" readonly onclick="copyField(this)">
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeCredsModal()">Готово</button>
</div>
</div>
</div>
<script>
let adminToken = localStorage.getItem('sqs_admin_token') || '';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
}
function api(path, opts = {}) {
return fetch(path, {
...opts,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + adminToken,
...(opts.headers || {})
}
}).then(r => {
if (r.status === 401) {
doLogout();
throw new Error('Неверный или истёкший admin-токен');
}
if (!r.ok) {
return r.json().catch(() => ({}))
.then(d => { throw new Error(d.error || r.status + ' ' + r.statusText); });
}
if (r.status === 204) return null;
return r.json();
});
}
function doLogin() {
const t = document.getElementById('admin-token').value.trim();
if (!t) return;
adminToken = t;
api('/admin/tenants')
.then(() => {
localStorage.setItem('sqs_admin_token', t);
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
loadTenants();
})
.catch(err => {
adminToken = '';
document.getElementById('login-error').textContent = 'Ошибка: ' + err.message;
});
}
function doLogout() {
adminToken = '';
localStorage.removeItem('sqs_admin_token');
document.getElementById('app').classList.add('hidden');
document.getElementById('login-page').classList.remove('hidden');
}
function loadTenants() {
document.getElementById('list-error').textContent = '';
api('/admin/tenants')
.then(list => renderTenants(list || []))
.catch(err => { document.getElementById('list-error').textContent = 'Ошибка: ' + err.message; });
}
function renderTenants(list) {
const tbody = document.getElementById('tenants-tbody');
tbody.innerHTML = (list || []).map(t => `
<tr>
<td><strong>${esc(t.name)}</strong>${t.id === (window._lastCreated || '') ? '' : ''}</td>
<td class="mono">${esc(t.id)}</td>
<td class="mono">${esc(t.access_key)}</td>
<td>${t.max_queues}</td>
<td>${t.active
? '<span class="badge badge-active">active</span>'
: '<span class="badge badge-inactive">inactive</span>'}</td>
<td>${fmtDate(t.created_at)}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteTenant('${esc(t.id)}','${esc(t.name)}')">Удалить</button></td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Тенантов нет</td></tr>';
}
function openCreateModal() {
document.getElementById('ct-name').value = '';
document.getElementById('ct-queues').value = '10';
document.getElementById('ct-error').textContent = '';
document.getElementById('modal-create').classList.remove('hidden');
}
function closeCreateModal() {
document.getElementById('modal-create').classList.add('hidden');
}
function createTenant() {
const name = document.getElementById('ct-name').value.trim();
const maxQ = parseInt(document.getElementById('ct-queues').value) || 10;
if (!name) {
document.getElementById('ct-error').textContent = 'Укажите имя';
return;
}
api('/admin/tenants', { method: 'POST', body: JSON.stringify({ name: name, max_queues: maxQ }) })
.then(data => {
closeCreateModal();
document.getElementById('creds-ak').value = data.access_key || '';
document.getElementById('creds-sk').value = data.secret_key || '';
document.getElementById('modal-creds').classList.remove('hidden');
loadTenants();
})
.catch(err => { document.getElementById('ct-error').textContent = 'Ошибка: ' + err.message; });
}
function closeCredsModal() {
document.getElementById('modal-creds').classList.add('hidden');
}
function deleteTenant(id, name) {
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди и сообщения будут удалены.')) return;
api('/admin/tenants/' + id, { method: 'DELETE' })
.then(() => loadTenants())
.catch(err => alert('Ошибка: ' + err.message));
}
function copyField(el) {
el.select();
navigator.clipboard && navigator.clipboard.writeText(el.value);
}
// init
if (adminToken) {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
loadTenants();
}
</script>
</body>
</html>
+1 -54
View File
@@ -5,66 +5,13 @@ package ui
import ( import (
"embed" "embed"
"io/fs"
"net/http" "net/http"
"strings"
) )
//go:embed index.html info.html admin.html examples.html static //go:embed index.html
var content embed.FS var content embed.FS
// Handler — возвращает http.Handler, раздающий встроенный index.html // Handler — возвращает http.Handler, раздающий встроенный index.html
func Handler() http.Handler { func Handler() http.Handler {
return http.FileServer(http.FS(content)) return http.FileServer(http.FS(content))
} }
// InfoHandler — публичная страница-описание сервиса (GET /) с подстановкой версии
func InfoHandler(version string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := content.ReadFile("info.html")
if err != nil {
http.Error(w, "info page unavailable", http.StatusInternalServerError)
return
}
html := strings.ReplaceAll(string(body), "{{VERSION}}", version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
})
}
// AdminHandler — админская консоль (GET /admin) с подстановкой версии
func AdminHandler(version string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := content.ReadFile("admin.html")
if err != nil {
http.Error(w, "admin page unavailable", http.StatusInternalServerError)
return
}
html := strings.ReplaceAll(string(body), "{{VERSION}}", version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
})
}
// ExamplesHandler — страница примеров команд (GET /examples) с подстановкой версии
func ExamplesHandler(version string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := content.ReadFile("examples.html")
if err != nil {
http.Error(w, "examples page unavailable", http.StatusInternalServerError)
return
}
html := strings.ReplaceAll(string(body), "{{VERSION}}", version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
})
}
// StaticHandler — раздаёт /static/* (логотип, favicon) для публичных страниц
func StaticHandler() http.Handler {
sub, err := fs.Sub(content, "static")
if err != nil {
panic(err)
}
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
}
-228
View File
@@ -1,228 +0,0 @@
<!DOCTYPE html>
<!-- app/ui/examples.html — примеры команд SQS API (отдельная страница, крупный шрифт) -->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Примеры команд — shared-sqs</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.6;
}
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
a { color: var(--brand-primary); text-decoration: none; }
a:hover { color: var(--brand-primary-dark); text-decoration: underline; }
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; }
.hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.hero h1 { font-size: 26px; margin: 0; }
.hero .sub { color: var(--text-muted); margin: 4px 0 0; }
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; }
.card-body { padding: 16px; }
.card-body p { margin: 8px 0; color: #374151; }
.step-num {
display: inline-flex;
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--brand-primary);
color: #fff;
font-weight: 700;
font-size: 14px;
align-items: center;
justify-content: center;
margin-right: 8px;
}
pre {
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.7;
margin: 10px 0 16px;
}
pre code { background: none; border: none; padding: 0; }
.note {
font-size: 13px;
color: var(--text-muted);
margin: -8px 0 14px;
}
.tbl-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th {
background: var(--brand-grey-light);
text-align: left;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.3px;
padding: 8px 12px;
border-right: 1px solid var(--brand-gray);
color: var(--text-muted);
font-weight: 600;
}
td { padding: 8px 12px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:last-child td { border-bottom: none; }
td:last-child, th:last-child { border-right: none; }
tr:hover td { background: rgba(243,244,246,0.5); }
td code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
.footer { color: var(--text-muted); font-size: 13px; text-align: center; margin-top: 32px; }
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">Nubes</span>
<span class="muted">· очередь</span>
<span class="spacer"></span>
<a href="/">← На главную</a>
<span class="chip">{{VERSION}}</span>
</div>
<div class="container">
<div class="hero">
<div>
<h1>Примеры команд</h1>
<p class="sub">Пошаговое подключение к SQS API через AWS CLI. Ключи доступа: консоль <a href="/ui">/ui</a> → кнопка Credentials.</p>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 1. Указание ключей доступа</div>
<div class="card-body">
<p><span class="step-num">A</span><strong>Переменные окружения</strong> (действуют в текущей сессии терминала):</p>
<pre><code>export AWS_ACCESS_KEY_ID=&lt;Access Key&gt;
export AWS_SECRET_ACCESS_KEY=&lt;Secret Key&gt;</code></pre>
<p><span class="step-num">B</span><strong>Файл ~/.aws/credentials</strong> (постоянно, для всех команд):</p>
<pre><code>[sqs]
aws_access_key_id = &lt;Access Key&gt;
aws_secret_access_key = &lt;Secret Key&gt;</code></pre>
<p class="note">Для способа B к каждой команде добавляется <code>--profile sqs</code>.</p>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 2. Создание очереди</div>
<div class="card-body">
<pre><code>aws sqs create-queue --queue-name demo \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs</code></pre>
<p>В ответе возвращается <code>QueueUrl</code> — он используется в следующих командах.</p>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 3. Отправка сообщения</div>
<div class="card-body">
<pre><code>aws sqs send-message \
--queue-url &lt;QueueUrl&gt; \
--message-body "hello nubes" \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 4. Получение и удаление сообщения</div>
<div class="card-body">
<pre><code>aws sqs receive-message \
--queue-url &lt;QueueUrl&gt; \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs
aws sqs delete-message \
--queue-url &lt;QueueUrl&gt; \
--receipt-handle &lt;ReceiptHandle из ответа receive-message&gt; \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">Все команды SQS API</div>
<div class="card-body">
<p>Все команды отправляются на <code>POST https://sqs.containerk8s.dev.nubes.ru/</code> с параметром <code>Action</code> (AWS CLI делает это сам).</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>Action</th><th>Описание</th></tr>
</thead>
<tbody>
<tr><td><code>CreateQueue</code></td><td>Создать очередь</td></tr>
<tr><td><code>ListQueues</code></td><td>Список очередей</td></tr>
<tr><td><code>GetQueueUrl</code></td><td>URL очереди по имени</td></tr>
<tr><td><code>GetQueueAttributes</code></td><td>Атрибуты очереди</td></tr>
<tr><td><code>SetQueueAttributes</code></td><td>Изменить атрибуты очереди</td></tr>
<tr><td><code>SendMessage</code></td><td>Отправить сообщение</td></tr>
<tr><td><code>SendMessageBatch</code></td><td>Отправить до 10 сообщений разом</td></tr>
<tr><td><code>ReceiveMessage</code></td><td>Получить сообщения (long polling до 20с)</td></tr>
<tr><td><code>DeleteMessage</code></td><td>Удалить сообщение</td></tr>
<tr><td><code>DeleteMessageBatch</code></td><td>Удалить до 10 сообщений разом</td></tr>
<tr><td><code>ChangeMessageVisibility</code></td><td>Изменить таймаут видимости сообщения</td></tr>
<tr><td><code>ChangeMessageVisibilityBatch</code></td><td>Пакетно изменить таймаут видимости</td></tr>
<tr><td><code>PurgeQueue</code></td><td>Очистить очередь</td></tr>
<tr><td><code>DeleteQueue</code></td><td>Удалить очередь</td></tr>
<tr><td><code>TagQueue</code></td><td>Назначить теги очереди</td></tr>
<tr><td><code>UntagQueue</code></td><td>Снять теги с очереди</td></tr>
<tr><td><code>ListQueueTags</code></td><td>Список тегов очереди</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="footer">shared-sqs · версия {{VERSION}} · <a href="/">на главную</a> · <a href="/ui">консоль</a> · <a href="/health">health</a></div>
</div>
</body>
</html>
+68 -144
View File
@@ -12,27 +12,25 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SQS Console — Nubes</title> <title>SQS Console — Nubes</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style> <style>
/* Nubes design system — светлая тема (deck) */ /* Nubes palette — идентично iot.kube5s.ru/console */
:root { :root {
--bg-page: #fafafa; --bg-page: #001120;
--bg-surface: #ffffff; --bg-surface: #001929;
--bg-navbar: #ffffff; --bg-navbar: #001C34;
--border: #d1d5db; --border: #0b2d50;
--accent: #2563eb; --accent: #1a7fd4;
--accent-hover: #1d4ed8; --accent-hover: #2196f3;
--text-primary: #1a1a1a; --text-primary: #e2ecf6;
--text-secondary: #6b7280; --text-secondary: #6b8eaa;
--danger: #dc2626; --danger: #e74c3c;
--success: #16a34a; --success: #27ae60;
--warning: #d97706; --warning: #f39c12;
--brand-grey-light: #f3f4f6;
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
background: var(--bg-page); background: var(--bg-page);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -57,6 +55,7 @@ a:hover { color: var(--accent-hover); }
} }
.navbar-brand img { .navbar-brand img {
height: 28px; height: 28px;
filter: brightness(0) invert(1);
} }
.navbar-brand span { .navbar-brand span {
font-size: 16px; font-size: 16px;
@@ -89,8 +88,7 @@ a:hover { color: var(--accent-hover); }
.card { .card {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 20px; padding: 20px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -113,17 +111,15 @@ a:hover { color: var(--accent-hover); }
.stat-card { .stat-card {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 20px; padding: 20px;
text-align: center; text-align: center;
} }
.stat-value { .stat-value {
font-size: 30px; font-size: 36px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--accent);
} }
.stat-value.accent { color: var(--accent); }
.stat-label { .stat-label {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -153,7 +149,7 @@ tbody td {
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
tbody tr:hover { background: var(--brand-grey-light); } tbody tr:hover { background: rgba(26, 127, 212, 0.05); }
tbody tr { cursor: pointer; } tbody tr { cursor: pointer; }
/* Buttons */ /* Buttons */
@@ -172,8 +168,8 @@ tbody tr { cursor: pointer; }
.btn-primary { background: var(--accent); color: #fff; } .btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: #fff; } .btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #b91c1c; } .btn-danger:hover { background: #c0392b; }
.btn-sm { padding: 4px 12px; font-size: 13px; } .btn-sm { padding: 4px 12px; font-size: 12px; }
/* Forms */ /* Forms */
.form-group { margin-bottom: 16px; } .form-group { margin-bottom: 16px; }
@@ -186,9 +182,9 @@ tbody tr { cursor: pointer; }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
background: #ffffff; background: var(--bg-page);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 4px;
color: var(--text-primary); color: var(--text-primary);
font-size: 14px; font-size: 14px;
} }
@@ -212,10 +208,10 @@ tbody tr { cursor: pointer; }
width: 400px; width: 400px;
text-align: center; text-align: center;
} }
.login-box img { height: 40px; margin-bottom: 12px; } .login-box img { height: 40px; filter: brightness(0) invert(1); margin-bottom: 12px; }
.login-box h1 { .login-box h1 {
font-size: 22px; font-size: 18px;
letter-spacing: 1px; letter-spacing: 2px;
margin-bottom: 32px; margin-bottom: 32px;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -229,15 +225,15 @@ tbody tr { cursor: pointer; }
/* Badges */ /* Badges */
.badge { .badge {
display: inline-block; display: inline-block;
padding: 2px 10px; padding: 2px 8px;
border-radius: 999px; border-radius: 10px;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
.badge-active { background: rgba(22,163,74,0.12); color: var(--success); } .badge-active { background: rgba(39,174,96,0.15); color: var(--success); }
.badge-inactive { background: rgba(220,38,38,0.1); color: var(--danger); } .badge-inactive { background: rgba(231,76,60,0.15); color: var(--danger); }
.badge-count { .badge-count {
background: rgba(37,99,235,0.1); background: rgba(26,127,212,0.15);
color: var(--accent); color: var(--accent);
min-width: 24px; min-width: 24px;
text-align: center; text-align: center;
@@ -256,7 +252,7 @@ tbody tr { cursor: pointer; }
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0,0,0,0.45); background: rgba(0,0,0,0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -266,15 +262,12 @@ tbody tr { cursor: pointer; }
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
padding: 32px; padding: 32px;
width: 560px; width: 480px;
max-width: 92vw; max-width: 90vw;
max-height: 88vh;
overflow-y: auto;
} }
.modal h2 { .modal h2 {
font-size: 20px; font-size: 18px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.modal-actions { .modal-actions {
@@ -311,7 +304,7 @@ tbody tr { cursor: pointer; }
margin-bottom: 16px; margin-bottom: 16px;
} }
.toolbar h2 { .toolbar h2 {
font-size: 20px; font-size: 18px;
font-weight: 600; font-weight: 600;
} }
.auto-refresh { .auto-refresh {
@@ -322,36 +315,6 @@ tbody tr { cursor: pointer; }
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Credentials modal — подсказка */
.creds-hint {
font-size: 13px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 16px;
line-height: 1.6;
}
.creds-hint .warn {
color: var(--warning);
font-weight: 600;
}
.creds-hint p { margin: 6px 0; }
.creds-hint .creds-sub { font-weight: 600; margin-top: 10px; color: var(--text-primary); }
.creds-hint pre {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
margin: 6px 0 10px;
}
/* Expandable message rows */ /* Expandable message rows */
td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); } td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
.msg-expand-inner { padding: 12px 20px; background: var(--bg-page); } .msg-expand-inner { padding: 12px 20px; background: var(--bg-page); }
@@ -364,7 +327,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<!-- ===== LOGIN PAGE ===== --> <!-- ===== LOGIN PAGE ===== -->
<div id="login-page" class="login-page"> <div id="login-page" class="login-page">
<div class="login-box"> <div class="login-box">
<img src="/static/logo.svg" alt="Nubes"> <img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<h1>SQS CONSOLE</h1> <h1>SQS CONSOLE</h1>
<div class="form-group"> <div class="form-group">
<label for="login-token">API Token или Demo Token</label> <label for="login-token">API Token или Demo Token</label>
@@ -374,19 +337,8 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button> <button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px"> <p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br> Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
Для личного аккаунта используйте API token из панели Nubes. Для личного tenant-а используйте API token из панели Nubes.
</p> </p>
<div style="margin-top:24px;text-align:left;font-size:12px;color:var(--text-secondary);border-top:1px solid var(--border);padding-top:16px;line-height:1.7">
<div style="font-weight:700;color:var(--text-primary);margin-bottom:6px;letter-spacing:0.5px">Информация о сервисе</div>
<div>Endpoint: <span style="color:var(--accent);font-family:monospace">https://sqs.containerk8s.dev.nubes.ru</span></div>
<div>Health: <span style="font-family:monospace">GET /health</span> · Метрики: <span style="font-family:monospace">GET /metrics</span></div>
<div>SQS API: <span style="font-family:monospace">POST /</span> (AWS Signature V4, регион <span style="font-family:monospace">us-east-1</span>)</div>
<div>Админ-консоль: <a href="/admin">/admin</a> (вход по admin-токену)</div>
<div>Realm: <span style="font-family:monospace">iot-naeel</span> · Persistence: Managed Redis</div>
<div>Версия: <span id="svc-version" style="font-family:monospace">—</span></div>
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.35</span></div>
<div style="margin-top:8px;color:var(--warning)">Статус: тестирование</div>
</div>
</div> </div>
</div> </div>
@@ -394,7 +346,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<div id="app" class="hidden"> <div id="app" class="hidden">
<nav class="navbar"> <nav class="navbar">
<div class="navbar-brand"> <div class="navbar-brand">
<img src="/static/logo.svg" alt="Nubes"> <img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<span>SQS CONSOLE</span> <span>SQS CONSOLE</span>
</div> </div>
<div class="navbar-user"> <div class="navbar-user">
@@ -413,7 +365,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<!-- ===== CREATE TENANT MODAL ===== --> <!-- ===== CREATE TENANT MODAL ===== -->
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeModal()"> <div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeModal()">
<div class="modal"> <div class="modal">
<h2>Создать аккаунт</h2> <h2>Создать тенанта</h2>
<div class="form-group"> <div class="form-group">
<label for="ct-name">Имя</label> <label for="ct-name">Имя</label>
<input id="ct-name" placeholder="my-service"> <input id="ct-name" placeholder="my-service">
@@ -433,21 +385,10 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<!-- ===== CREDENTIALS MODAL ===== --> <!-- ===== CREDENTIALS MODAL ===== -->
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()"> <div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
<div class="modal"> <div class="modal">
<h2>Credentials</h2> <h2>Тенант создан</h2>
<div class="creds-hint"> <p style="color:var(--warning);font-size:13px;margin-bottom:16px">
<p class="warn">⚠ Сохраните ключи — это креды вашего аккаунта для AWS CLI/SDK.</p> ⚠ Сохраните credentials — Secret Key показывается только один раз.
<p><strong>Зачем:</strong> приложения подключаются к SQS API по протоколу AWS (подпись Signature V4). Токен входа в консоль для этого не подходит.</p> </p>
<p><strong>Что делать:</strong> укажите ключи одним из двух способов:</p>
<p class="creds-sub">Способ 1 — переменные окружения:</p>
<pre>export AWS_ACCESS_KEY_ID=&lt;Access Key&gt;
export AWS_SECRET_ACCESS_KEY=&lt;Secret Key&gt;</pre>
<p class="creds-sub">Способ 2 — файл ~/.aws/credentials:</p>
<pre>[sqs]
aws_access_key_id = &lt;Access Key&gt;
aws_secret_access_key = &lt;Secret Key&gt;</pre>
<p>Дальше выполняйте команды (для способа 2 добавьте <code>--profile sqs</code>):</p>
<pre>aws sqs list-queues --endpoint-url https://sqs.containerk8s.dev.nubes.ru --region us-east-1 --profile sqs</pre>
</div>
<div class="form-group"> <div class="form-group">
<label>Access Key</label> <label>Access Key</label>
<input id="creds-ak" readonly onclick="copyField(this)"> <input id="creds-ak" readonly onclick="copyField(this)">
@@ -468,7 +409,7 @@ aws_secret_access_key = &lt;Secret Key&gt;</pre>
<h2>Отправить сообщение</h2> <h2>Отправить сообщение</h2>
<div class="form-group"> <div class="form-group">
<label for="send-body">Тело сообщения</label> <label for="send-body">Тело сообщения</label>
<textarea id="send-body" rows="6" style="width:100%;background:#ffffff;border:1px solid var(--border);border-radius:8px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical"></textarea> <textarea id="send-body" rows="6" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical"></textarea>
</div> </div>
<div id="send-error" class="login-error"></div> <div id="send-error" class="login-error"></div>
<div class="modal-actions"> <div class="modal-actions">
@@ -488,7 +429,7 @@ aws_secret_access_key = &lt;Secret Key&gt;</pre>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Body (нажмите, чтобы скопировать)</label> <label>Body (нажмите, чтобы скопировать)</label>
<textarea id="msg-detail-body" readonly rows="8" style="width:100%;background:#ffffff;border:1px solid var(--border);border-radius:8px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical;cursor:pointer" onclick="copyTextarea(this)"></textarea> <textarea id="msg-detail-body" readonly rows="8" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical;cursor:pointer" onclick="copyTextarea(this)"></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Отправлено</label> <label>Отправлено</label>
@@ -545,14 +486,6 @@ let sessionEmail = null; // Email из JWT для отображения в
} }
} catch(e) { /* corrupted — show login */ } } catch(e) { /* corrupted — show login */ }
} }
// Версия сервиса — берём из публичного /health и показываем на странице входа
fetch(BASE + '/health')
.then(r => r.json())
.then(h => {
const el = document.getElementById('svc-version');
if (el && h.version) el.textContent = h.version;
})
.catch(() => { /* health недоступен — оставляем '—' */ });
showLogin(); showLogin();
})(); })();
@@ -609,13 +542,7 @@ function enterApp() {
document.getElementById('login-page').classList.add('hidden'); document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden'); document.getElementById('app').classList.remove('hidden');
document.getElementById('user-email').textContent = sessionEmail || ''; document.getElementById('user-email').textContent = sessionEmail || '';
// Юзер сразу попадает в свой аккаунт — без промежуточного дашборда с таблицей. showDashboard();
api('/tenants')
.then(list => {
if (list && list.length > 0) showTenant(list[0].id);
else showDashboard();
})
.catch(() => showDashboard());
} }
// ===== API HELPER ===== // ===== API HELPER =====
@@ -666,6 +593,10 @@ function renderDashboard(health, tenants) {
const el = document.getElementById('view-dashboard'); const el = document.getElementById('view-dashboard');
el.innerHTML = ` el.innerHTML = `
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${health.tenant_count || 0}</div>
<div class="stat-label">Тенанты</div>
</div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-value">${health.queue_count || 0}</div> <div class="stat-value">${health.queue_count || 0}</div>
<div class="stat-label">Очереди</div> <div class="stat-label">Очереди</div>
@@ -678,14 +609,10 @@ function renderDashboard(health, tenants) {
<div class="stat-value" style="color:var(--success)">●</div> <div class="stat-value" style="color:var(--success)">●</div>
<div class="stat-label">${health.status || 'ok'}</div> <div class="stat-label">${health.status || 'ok'}</div>
</div> </div>
<div class="stat-card">
<div class="stat-value" style="font-size:18px">${health.version || 'dev'}</div>
<div class="stat-label">Версия</div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="toolbar"> <div class="toolbar">
<h2>Мой аккаунт</h2> <h2>Мой tenant</h2>
<div style="display:flex;gap:12px;align-items:center"> <div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh"> <div class="auto-refresh">
<span>⟳ 10с</span> <span>⟳ 10с</span>
@@ -697,6 +624,7 @@ function renderDashboard(health, tenants) {
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Имя</th>
<th>Access Key</th>
<th>Макс. очередей</th> <th>Макс. очередей</th>
<th>Статус</th> <th>Статус</th>
<th>Создан</th> <th>Создан</th>
@@ -706,6 +634,7 @@ function renderDashboard(health, tenants) {
${(tenants || []).map(t => ` ${(tenants || []).map(t => `
<tr onclick="showTenant('${esc(t.id)}')"> <tr onclick="showTenant('${esc(t.id)}')">
<td><strong>${esc(t.name)}</strong></td> <td><strong>${esc(t.name)}</strong></td>
<td style="font-family:monospace;font-size:12px">${esc(t.access_key)}</td>
<td>${t.max_queues}</td> <td>${t.max_queues}</td>
<td>${t.active <td>${t.active
? '<span class="badge badge-active">active</span>' ? '<span class="badge badge-active">active</span>'
@@ -713,7 +642,7 @@ function renderDashboard(health, tenants) {
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td> <td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
</tr> </tr>
`).join('')} `).join('')}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:32px">Аккаунт не найден</td></tr>' : ''} ${(!tenants || tenants.length === 0) ? '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:32px">Tenant не найден</td></tr>' : ''}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -747,6 +676,11 @@ function renderTenant(tenant, queues) {
const el = document.getElementById('view-tenant'); const el = document.getElementById('view-tenant');
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0); const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
el.innerHTML = ` el.innerHTML = `
<div class="breadcrumb">
<a href="#" onclick="event.preventDefault();showDashboard()">Мой tenant</a>
<span>›</span>
${esc(tenant.name)}
</div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-value">${(queues || []).length}</div> <div class="stat-value">${(queues || []).length}</div>
@@ -760,13 +694,16 @@ function renderTenant(tenant, queues) {
<div class="stat-value">${tenant.max_queues}</div> <div class="stat-value">${tenant.max_queues}</div>
<div class="stat-label">Лимит очередей</div> <div class="stat-label">Лимит очередей</div>
</div> </div>
<div class="stat-card">
<div class="stat-value" style="font-size:20px;font-family:monospace">${esc(tenant.access_key)}</div>
<div class="stat-label">Access Key</div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="toolbar"> <div class="toolbar">
<h2>Очереди</h2> <h2>Очереди</h2>
<div style="display:flex;gap:12px;align-items:center"> <div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh"><span>⟳ 10с</span></div> <div class="auto-refresh"><span>⟳ 10с</span></div>
<button class="btn btn-primary btn-sm" onclick="showCredentials()">Credentials</button>
<button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button> <button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
</div> </div>
</div> </div>
@@ -857,22 +794,9 @@ function closeCredsModal() {
document.getElementById('modal-creds').classList.add('hidden'); document.getElementById('modal-creds').classList.add('hidden');
} }
// showCredentials — запрашивает ключи тенанта и показывает их в модалке
function showCredentials() {
api('/credentials')
.then(data => {
document.getElementById('creds-ak').value = data.access_key || '';
document.getElementById('creds-sk').value = data.secret_key || '';
document.getElementById('modal-creds').classList.remove('hidden');
})
.catch(err => {
alert('Не удалось получить credentials: ' + err.message);
});
}
// deleteTenant — DELETE /tenants/{id} с подтверждением // deleteTenant — DELETE /tenants/{id} с подтверждением
function deleteTenant(id, name) { function deleteTenant(id, name) {
if (!confirm('Удалить аккаунт "' + name + '"?\nВсе очереди будут удалены.')) return; if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
api('/tenants/' + id, { method: 'DELETE' }) api('/tenants/' + id, { method: 'DELETE' })
.then(() => loadDashboard()) .then(() => loadDashboard())
.catch(err => alert('Ошибка удаления: ' + err.message)); .catch(err => alert('Ошибка удаления: ' + err.message));
@@ -1025,7 +949,7 @@ function renderQueueMessages(queueName, msgs) {
<tbody> <tbody>
${msgs.map(m => ` ${msgs.map(m => `
<tr onclick="openMsgDetail('${esc(m.id)}')" style="cursor:pointer" <tr onclick="openMsgDetail('${esc(m.id)}')" style="cursor:pointer"
onmouseover="this.style.background='var(--brand-grey-light)'" onmouseout="this.style.background=''"> onmouseover="this.style.background='rgba(26,127,212,0.08)'" onmouseout="this.style.background=''">
<td style="padding:6px 12px;font-family:monospace;color:var(--text-secondary)">${esc(m.id).substring(0,8)}…</td> <td style="padding:6px 12px;font-family:monospace;color:var(--text-secondary)">${esc(m.id).substring(0,8)}…</td>
<td style="padding:6px 12px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.body || '').substring(0,120)}${(m.body||'').length>120?'…':''}</td> <td style="padding:6px 12px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.body || '').substring(0,120)}${(m.body||'').length>120?'…':''}</td>
<td style="padding:6px 12px;color:var(--text-secondary)">${fmtDate(m.sent_at)}</td> <td style="padding:6px 12px;color:var(--text-secondary)">${fmtDate(m.sent_at)}</td>
-268
View File
@@ -1,268 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shared-sqs — очередь Nubes (SQS API)</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.5;
}
/* Топбар — как в deck: белый, border-bottom */
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; color: var(--text-primary); }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
.container { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; }
/* Hero */
.hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.hero h1 { font-size: 24px; margin: 0; letter-spacing: -0.3px; }
.hero .sub { color: var(--text-muted); margin: 4px 0 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--warning);
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 999px;
padding: 2px 10px;
margin-left: 8px;
}
/* Карточки — как в design-system */
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header {
background: var(--brand-grey-light);
padding: 12px;
font-weight: 600;
font-size: 14px;
}
.card-body { padding: 12px; }
.card-body > p:first-child { margin-top: 0; }
.card-body > p:last-child { margin-bottom: 0; }
/* Сетка «подключение» */
.kv { display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; }
.kv .k { color: var(--text-muted); }
.kv .v { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; word-break: break-all; }
a { color: var(--brand-primary); text-decoration: none; }
a:hover { color: var(--brand-primary-dark); text-decoration: underline; }
/* Таблицы — как в design-system */
.tbl-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--brand-grey-light);
text-align: left;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.3px;
padding: 4px 8px;
border-right: 1px solid var(--brand-gray);
color: var(--text-muted);
font-weight: 600;
}
td { padding: 4px 8px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:last-child td { border-bottom: none; }
td:last-child, th:last-child { border-right: none; }
tr:hover td { background: rgba(243,244,246,0.5); }
td code, .v code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
/* Код-блоки */
pre {
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
margin: 8px 0 0;
}
pre code { background: none; border: none; padding: 0; }
.cmd-label { font-size: 12px; color: var(--text-muted); margin: 12px 0 0; font-weight: 600; }
.cmd-label:first-child { margin-top: 0; }
.status-ok { color: var(--success); font-weight: 600; }
.footer { color: var(--text-muted); font-size: 12px; text-align: center; margin-top: 32px; }
/* Быстрый старт — шаги */
.step { display: flex; gap: 14px; margin-bottom: 18px; }
.step:last-child { margin-bottom: 0; }
.step-num {
flex: 0 0 30px;
height: 30px;
border-radius: 999px;
background: var(--brand-primary);
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.step-body { flex: 1; }
.step-title { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
.step-body p { color: #374151; margin: 4px 0; }
.btn-hero {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--brand-primary);
color: #fff !important;
text-decoration: none !important;
font-size: 15px;
font-weight: 600;
padding: 10px 20px;
border-radius: 8px;
margin-top: 8px;
}
.btn-hero:hover { background: var(--brand-primary-dark); }
.link-card { text-align: center; padding: 8px 0 4px; }
.link-card a { font-size: 15px; font-weight: 600; }
.hint-box {
font-size: 14px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 10px 14px;
margin-top: 10px;
line-height: 1.7;
}
.hint-box code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">Nubes</span>
<span class="muted">· очередь</span>
<span class="spacer"></span>
<span class="chip">{{VERSION}}</span>
</div>
<div class="container">
<div class="hero">
<img src="/static/logo.svg" height="28" alt="nubes">
<div>
<h1>shared-sqs <span class="badge">тестирование</span></h1>
<p class="sub">Очередь, совместимая с AWS SQS API. Очереди и сообщения пользователей — в одном месте, доступ по протоколу AWS.</p>
</div>
</div>
<div class="card">
<div class="card-header">Порядок действий — 3 шага</div>
<div class="card-body">
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<div class="step-title">Вход в консоль</div>
<p>API-токен выдается в панели Nubes и вводится на странице входа.</p>
<a class="btn-hero" href="/ui">Открыть консоль →</a>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<div class="step-title">Получение ключей доступа</div>
<p>В консоли кнопка <code>Credentials</code> открывает окно с <strong>Access Key</strong> и <strong>Secret Key</strong> — это учетные данные для подключения.</p>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<div class="step-title">Подключение клиента</div>
<p>Ключи указываются в AWS CLI/SDK; далее работа с очередями ведется как с обычным AWS SQS.</p>
<div class="hint-box">
Быстрый вариант — переменные окружения:<br>
<code>export AWS_ACCESS_KEY_ID=&lt;Access Key&gt;</code><br>
<code>export AWS_SECRET_ACCESS_KEY=&lt;Secret Key&gt;</code><br>
<code>aws sqs list-queues --endpoint-url https://sqs.containerk8s.dev.nubes.ru --region us-east-1</code>
</div>
<p style="margin-top:10px">Полные примеры команд, включая способ через файл <code>~/.aws/credentials</code>, — на отдельной странице:</p>
<a class="btn-hero" href="/examples">Примеры команд →</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Подключение</div>
<div class="card-body kv">
<div class="k">Endpoint</div>
<div class="v">https://sqs.containerk8s.dev.nubes.ru</div>
<div class="k">Протокол</div>
<div class="v">AWS SQS Query / JSON, подпись <code>AWS Signature V4</code></div>
<div class="k">Регион</div>
<div class="v">us-east-1</div>
<div class="k">Аутентификация</div>
<div class="v">Access Key / Secret Key — выдаются в консоли <a href="/ui">/ui</a> (кнопка Credentials)</div>
<div class="k">Консоль</div>
<div class="v"><a href="/ui">/ui</a> — веб-интерфейс (вход по API-токену Nubes)</div>
<div class="k">Health</div>
<div class="v"><a href="/health">GET /health</a> <span class="status-ok">● ok</span></div>
<div class="k">Метрики</div>
<div class="v"><a href="/metrics">GET /metrics</a> — Prometheus</div>
<div class="k">Persistence</div>
<div class="v">Managed Redis (realm <code>iot-naeel</code>)</div>
</div>
</div>
<div class="card">
<div class="card-header">Команды и примеры</div>
<div class="card-body">
<p style="color:#374151">Полный список команд SQS API и подробные примеры для AWS CLI — на отдельной странице с крупным шрифтом.</p>
<div class="link-card"><a href="/examples">Открыть примеры команд →</a></div>
</div>
</div>
<div class="footer">shared-sqs · realm iot-naeel · версия {{VERSION}} · <a href="/examples">примеры</a> · <a href="/metrics">метрики</a> · <a href="/health">health</a> · <a href="/admin">админ</a></div>
</div>
</body>
</html>
-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="57"
height="57"
xml:space="preserve"
overflow="hidden"
version="1.1"
id="svg8"
sodipodi:docname="U_v3.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.226667"
inkscape:cx="27.146672"
inkscape:cy="28.317584"
inkscape:window-width="2560"
inkscape:window-height="1494"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" /><defs
id="defs2"><clipPath
id="clip0"><rect
x="652"
y="420"
width="64"
height="75"
id="rect1" /></clipPath><clipPath
id="clip1"><rect
x="652"
y="420"
width="64"
height="70"
id="rect2" /></clipPath></defs><g
clip-path="url(#clip0)"
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
id="g8"><g
clip-path="url(#clip1)"
id="g7"><g
id="g6"><path
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
fill="#001c34"
fill-rule="evenodd"
transform="matrix(1,0,0,1.01337,587.92,420.059)"
id="path6" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

+1 -13
View File
@@ -2,7 +2,6 @@ package utils
import ( import (
"crypto/md5" "crypto/md5"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
@@ -83,11 +82,7 @@ func ExtractQueueAttributes(u url.Values) map[string]string {
// CreateErrorResponseV1 — формирует SQS error response по ключу ошибки. // CreateErrorResponseV1 — формирует SQS error response по ключу ошибки.
// Параметр isSqs оставлен для обратной совместимости (всегда true). // Параметр isSqs оставлен для обратной совместимости (всегда true).
func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) { func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) {
err, ok := models.SqsErrors[errKey] err := models.SqsErrors[errKey]
if !ok {
// Неизвестный ключ → InternalError вместо nil-deref (ревью 2026-10-11).
err = models.SqsErrors["InternalError"]
}
respStruct := models.ErrorResponse{ respStruct := models.ErrorResponse{
Result: err.Response(), Result: err.Response(),
@@ -102,13 +97,6 @@ func GetMD5Hash(text string) string {
return hex.EncodeToString(hasher.Sum(nil)) 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 { func HashAttributes(attributes map[string]models.MessageAttribute) string {
hasher := md5.New() hasher := md5.New()
+139
View File
@@ -0,0 +1,139 @@
package utils
import (
"net/url"
"testing"
"shared-sqs/app/models"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/mocks"
"github.com/stretchr/testify/assert"
)
func TestTransformRequest_success_json(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", fixtures.JSONRequestBody, true)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.True(t, ok)
assert.Equal(t, "mock-value", mock.RequestFieldStr)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_success_json_empty_request_accepted(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, true)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, true)
assert.True(t, ok)
//assert.Equal(t, "mock-value", mock.RequestFieldStr)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_success_xml(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, false)
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "UnitTestQueue1")
form.Add("Attribute.1.Name", "VisibilityTimeout")
form.Add("Attribute.1.Value", "60")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "2048")
r.PostForm = form
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.True(t, ok)
assert.True(t, mock.SetAttributesFromFormCalled)
assert.Equal(t, []interface{}{form}, mock.SetAttributesFromFormCalledWith)
}
func TestTransformRequest_error_invalid_request_body_json(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", "\"I-am-garbage", true)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.False(t, ok)
assert.Equal(t, "", mock.RequestFieldStr)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_error_failure_to_parse_form_xml(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, false)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.False(t, ok)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_error_invalid_request_body_xml(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, false)
form := url.Values{}
form.Add("intField", "\"I-am-garbage")
r.PostForm = form
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.False(t, ok)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestExtractQueueAttributes_success(t *testing.T) {
u := url.Values{}
u.Add("Attribute.1.Name", "DelaySeconds")
u.Add("Attribute.1.Value", "20")
u.Add("Attribute.2.Name", "VisibilityTimeout")
u.Add("Attribute.2.Value", "30")
u.Add("Attribute.3.Name", "Policy")
attr := ExtractQueueAttributes(u)
expected := map[string]string{
"DelaySeconds": "20",
"VisibilityTimeout": "30",
}
assert.Equal(t, expected, attr)
}
func TestGetMD5Hash(t *testing.T) {
hash1 := GetMD5Hash("This is a test")
hash2 := GetMD5Hash("This is a test")
if hash1 != hash2 {
t.Errorf("hashs and hash2 should be the same, but were not")
}
hash1 = GetMD5Hash("This is a test")
hash2 = GetMD5Hash("This is a tfst")
if hash1 == hash2 {
t.Errorf("hashs and hash2 are the same, but should not be")
}
}
func TestSortedKeys(t *testing.T) {
attributes := map[string]models.MessageAttribute{
"b": {},
"a": {},
}
keys := sortedKeys(attributes)
assert.Equal(t, "a", keys[0])
assert.Equal(t, "b", keys[1])
}
+77
View File
@@ -0,0 +1,77 @@
# deployments/k8s/deployment.yaml
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
# Updated: 2026-04-10 — v0.1.17: security fixes (20 vulnerabilities), AWS-compatible limits
apiVersion: apps/v1
kind: Deployment
metadata:
name: shared-sqs
namespace: shared-sqs
labels:
app: shared-sqs
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: shared-sqs
template:
metadata:
labels:
app: shared-sqs
spec:
containers:
- name: shared-sqs
image: naeel/shared-sqs:v0.1.17
ports:
- containerPort: 4100
name: http
env:
- name: SHARED_SQS_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: shared-sqs-admin
key: token
- name: SHARED_SQS_SEED_DEMO
value: "true"
- name: REDIS_ADDR
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: addr
- name: REDIS_USER
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: user
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: password
- name: NUBES_ENDPOINT
value: "https://deck-api-test.ngcloud.ru/api/v1"
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 4100
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 4100
initialDelaySeconds: 3
periodSeconds: 5
imagePullSecrets:
- name: sless-registry-auth
+37
View File
@@ -0,0 +1,37 @@
# deployments/k8s/ingress.yaml
# Ingress для shared-sqs на домене qu.kube5s.ru
# Created: 2026-04-09, Updated: 2026-04-11
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shared-sqs-ingress
namespace: shared-sqs
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
nginx.ingress.kubernetes.io/proxy-send-timeout: "30"
# Fix: 64KB+ payload (boto3/urllib3 2.0 шлёт headers и body отдельными TCP write,
# botocore убирает TCP_NODELAY → последние ~16KB body не доходят за client_body_timeout).
# Решение: proxy-request-buffering=off стримит body напрямую в backend без ожидания полного тела.
nginx.ingress.kubernetes.io/client-body-buffer-size: "512k"
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
nginx.ingress.kubernetes.io/server-snippet: |
client_body_timeout 120s;
spec:
ingressClassName: nginx
rules:
- host: qu.kube5s.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shared-sqs
port:
number: 4100
tls:
- hosts:
- qu.kube5s.ru
secretName: shared-sqs-tls
+9
View File
@@ -0,0 +1,9 @@
# deployments/k8s/namespace.yaml
# Namespace для shared-sqs сервиса
# Created: 2026-04-09
apiVersion: v1
kind: Namespace
metadata:
name: shared-sqs
labels:
app: shared-sqs
+80
View File
@@ -0,0 +1,80 @@
# 2026-04-10 — Redis для shared-sqs в кластере naeel-test-3
# Single-node Redis с PVC для persistence состояния shared-sqs между рестартами.
# Namespace: shared-sqs. Пароль совпадает с managed Redis из deck.ngcloud.ru.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-sqs-redis-pvc
namespace: shared-sqs
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: shared-sqs-redis
namespace: shared-sqs
labels:
app: shared-sqs-redis
spec:
replicas: 1
selector:
matchLabels:
app: shared-sqs-redis
strategy:
type: Recreate
template:
metadata:
labels:
app: shared-sqs-redis
spec:
containers:
- name: redis
image: redis:7.2-alpine
command:
- redis-server
- --requirepass
- $(REDIS_PASSWORD)
- --appendonly
- "yes"
- --save
- "60 1"
ports:
- containerPort: 6379
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: password
volumeMounts:
- name: redis-data
mountPath: /data
resources:
requests:
memory: 128Mi
cpu: 50m
limits:
memory: 256Mi
cpu: 200m
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: shared-sqs-redis-pvc
---
apiVersion: v1
kind: Service
metadata:
name: shared-sqs-redis
namespace: shared-sqs
spec:
selector:
app: shared-sqs-redis
ports:
- port: 6379
targetPort: 6379
+26
View File
@@ -0,0 +1,26 @@
# deployments/k8s/secret.yaml
# Секреты для shared-sqs: admin token + Redis credentials
# Created: 2026-04-09
# Updated: 2026-04-10 — добавлен shared-sqs-redis secret
# ВНИМАНИЕ: заполнить реальными значениями перед деплоем
# kubectl create secret generic shared-sqs-admin --from-literal=token=YOUR_TOKEN -n shared-sqs
# kubectl create secret generic shared-sqs-redis --from-literal=addr=HOST:6379 --from-literal=user=default --from-literal=password=PASS -n shared-sqs
apiVersion: v1
kind: Secret
metadata:
name: shared-sqs-admin
namespace: shared-sqs
type: Opaque
stringData:
token: "REPLACE_WITH_REAL_TOKEN"
---
apiVersion: v1
kind: Secret
metadata:
name: shared-sqs-redis
namespace: shared-sqs
type: Opaque
stringData:
addr: "REPLACE_WITH_REDIS_ADDR"
user: "default"
password: "REPLACE_WITH_REDIS_PASSWORD"
+19
View File
@@ -0,0 +1,19 @@
# deployments/k8s/service.yaml
# Service для shared-sqs
# Created: 2026-04-09
apiVersion: v1
kind: Service
metadata:
name: shared-sqs
namespace: shared-sqs
labels:
app: shared-sqs
spec:
selector:
app: shared-sqs
ports:
- name: http
port: 4100
targetPort: 4100
protocol: TCP
type: ClusterIP
-222
View File
@@ -1,222 +0,0 @@
# Миграция IoT из Kubernetes → Managed Services (Nubes)
## Сессия 2026-08-13
---
## 1. Что изучили — текущая архитектура
### Стек
- **Язык:** Go 1.25
- **Паттерн:** Kubernetes Operator (controller-runtime)
- **Модуль:** `gitea.services.ngcloud.ru/Nail/IoT`
- **Docker Hub:** `naeel/iot-operator:v0.2.6`
### Три бинарника в одном образе
| Бинарник | Роль |
|---|---|
| `cmd/iot-operator` | Controller-manager (CRD IoTDevice) + REST API :9090 |
| `cmd/mqtt-bridge` | MQTT (EMQX) → shared-SQS (AWS SDK v2) |
| `cmd/sqs-consumer` | shared-SQS → per-tenant Postgres |
### Поток данных (текущий)
```
IoT Device (MQTT CONNECT, username="{ns}_{deviceId}")
↓
EMQX 5.5.1 (HTTP auth/acl → iot-operator:9090/internal/mqtt/auth)
↓ MQTT PUBLISH "{ns}/telemetry/{deviceId}"
iot-mqtt-bridge (подписка "+/telemetry/+")
↓ AWS SDK SQS SendMessage
shared-SQS (namespace shared-sqs, очередь "iot-telemetry")
↓ long polling (WaitTimeSeconds=20)
iot-sqs-consumer (at-least-once, DeleteMessage после успешной записи)
↓
Managed PostgreSQL 17 (per-tenant DB: tenant_{namespace})
↓
REST API (iot-operator:9090) → Пользователь (UI/Terraform)
```
### Ключевые компоненты k8s
- **CRD IoTDevice** (`iot.nubes.ru/v1alpha1`) — регистрация устройств
- **k8s Secret** `iot-{deviceId}` — хранит MQTT пароль (64 hex, crypto/rand)
- **OwnerReference** — каскадное удаление Secret при удалении IoTDevice
- **RBAC** — ClusterRole для чтения/записи IoTDevice + Secrets
- **Namespace = ID тенанта** — изоляция устройств и данных
### Мультитенантность
- MQTT topic изоляция: `{ns}/telemetry/{deviceId}` — ACL на уровне EMQX
- Per-tenant Postgres DB: `tenant_{namespace}` (дефисы → подчёркивания)
- MQTT username: `{namespace}_{deviceId}` — глобально уникален
- `sync.Map` кэш `*sql.DB` per tenant — lazy init
### Наблюдения / потенциальные проблемы
1. **`authTestMode = true`** в `middleware/auth.go` — тестовый режим включён в проде, JWT подпись не проверяется
2. **`ADMIN_STATS_TOKEN = "iot-admin-2026"`** захардкожен в YAML манифесте вместо Secret
3. Устаревшие файлы: `deployments/k8s/kafka.yaml`, `iot-kafka-consumer.yaml` (Kafka удалена, манифесты остались)
4. Bridge clientID `"sless-iot-bridge"` захардкожен и в bridge коде и в ACL логике
5. `examples/main.tf` и `handler.py` описывают устаревший поток (RabbitMQ/serverless)
---
## 2. Почему хотим уйти из k8s
- Цель: использовать только **managed services** облака Nubes
- Nubes предоставляет: **Managed Node.js, Flask, Lucee, PostgreSQL**
- Никаких VPS, никакого k8s — только managed-платформа
- SQS тоже **наш собственный сервис** (не сторонний облачный), работает сейчас в k8s, тоже надо вынести
---
## 3. Принятые решения
### Node.js — выбранный стек
| Задача | Node.js |
|---|---|
| REST API | Express/Fastify |
| MQTT-брокер | `aedes` (embedded, over WebSocket) |
| MQTT-клиент для publish | `mqtt` npm |
| SQS клиент | `@aws-sdk/client-sqs` |
| Postgres | `pg` npm |
**Почему не Flask:** сложнее держать persistent MQTT и SQS polling в фоне
**Почему не Lucee:** не подходит для long-running background workers
**Почему Node.js — монолит:** bridge и consumer нельзя масштабировать независимо (один MQTT-клиент = одна подписка), смысла разделять нет
### EMQX → aedes (embedded в Node.js)
- Managed Node.js открывает только HTTP/HTTPS порты
- MQTT over WebSocket = HTTP upgrade → работает на любой managed-платформе
- Устройства подключаются через `wss://` (уже сейчас так, через ingress emqx-ws-ingress.yaml)
- **aedes** — полноценный MQTT-брокер на Node.js, встраивается в Express HTTP-сервер
- Auth/ACL становится обычной функцией внутри того же процесса (быстрее, проще)
### Мультитенантность сохраняется полностью
- `namespace` — просто строка в таблице `iot_devices` вместо k8s namespace
- Per-tenant Postgres DB остаётся (чистый SQL, без k8s)
- MQTT topic изоляция остаётся (`{ns}/telemetry/{deviceId}`)
- ACL по топику остаётся — просто функция вместо HTTP endpoint
### k8s CRD/Secret → таблица в Postgres
```sql
CREATE TABLE iot_devices (
namespace TEXT NOT NULL,
name TEXT NOT NULL,
device_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
mqtt_password TEXT NOT NULL, -- было в k8s Secret
metadata JSONB,
phase TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (namespace, device_id)
);
```
---
## 4. Итоговая архитектура на Nubes Managed
```
IoT Device (wss://iot.example.ru/mqtt)
↓
Managed Node.js — IoT сервис
├── aedes MQTT-брокер (over WebSocket, порт :3000/mqtt)
│ auth/acl → функция → таблица iot_devices в PG
│ on publish → SQS SendMessage
├── SQS consumer (long polling, фоновый setInterval/async loop)
│ → per-tenant Postgres (tenant_{namespace})
└── REST API (Express)
GET/POST /v1/namespaces/{ns}/iot/devices
GET /v1/namespaces/{ns}/iot/telemetry
POST /internal/mqtt/auth (совместимость, опционально)
GET /console (embedded HTML)
GET /iot-admin/stats
↓
Managed Node.js — shared-SQS сервис ← другие сервисы тоже
↓
Managed PostgreSQL — IoT данные
```
### Два managed Node.js сервиса
| Сервис | Назначение |
|---|---|
| **shared-SQS** | AWS SQS-совместимая очередь, multi-tenant, HTTP API |
| **iot-service** | aedes MQTT + REST API + SQS consumer + PG |
---
## 5. Порядок миграции
### ⚠️ Сначала shared-SQS, потом IoT
**Причина:** IoT зависит от SQS. SQS независим — мигрирует первым.
```
Шаг 1: Вынести shared-SQS из k8s → Managed Node.js на Nubes
Шаг 2: Переписать IoT сервис на Node.js (aedes + REST + consumer)
Шаг 3: Деплой IoT на Managed Node.js на Nubes
Шаг 4: Отключить k8s деплой IoT
```
---
## 6. Детали для нового чата — shared-SQS миграция
### Что сейчас
- SQS сервис живёт в `namespace: shared-sqs` в кластере `iot-naeel`
- Endpoint: `https://sqs.containerk8s.dev.nubes.ru`
- Multi-tenant: tenant `iot-service` (id: `t-96afe7e9f781f6ca`), очередь `iot-telemetry`
- IoT использует: `SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`
- Протокол: AWS SQS-совместимый (SendMessage, ReceiveMessage, DeleteMessage, GetQueueUrl, GetQueueAttributes)
### Что нужно от нового SQS сервиса
- AWS SQS-совместимый HTTP API (те же методы что сейчас)
- Multi-tenant (разные access key / secret key для разных тенантов)
- Очереди создаются по имени (`GetQueueUrl` + `CreateQueue`)
- Long polling: `ReceiveMessage` с `WaitTimeSeconds` до 20
- Хранение сообщений: in-memory или Postgres/Redis
### Клиенты SQS в IoT коде
1. **mqtt-bridge** (`cmd/mqtt-bridge/main.go`): `SendMessage` при каждом MQTT сообщении
2. **sqs-consumer** (`cmd/sqs-consumer/main.go`): `ReceiveMessage` (polling) + `DeleteMessage`
3. **iot-admin-stats** (`internal/api/handler/iot_admin_stats_handler.go`): `GetQueueAttributes` для мониторинга
### Env vars для IoT → SQS
```
SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru (поменяется на новый managed URL)
SQS_ACCESS_KEY=...
SQS_SECRET_KEY=...
SQS_QUEUE_NAME=iot-telemetry (default)
SQS_REGION=us-east-1 (default, не важен для self-hosted)
```
---
## 7. Что переписывается в IoT (Node.js)
### Соответствие Go → Node.js
| Go файл | Node.js файл |
|---|---|
| `cmd/iot-operator/main.go` | `src/index.js` (точка входа) |
| `internal/api/router.go` | `src/routes.js` |
| `internal/api/handler/iot_device_handler.go` | `src/handlers/devices.js` |
| `internal/api/handler/iot_telemetry_handler.go` | `src/handlers/telemetry.js` |
| `internal/api/handler/iot_admin_stats_handler.go` | `src/handlers/admin.js` |
| `internal/api/middleware/auth.go` | `src/middleware/auth.js` |
| `internal/storage/iotpg/iot_telemetry_store.go` | `src/storage/pg.js` |
| `cmd/mqtt-bridge/main.go` | встроен в `src/mqtt.js` (aedes) |
| `cmd/sqs-consumer/main.go` | встроен в `src/sqsWorker.js` |
| `controllers/iotdevice_controller.go` | **не нужен** (заменён CRUD в БД) |
| `api/v1alpha1/device_types.go` | **не нужен** (таблица iot_devices) |
### npm зависимости
```json
{
"dependencies": {
"express": "^4",
"aedes": "^0.51",
"websocket-stream": "^5",
"@aws-sdk/client-sqs": "^3",
"pg": "^8",
"uuid": "^9"
}
}
```
-56
View File
@@ -1,56 +0,0 @@
# Миграция shared-sqs: k8s → Nubes Managed
## Сессия 2026-08-13
---
## Текущая архитектура (k8s)
- Go-бинарник, HTTP-сервер, ~50MB RAM
- Deployment в k8s: `naeel/shared-sqs:v0.1.22`
- Redis sidecar — write-through persistence
- Exposed через Ingress + TLS
## Целевая архитектура (Nubes Managed)
| Компонент | Сейчас | После |
|---|---|---|
| Рантайм | k8s Deployment | Nubes Simple HTTP Container |
| Persistence | Redis pod в k8s | Nubes Managed Redis |
| Секреты | k8s Secret | Env vars в Container |
| Ingress/TLS | k8s Ingress | встроен в Simple HTTP Container |
**Код не меняется вообще.** Только инфраструктура.
## Совместимость
Сервис протестирован как AWS SQS совместимый — работает с AWS CLI, AWS SDK, Yandex.
## Открытые вопросы (уточнить перед деплоем)
1. Managed Redis: TLS или plain endpoint? → влияет на флаг `REDIS_INSECURE` в коде
2. Simple HTTP Container: поддержка custom domain или только `*.containerk8s.services.ngcloud.ru`?
## План миграции
**Шаг 1** — Создать Managed Redis на Nubes
**Шаг 2** — Задеплоить образ в Simple HTTP Container:
```
image: naeel/shared-sqs:v0.1.22
PORT=4100
SHARED_SQS_ADMIN_TOKEN=<секрет>
REDIS_ADDR=<managed redis endpoint>
REDIS_USER=<user>
REDIS_PASSWORD=<password>
SHARED_SQS_SEED_DEMO=false
```
**Шаг 3** — Проверить работоспособность (AWS CLI / SDK)
**Шаг 4** — Выключить k8s Deployment
## Что НЕ нужно делать
- Переписывать код на Node.js/Flask — не нужно
- Менять Redis persistence layer — не нужно
- Мигрировать данные вручную — Redis восстановит состояние автоматически при старте (LoadAllQueues / LoadAllTenantsRaw)
@@ -1,5 +1,3 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ. Исторический бенчмарк (апрель 2026). Помечено 2026-08-13. -->
# Сравнительный benchmark shared-sqs vs Yandex MQ # Сравнительный benchmark shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 05:45 UTC **Дата:** 2026-04-12 05:45 UTC
@@ -1,5 +1,3 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ. Внешний справочник Yandex MQ (для сравнения). Помечено 2026-08-13. -->
# Yandex Message Queue API Reference # Yandex Message Queue API Reference
**Дата документации:** 11 апреля 2026 **Дата документации:** 11 апреля 2026

Some files were not shown because too many files have changed in this diff Show More