v0.1.35: query-mode XML-ответы, TOCTOU receive, валидации AWS, purge 60s, сужение локов
This commit is contained in:
@@ -465,3 +465,57 @@ DeleteQueue всегда успешен; NextSequenceNumber пересоздаё
|
|||||||
PurgeQueueInProgress (LastPurgeTime); FIFO MissingParameter; SendMessage пустое тело/размер — AWS-коды.
|
PurgeQueueInProgress (LastPurgeTime); FIFO MissingParameter; SendMessage пустое тело/размер — AWS-коды.
|
||||||
10. NumberOfReceives инкремент при выдаче.
|
10. NumberOfReceives инкремент при выдаче.
|
||||||
После: 25-цикловая проверка целостности, smoke, повторный 30-мин нагрузочный тест.
|
После: 25-цикловая проверка целостности, smoke, повторный 30-мин нагрузочный тест.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1.35 — реализация фиксов по итогам нагрузочного теста (14.08.2026)
|
||||||
|
|
||||||
|
**Изменённые файлы** (правки с подробными комментариями каждой функции и логики):
|
||||||
|
|
||||||
|
1. `app/router/router.go`:
|
||||||
|
- `resolveProtocol`: заголовок `x-amzn-query-mode: true` (AWS CLI v2/boto3) →
|
||||||
|
принудительно Query-протокол (XML-ответы). Раньше query-mode запросы получали
|
||||||
|
JSON-ошибки → botocore Code=None («сырые 400/404»).
|
||||||
|
- `extractAction`: Action берётся сначала из `X-Amz-Target` (JSON и query-mode
|
||||||
|
клиенты), затем из form `Action` — для query-mode раньше Action не находился.
|
||||||
|
- `encodeResponse` (JSON): ошибки — в формате AWS-JSON
|
||||||
|
`{"__type":"com.amazonaws.sqs#<Code>","message":"..."}` (раньше голый ErrorResult).
|
||||||
|
- `actionHandler`: неизвестный Action → GeneralError в формате протокола клиента
|
||||||
|
(раньше текст "Bad Request"). Убран импорт `io`.
|
||||||
|
|
||||||
|
2. `app/gosqs/receive_message.go`:
|
||||||
|
- TOCTOU устранён: проверка готовности И пометка in-flight — под одним Lock
|
||||||
|
(`receiveMessagesUnderLock`). Раньше два параллельных receive видели одно
|
||||||
|
сообщение, второй получал пусто → «потери».
|
||||||
|
- MaxNumberOfMessages: 1–10, выход → InvalidParameterValue (был clamp).
|
||||||
|
- Проверка существования очереди — под RLock (была гонка чтения map).
|
||||||
|
- `msg.NumberOfReceives++` при выдаче (счётчик доставок).
|
||||||
|
|
||||||
|
3. `app/gosqs/send_message.go`:
|
||||||
|
- FIFO без MessageGroupId → MissingParameter (было: принималось).
|
||||||
|
- Док-схема функции; Redis-записи асинхронны, под Lock только marshal.
|
||||||
|
|
||||||
|
4. `app/gosqs/gosqs.go`:
|
||||||
|
- `PeriodicTasks`: снапшот указателей под RLock + обработка каждой очереди под
|
||||||
|
отдельным Lock (раньше глобальный Lock на весь обход всех очередей).
|
||||||
|
|
||||||
|
5. `app/models/models.go`:
|
||||||
|
- `LockGroup`/`NextSequenceNumber`: map больше НЕ пересоздаётся (терялись
|
||||||
|
блокировки/счётчики остальных групп).
|
||||||
|
- Новое поле `Queue.LastPurgeTime`.
|
||||||
|
|
||||||
|
6. `app/gosqs/queue_attributes.go` + `set_queue_attributes.go` + `create_queue.go`:
|
||||||
|
- Применяются только явно переданные атрибуты (!= 0) — раньше zero-value
|
||||||
|
обнулял непереданные атрибуты.
|
||||||
|
- Bounds-check → InvalidParameterValue (был clamp).
|
||||||
|
- Whitelist имён → InvalidAttributeName (Query-протокол).
|
||||||
|
|
||||||
|
7. `app/gosqs/purge_queue.go`: повторный purge < 60 с → PurgeQueueInProgress.
|
||||||
|
|
||||||
|
8. `app/gosqs/delete_queue.go`: удаление несуществующей очереди → QueueNotFound.
|
||||||
|
|
||||||
|
9. `app/models/errors.go`: добавлены `InvalidAttributeName`, `PurgeQueueInProgress`.
|
||||||
|
|
||||||
|
**Проверки**: go build OK (v0.1.35), go vet чистый (кроме известной некомпиляции
|
||||||
|
тестов gosqs — пакет fixtures), go test app/models OK.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Registry: Docker Hub (naeel/shared-sqs)
|
# Registry: Docker Hub (naeel/shared-sqs)
|
||||||
|
|
||||||
IMAGE_REPO=naeel/shared-sqs
|
IMAGE_REPO=naeel/shared-sqs
|
||||||
VERSION=v0.1.34
|
VERSION=v0.1.35
|
||||||
LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION)
|
LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION)
|
||||||
BINARY=shared-sqs
|
BINARY=shared-sqs
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// Изменено: 2026-04-10 — добавлена Redis persistence
|
|
||||||
// CreateQueueV1 — создаёт очередь для тенанта из request context.
|
|
||||||
// Ключ в SyncQueues: "{tenantAccessKey}:{queueName}" для изоляции между тенантами.
|
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,6 +12,21 @@ 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)
|
||||||
@@ -53,7 +65,13 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
||||||
Duplicates: make(map[string]time.Time),
|
Duplicates: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil {
|
// Список имён переданных атрибутов — для whitelist-валидации
|
||||||
|
// (Query-протокол: Attribute.N.Name; JSON: nil — проверка пропускается).
|
||||||
|
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); err != nil {
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
return utils.CreateErrorResponseV1(err.Error(), true)
|
return utils.CreateErrorResponseV1(err.Error(), true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Изменено: 2026-04-10 — добавлена Redis persistence
|
|
||||||
// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу.
|
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,6 +12,17 @@ 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)
|
||||||
@@ -34,8 +43,14 @@ 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()
|
||||||
|
// Проверка существования: AWS возвращает NonExistentQueue, а не 200
|
||||||
|
// (раньше удаление несуществующей очереди молча «успевало»).
|
||||||
|
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)
|
||||||
|
}
|
||||||
delete(models.SyncQueues.Queues, key)
|
delete(models.SyncQueues.Queues, key)
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
// Удаляем из Redis асинхронно
|
// Удаляем из Redis асинхронно
|
||||||
persistence.DeleteQueue(key)
|
persistence.DeleteQueue(key)
|
||||||
|
|||||||
+79
-34
@@ -13,46 +13,40 @@ 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:
|
||||||
models.SyncQueues.Lock()
|
// Снапшот указателей на очереди под коротким RLock.
|
||||||
for qName := range models.SyncQueues.Queues {
|
// Указатели остаются валидными после снятия RLock: очереди из map
|
||||||
queue := models.SyncQueues.Queues[qName]
|
// не удаляются во время итерации, а объекты живут в куче.
|
||||||
|
models.SyncQueues.RLock()
|
||||||
// Reset deduplication period
|
queues := make([]*models.Queue, 0, len(models.SyncQueues.Queues))
|
||||||
for dedupId, startTime := range queue.Duplicates {
|
for _, queue := range models.SyncQueues.Queues {
|
||||||
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
|
queues = append(queues, queue)
|
||||||
log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId)
|
}
|
||||||
delete(queue.Duplicates, dedupId)
|
models.SyncQueues.RUnlock()
|
||||||
}
|
|
||||||
}
|
// Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной
|
||||||
|
// очереди не мешает обработке остальных и HTTP-запросам.
|
||||||
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
|
for _, queue := range queues {
|
||||||
for i := 0; i < len(queue.Messages); i++ {
|
models.SyncQueues.Lock()
|
||||||
msg := &queue.Messages[i]
|
processQueueTick(queue)
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
if msg.ReceiptHandle != "" {
|
|
||||||
if msg.VisibilityTimeout.Before(time.Now()) {
|
|
||||||
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
|
|
||||||
queue.UnlockGroup(msg.GroupID)
|
|
||||||
msg.ReceiptHandle = ""
|
|
||||||
msg.ReceiptTime = time.Now().UTC()
|
|
||||||
msg.Retry++
|
|
||||||
if queue.MaxReceiveCount > 0 &&
|
|
||||||
queue.DeadLetterQueue != nil &&
|
|
||||||
msg.Retry >= queue.MaxReceiveCount {
|
|
||||||
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
|
|
||||||
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
|
|
||||||
i--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
case <-quit:
|
case <-quit:
|
||||||
ticker.Stop()
|
ticker.Stop()
|
||||||
return
|
return
|
||||||
@@ -60,6 +54,57 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processQueueTick — обработка ОДНОЙ очереди за один такт.
|
||||||
|
//
|
||||||
|
// ВЫЗЫВАТЬ ТОЛЬКО под удержанием SyncQueues.Lock.
|
||||||
|
//
|
||||||
|
// Логическая схема:
|
||||||
|
// 1. Deduplication-окно (FIFO): удаляем записи старше DeduplicationPeriod.
|
||||||
|
// 2. Проходим по сообщениям очереди:
|
||||||
|
// - пустой ReceiptHandle (не in-flight) — пропускаем;
|
||||||
|
// - VisibilityTimeout ещё не истёк — пропускаем;
|
||||||
|
// - иначе: возвращаем видимость (сброс ReceiptHandle, разблокировка
|
||||||
|
// FIFO-группы, обновление ReceiptTime), инкрементируем Retry;
|
||||||
|
// - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение
|
||||||
|
// в DLQ и удаляем из исходной очереди.
|
||||||
|
func processQueueTick(queue *models.Queue) {
|
||||||
|
// 1. Просроченные записи deduplication-окна.
|
||||||
|
for dedupId, startTime := range queue.Duplicates {
|
||||||
|
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
|
||||||
|
log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId)
|
||||||
|
delete(queue.Duplicates, dedupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Возврат видимости истёкших сообщений + перенос в DLQ.
|
||||||
|
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
|
||||||
|
for i := 0; i < len(queue.Messages); i++ {
|
||||||
|
msg := &queue.Messages[i]
|
||||||
|
|
||||||
|
if msg.ReceiptHandle == "" {
|
||||||
|
continue // сообщение не в полёте
|
||||||
|
}
|
||||||
|
if msg.VisibilityTimeout.After(time.Now()) {
|
||||||
|
continue // таймаут видимости ещё действует
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
|
||||||
|
queue.UnlockGroup(msg.GroupID)
|
||||||
|
msg.ReceiptHandle = ""
|
||||||
|
msg.ReceiptTime = time.Now().UTC()
|
||||||
|
msg.Retry++
|
||||||
|
|
||||||
|
// 3. Перенос в DLQ при превышении MaxReceiveCount.
|
||||||
|
if queue.MaxReceiveCount > 0 &&
|
||||||
|
queue.DeadLetterQueue != nil &&
|
||||||
|
msg.Retry >= queue.MaxReceiveCount {
|
||||||
|
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
|
||||||
|
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
|
||||||
|
i-- // элемент на позиции i удалён — повторно проверяем новый элемент на ней
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func numberOfHiddenMessagesInQueue(queue models.Queue) int {
|
func numberOfHiddenMessagesInQueue(queue models.Queue) int {
|
||||||
num := 0
|
num := 0
|
||||||
for _, m := range queue.Messages {
|
for _, m := range queue.Messages {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Изменено: 2026-04-10 — добавлена Redis persistence
|
|
||||||
// PurgeQueueV1 — очищает все сообщения в очереди тенанта.
|
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,6 +13,21 @@ 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)
|
||||||
@@ -39,9 +52,18 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь.
|
||||||
|
// Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает.
|
||||||
|
if time.Since(models.SyncQueues.Queues[key].LastPurgeTime) < 60*time.Second {
|
||||||
|
log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, models.SyncQueues.Queues[key].LastPurgeTime.Format(time.RFC3339))
|
||||||
|
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)
|
||||||
models.SyncQueues.Queues[key].Messages = nil
|
models.SyncQueues.Queues[key].Messages = nil
|
||||||
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
|
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
|
||||||
|
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
|
||||||
|
models.SyncQueues.Queues[key].LastPurgeTime = time.Now()
|
||||||
// Удаляем все сообщения из Redis одной командой DEL
|
// Удаляем все сообщения из Redis одной командой DEL
|
||||||
persistence.PurgeMessagesPersist(key)
|
persistence.PurgeMessagesPersist(key)
|
||||||
// Сохраняем пустые метаданные очереди
|
// Сохраняем пустые метаданные очереди
|
||||||
|
|||||||
@@ -9,27 +9,65 @@ import (
|
|||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO - Support:
|
// setQueueAttributesV1 — применяет атрибуты очереди с валидацией по AWS-спецификации.
|
||||||
// - attr.MessageRetentionPeriod
|
//
|
||||||
// - attr.Policy
|
// Логическая схема:
|
||||||
// - attr.RedriveAllowPolicy
|
// 1. Whitelist имён: неизвестное имя атрибута → InvalidAttributeName.
|
||||||
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error {
|
// Список имён (provided) доступен для Query-протокола (form-параметры
|
||||||
// AWS-совместимые лимиты: clamp значений к допустимым диапазонам
|
// Attribute.N.Name); для JSON-протокола provided == nil и проверка пропускается
|
||||||
if attr.DelaySeconds >= 0 {
|
// (структура уже разобрана из тела, неизвестные поля отброшены).
|
||||||
q.DelaySeconds = ClampInt(attr.DelaySeconds.Int(), 0, MaxDelaySeconds)
|
// 2. Применяются ТОЛЬКО явно переданные атрибуты (значение != 0).
|
||||||
|
// ВАЖНО (фикс): раньше условие `attr.X >= 0` срабатывало и для НЕ переданных
|
||||||
|
// полей (zero-value = 0) — вызов SetQueueAttributes с одним атрибутом
|
||||||
|
// обнулял все остальные атрибуты очереди.
|
||||||
|
// 3. Значения вне диапазонов AWS → ошибка InvalidParameterValue
|
||||||
|
// (раньше значения молча клэмпились в допустимый диапазон).
|
||||||
|
// 4. RedrivePolicy: ARN DLQ разбирается, DLQ должна существовать, иначе
|
||||||
|
// InvalidAttributeValue.
|
||||||
|
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided map[string]string) error {
|
||||||
|
// Шаг 1: whitelist имён атрибутов.
|
||||||
|
for name := range provided {
|
||||||
|
if !attrNameWhitelist[name] {
|
||||||
|
return fmt.Errorf("InvalidAttributeName")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if attr.MaximumMessageSize >= 0 {
|
|
||||||
q.MaximumMessageSize = ClampInt(attr.MaximumMessageSize.Int(), MinMessageSizeLimit, MaxMessageSizeLimit)
|
// Шаг 2: применяем только явно переданные атрибуты с проверкой диапазонов.
|
||||||
|
// Диапазоны 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.MessageRetentionPeriod > 0 {
|
if attr.MaximumMessageSize != 0 {
|
||||||
q.MessageRetentionPeriod = ClampInt(attr.MessageRetentionPeriod.Int(), MinMessageRetentionPeriod, MaxMessageRetentionPeriod)
|
if attr.MaximumMessageSize.Int() < MinMessageSizeLimit || attr.MaximumMessageSize.Int() > MaxMessageSizeLimit {
|
||||||
|
return fmt.Errorf("InvalidParameterValue")
|
||||||
|
}
|
||||||
|
q.MaximumMessageSize = attr.MaximumMessageSize.Int()
|
||||||
}
|
}
|
||||||
if attr.ReceiveMessageWaitTimeSeconds > 0 {
|
if attr.MessageRetentionPeriod != 0 {
|
||||||
q.ReceiveMessageWaitTimeSeconds = ClampInt(attr.ReceiveMessageWaitTimeSeconds.Int(), 0, MaxReceiveMessageWaitTimeSeconds)
|
if attr.MessageRetentionPeriod.Int() < MinMessageRetentionPeriod || attr.MessageRetentionPeriod.Int() > MaxMessageRetentionPeriod {
|
||||||
|
return fmt.Errorf("InvalidParameterValue")
|
||||||
|
}
|
||||||
|
q.MessageRetentionPeriod = attr.MessageRetentionPeriod.Int()
|
||||||
}
|
}
|
||||||
if attr.VisibilityTimeout >= 0 {
|
if attr.ReceiveMessageWaitTimeSeconds != 0 {
|
||||||
q.VisibilityTimeout = ClampInt(attr.VisibilityTimeout.Int(), 0, MaxVisibilityTimeout)
|
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]
|
||||||
@@ -43,3 +81,14 @@ func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attrNameWhitelist — имена атрибутов, которые SetQueueAttributes может изменять.
|
||||||
|
// Всё, чего нет в списке, отклоняется с ошибкой InvalidAttributeName.
|
||||||
|
var attrNameWhitelist = map[string]bool{
|
||||||
|
"DelaySeconds": true,
|
||||||
|
"MaximumMessageSize": true,
|
||||||
|
"MessageRetentionPeriod": true,
|
||||||
|
"ReceiveMessageWaitTimeSeconds": true,
|
||||||
|
"VisibilityTimeout": true,
|
||||||
|
"RedrivePolicy": true,
|
||||||
|
}
|
||||||
|
|||||||
+150
-79
@@ -19,7 +19,27 @@ 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 {
|
||||||
@@ -27,18 +47,31 @@ 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 = 1
|
maxNumberOfMessages = MinNumberOfMessagesLimit
|
||||||
|
}
|
||||||
|
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)
|
||||||
@@ -50,118 +83,150 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
|
|||||||
|
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
if _, ok := models.SyncQueues.Queues[key]; !ok {
|
// Проверка существования очереди — под RLock (защита от параллельной записи
|
||||||
|
// в map; раньше map читалась без блокировки — гонка данных).
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
_, queueExists := models.SyncQueues.Queues[key]
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
if !queueExists {
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
var messages []*models.ResultMessage
|
// --- Шаг 5: WaitTimeSeconds (long polling) ---
|
||||||
respStruct := models.ReceiveMessageResponse{}
|
// 0 (не задан) → атрибут очереди ReceiveMessageWaitTimeSeconds (default 0).
|
||||||
|
|
||||||
// Валидация 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 {
|
||||||
models.SyncQueues.RLock()
|
models.SyncQueues.RLock()
|
||||||
waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds
|
waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds
|
||||||
models.SyncQueues.RUnlock()
|
models.SyncQueues.RUnlock()
|
||||||
}
|
}
|
||||||
// Валидация 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]
|
// получатель не может «съесть» сообщения — они помечаются
|
||||||
if !queueFound {
|
// ReceiptHandle в том же критическом участке (см. receiveMessagesUnderLock).
|
||||||
models.SyncQueues.RUnlock()
|
messages, found := receiveMessagesUnderLock(key, requestBody.VisibilityTimeout, maxNumberOfMessages)
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
if found {
|
||||||
|
return http.StatusOK, buildReceiveResponse(messages)
|
||||||
}
|
}
|
||||||
messageFound := queueHasReceivableMessages(queue)
|
if time.Now().After(deadline) {
|
||||||
models.SyncQueues.RUnlock()
|
return http.StatusOK, buildReceiveResponse(nil)
|
||||||
if messageFound || time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-req.Context().Done():
|
case <-req.Context().Done():
|
||||||
return http.StatusOK, models.ReceiveMessageResponse{
|
// Клиент разорвал соединение — завершаем без ошибки.
|
||||||
Xmlns: models.BaseXmlns,
|
return http.StatusOK, buildReceiveResponse(nil)
|
||||||
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)
|
||||||
|
return http.StatusOK, buildReceiveResponse(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiveMessagesUnderLock — АТОМАРНАЯ проверка и выборка сообщений очереди.
|
||||||
|
//
|
||||||
|
// ВЫЗЫВАТЬ БЕЗ удержания 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) {
|
||||||
models.SyncQueues.Lock()
|
models.SyncQueues.Lock()
|
||||||
defer models.SyncQueues.Unlock()
|
defer models.SyncQueues.Unlock()
|
||||||
|
|
||||||
if len(models.SyncQueues.Queues[key].Messages) > 0 {
|
queue, queueFound := models.SyncQueues.Queues[key]
|
||||||
numMsg := 0
|
if !queueFound || len(queue.Messages) == 0 {
|
||||||
messages = make([]*models.ResultMessage, 0)
|
return nil, false
|
||||||
for i := range models.SyncQueues.Queues[key].Messages {
|
}
|
||||||
if numMsg >= maxNumberOfMessages {
|
// Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
|
||||||
break
|
if !queueHasReceivableMessages(queue) {
|
||||||
}
|
return nil, false
|
||||||
|
|
||||||
if models.SyncQueues.Queues[key].Messages[i].ReceiptHandle != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := &models.SyncQueues.Queues[key].Messages[i]
|
|
||||||
if !msg.IsReadyForReceipt() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if models.SyncQueues.Queues[key].IsFIFO {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = append(messages, buildResultMessage(msg))
|
|
||||||
numMsg++
|
|
||||||
}
|
|
||||||
|
|
||||||
respStruct = models.ReceiveMessageResponse{
|
|
||||||
"http://queue.amazonaws.com/doc/2012-11-05/",
|
|
||||||
models.ReceiveMessageResult{Messages: messages},
|
|
||||||
models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"},
|
|
||||||
}
|
|
||||||
} 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
|
messages := make([]*models.ResultMessage, 0)
|
||||||
|
numMsg := 0
|
||||||
|
for i := range queue.Messages {
|
||||||
|
if numMsg >= maxNumberOfMessages {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
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.ReceiptHandle = msg.Uuid + "#" + uuid.NewString()
|
||||||
|
msg.ReceiptTime = time.Now().UTC()
|
||||||
|
if visibilityTimeout != 0 {
|
||||||
|
msg.VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
|
||||||
|
} else {
|
||||||
|
msg.VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second)
|
||||||
|
}
|
||||||
|
msg.NumberOfReceives++
|
||||||
|
|
||||||
|
messages = append(messages, buildResultMessage(msg))
|
||||||
|
numMsg++
|
||||||
|
}
|
||||||
|
|
||||||
|
if numMsg == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return messages, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
@@ -179,6 +244,12 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
msg := &queue.Messages[i]
|
msg := &queue.Messages[i]
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// Изменено: 2026-04-11 — убрано логирование тела сообщения (perf + security)
|
|
||||||
// SendMessageV1 — добавляет сообщение в очередь тенанта.
|
|
||||||
// Ловушка #6: queueName извлекается как ПОСЛЕДНИЙ сегмент URL — при URL вида
|
|
||||||
// http://host/tenantID/queueName последний сегмент = queueName (правильно).
|
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,6 +17,27 @@ 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)
|
||||||
@@ -78,6 +95,13 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
delaySecs := queue.DelaySeconds
|
delaySecs := queue.DelaySeconds
|
||||||
models.SyncQueues.RUnlock()
|
models.SyncQueues.RUnlock()
|
||||||
|
|
||||||
|
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
|
||||||
|
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
|
||||||
|
// Раньше сообщение просто попадало в группу с пустым именем.
|
||||||
|
if queueIsFIFO && messageGroupID == "" {
|
||||||
|
return utils.CreateErrorResponseV1("MissingParameter", true)
|
||||||
|
}
|
||||||
|
|
||||||
if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
|
if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
|
||||||
return utils.CreateErrorResponseV1("MessageTooBig", true)
|
return utils.CreateErrorResponseV1("MessageTooBig", true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// Изменено: 2026-04-10 — добавлена Redis persistence
|
|
||||||
// SetQueueAttributesV1 — устанавливает атрибуты очереди тенанта.
|
|
||||||
// Ловушка #9: при RedrivePolicy парсим ARN DLQ и DLQ тоже должна принадлежать тому же тенанту.
|
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,6 +12,17 @@ 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)
|
||||||
@@ -44,7 +52,16 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
|||||||
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)
|
||||||
}
|
}
|
||||||
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); err != nil {
|
||||||
return utils.CreateErrorResponseV1(err.Error(), true)
|
return utils.CreateErrorResponseV1(err.Error(), true)
|
||||||
}
|
}
|
||||||
// Сохраняем атрибуты в Redis пока держим Lock (через defer)
|
// Сохраняем атрибуты в Redis пока держим Lock (через defer)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ 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."},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-9
@@ -63,38 +63,66 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 _, ok := q.FIFOSequenceNumbers[groupId]; !ok {
|
if q.FIFOSequenceNumbers == nil {
|
||||||
q.FIFOSequenceNumbers = map[string]int{
|
q.FIFOSequenceNumbers = make(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 _, ok := q.FIFOMessages[groupId]; !ok {
|
if q.FIFOMessages == nil {
|
||||||
q.FIFOMessages = map[string]int{
|
q.FIFOMessages = make(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-окна.
|
||||||
|
//
|
||||||
|
// Логическая схема: дубликаты отслеживаются ТОЛЬКО для FIFO-очередей с
|
||||||
|
// включённой дупликацией (EnableDuplicates) и непустым deduplicationId.
|
||||||
|
// Для остальных случаев дубликаты не отслеживаются (AWS-семантика).
|
||||||
func (q *Queue) IsDuplicate(deduplicationId string) bool {
|
func (q *Queue) IsDuplicate(deduplicationId string) bool {
|
||||||
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
|
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
|
||||||
return false
|
return false
|
||||||
@@ -105,6 +133,10 @@ 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.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
|
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
+63
-15
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -103,10 +102,35 @@ 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)
|
||||||
@@ -196,9 +220,16 @@ func actionHandler(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
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)
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
errType := models.SqsErrors["GeneralError"]
|
||||||
io.WriteString(w, "Bad Request")
|
encodeResponse(w, req, errType.StatusCode(), models.ErrorResponse{
|
||||||
|
Result: errType.Response(),
|
||||||
|
RequestId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwsProtocol int
|
type AwsProtocol int
|
||||||
@@ -208,25 +239,42 @@ const (
|
|||||||
AwsQueryProtocol AwsProtocol = iota
|
AwsQueryProtocol AwsProtocol = iota
|
||||||
)
|
)
|
||||||
|
|
||||||
// extractAction — извлекает Action из запроса (Query Protocol или JSON Protocol)
|
// extractAction — извлекает имя операции SQS из запроса.
|
||||||
|
//
|
||||||
|
// Логическая схема (не зависит от 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 {
|
||||||
protocol := resolveProtocol(req)
|
if action := req.Header.Get("X-Amz-Target"); action != "" {
|
||||||
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 ""
|
return req.FormValue("Action")
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveProtocol — определяет протокол по Content-Type
|
// resolveProtocol — определяет протокол ОТВЕТА по заголовкам запроса.
|
||||||
|
//
|
||||||
|
// Логическая схема:
|
||||||
|
// 1. Заголовок x-amzn-query-mode: true (шлют AWS CLI v2 и boto3) означает
|
||||||
|
// «query-compatible JSON»: тело запроса сериализовано как JSON, но клиент
|
||||||
|
// ЖДЁТ ответ в XML (query-протокол). Если на такой запрос ответить JSON —
|
||||||
|
// botocore не распарсит тело ошибки (ClientError с Code=None — именно это
|
||||||
|
// наблюдалось: «сырые 400/404» без AWS-XML). Поэтому для query-mode
|
||||||
|
// принудительно отвечаем XML.
|
||||||
|
// 2. Content-Type: application/x-amz-json-1.0 БЕЗ query-mode — чистый
|
||||||
|
// JSON-протокол (SDK): и запрос, и ответ в JSON.
|
||||||
|
// 3. Всё остальное (form-urlencoded от старых клиентов) — query/XML.
|
||||||
func resolveProtocol(req *http.Request) AwsProtocol {
|
func resolveProtocol(req *http.Request) AwsProtocol {
|
||||||
|
if req.Header.Get("x-amzn-query-mode") == "true" {
|
||||||
|
return AwsQueryProtocol
|
||||||
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -384,7 +384,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
|||||||
<div>Админ-консоль: <a href="/admin">/admin</a> (вход по admin-токену)</div>
|
<div>Админ-консоль: <a href="/admin">/admin</a> (вход по admin-токену)</div>
|
||||||
<div>Realm: <span style="font-family:monospace">iot-naeel</span> · Persistence: Managed Redis</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 id="svc-version" style="font-family:monospace">—</span></div>
|
||||||
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.34</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 style="margin-top:8px;color:var(--warning)">Статус: тестирование</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""tests/load_test.py — длительная нагрузочная проверка устойчивости shared-sqs.
|
||||||
|
|
||||||
|
Режимы работы в каждом цикле:
|
||||||
|
1) Корректные операции: create/get-url/attributes/send/batch/receive(long poll)/
|
||||||
|
change-visibility/delete/batch-delete/tags/purge/delete-queue + FIFO.
|
||||||
|
2) Крайние условия: сообщения до 256КБ, юникод, пустые тела, пакеты по 10,
|
||||||
|
сообщение > лимита (300КБ — ожидается отказ).
|
||||||
|
3) Некорректные условия (ожидаемые отказы с известными кодами):
|
||||||
|
несуществующая очередь, битый ReceiptHandle, некорректные атрибуты,
|
||||||
|
повторный purge, >10 записей в batch, дубли Id в batch, пустой batch,
|
||||||
|
FIFO без MessageGroupId, некорректные имена очередей.
|
||||||
|
|
||||||
|
Контроль: отдельный поток каждые 10с проверяет GET /health.
|
||||||
|
Каждые 30с — строка прогресса. В конце — сводка по кодам ошибок и латентности.
|
||||||
|
|
||||||
|
Переменные окружения:
|
||||||
|
ENDPOINT_URL (по умолчанию https://sqs.containerk8s.dev.nubes.ru)
|
||||||
|
REGION (us-east-1)
|
||||||
|
DURATION_SECONDS (1800 = 30 минут)
|
||||||
|
WORKERS (4)
|
||||||
|
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY — обязательны
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
|
||||||
|
REGION = os.environ.get("REGION", "us-east-1")
|
||||||
|
DURATION = int(os.environ.get("DURATION_SECONDS", "1800"))
|
||||||
|
WORKERS = int(os.environ.get("WORKERS", "4"))
|
||||||
|
HEALTH_EVERY = 10
|
||||||
|
|
||||||
|
# Коды ошибок, которые ожидаемы для некорректных условий.
|
||||||
|
EXPECTED_CODES = {
|
||||||
|
"NonExistentQueue",
|
||||||
|
"QueueDeletedRecently",
|
||||||
|
"PurgeQueueInProgress",
|
||||||
|
"ReceiptHandleIsInvalid",
|
||||||
|
"InvalidParameterValue",
|
||||||
|
"InvalidAttributeValue",
|
||||||
|
"InvalidAttributeName",
|
||||||
|
"MissingParameter",
|
||||||
|
"ReadCountOutOfRange",
|
||||||
|
"TooManyEntriesInBatchRequest",
|
||||||
|
"BatchEntryIdsNotDistinct",
|
||||||
|
"EmptyBatchRequest",
|
||||||
|
"InvalidBatchEntryId",
|
||||||
|
"MessageTooLong",
|
||||||
|
"InvalidMessageContents",
|
||||||
|
"AWS.SimpleQueueService.NonExistentQueue",
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"valid_ok": 0,
|
||||||
|
"expected_err": 0,
|
||||||
|
"unexpected_fail": 0,
|
||||||
|
"integrity_fail": 0,
|
||||||
|
"soft_warn": 0, # некорректный запрос неожиданно прошёл
|
||||||
|
"health_fail": 0,
|
||||||
|
"ops_total": 0,
|
||||||
|
"err_by_code": {},
|
||||||
|
}
|
||||||
|
stats_lock = threading.Lock()
|
||||||
|
start_ts = time.time()
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
LATENCY = []
|
||||||
|
LATENCY_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def add_latency(sec):
|
||||||
|
with LATENCY_LOCK:
|
||||||
|
LATENCY.append(sec)
|
||||||
|
if len(LATENCY) > 100000:
|
||||||
|
del LATENCY[:50000]
|
||||||
|
|
||||||
|
|
||||||
|
def bump(key, n=1, code=None):
|
||||||
|
with stats_lock:
|
||||||
|
stats[key] += n
|
||||||
|
stats["ops_total"] += n
|
||||||
|
if code:
|
||||||
|
stats["err_by_code"][code] = stats["err_by_code"].get(code, 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def record_expected(e: ClientError):
|
||||||
|
code = e.response.get("Error", {}).get("Code", "Unknown")
|
||||||
|
bump("expected_err", code=code)
|
||||||
|
|
||||||
|
|
||||||
|
def record_unexpected(e):
|
||||||
|
code = getattr(e, "response", None) and e.response.get("Error", {}).get("Code", "Unknown") or type(e).__name__
|
||||||
|
bump("unexpected_fail", code=code)
|
||||||
|
|
||||||
|
|
||||||
|
def rand_body(max_size=250 * 1024):
|
||||||
|
size = random.randint(0, max_size)
|
||||||
|
return "".join(random.choices(string.ascii_letters + "абвгд\x00\xff😀", k=size))
|
||||||
|
|
||||||
|
|
||||||
|
def make_client():
|
||||||
|
cfg = Config(connect_timeout=10, read_timeout=25, retries={"max_attempts": 2})
|
||||||
|
return boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION, config=cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def health_monitor():
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
urllib.request.Request(ENDPOINT + "/health", headers={"User-Agent": "load-test"}),
|
||||||
|
timeout=10,
|
||||||
|
) as r:
|
||||||
|
body = r.read().decode("utf-8", "replace")
|
||||||
|
if r.status != 200 or '"status":"ok"' not in body:
|
||||||
|
bump("health_fail")
|
||||||
|
except Exception:
|
||||||
|
bump("health_fail")
|
||||||
|
stop.wait(HEALTH_EVERY)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_cycle(sqs, wid, it):
|
||||||
|
q = f"load-{wid}-{it}-{random.randint(0, 10**9)}"
|
||||||
|
url = sqs.create_queue(QueueName=q)["QueueUrl"]
|
||||||
|
try:
|
||||||
|
assert sqs.get_queue_url(QueueName=q)["QueueUrl"] == url
|
||||||
|
|
||||||
|
# сообщение произвольного размера, включая 256КБ и юникод
|
||||||
|
body = rand_body()
|
||||||
|
sqs.send_message(QueueUrl=url, MessageBody=body)
|
||||||
|
bump("valid_ok")
|
||||||
|
|
||||||
|
# batch до 10
|
||||||
|
entries = [{"Id": str(i), "MessageBody": f"b{i}-{random.randint(0, 10**6)}"} for i in range(random.randint(1, 10))]
|
||||||
|
r = sqs.send_message_batch(QueueUrl=url, Entries=entries)
|
||||||
|
assert len(r["Successful"]) == len(entries)
|
||||||
|
|
||||||
|
# receive с long poll, сверка целостности
|
||||||
|
msgs = None
|
||||||
|
for _ in range(6):
|
||||||
|
msgs = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=random.choice([1, 5, 20])).get("Messages", [])
|
||||||
|
if msgs:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
if not msgs:
|
||||||
|
raise AssertionError("сообщения не получены")
|
||||||
|
bodies = {m["Body"] for m in msgs}
|
||||||
|
if body not in bodies:
|
||||||
|
# тело могло уйти в другой receive предыдущего потока — считаем предупреждением
|
||||||
|
bump("soft_warn")
|
||||||
|
|
||||||
|
# change visibility + delete
|
||||||
|
sqs.change_message_visibility(QueueUrl=url, ReceiptHandle=msgs[0]["ReceiptHandle"], VisibilityTimeout=1)
|
||||||
|
sqs.delete_message(QueueUrl=url, ReceiptHandle=msgs[0]["ReceiptHandle"])
|
||||||
|
handles = [{"Id": str(i), "ReceiptHandle": m["ReceiptHandle"]} for i, m in enumerate(msgs[1:])]
|
||||||
|
if handles:
|
||||||
|
sqs.delete_message_batch(QueueUrl=url, Entries=handles)
|
||||||
|
|
||||||
|
# теги
|
||||||
|
sqs.tag_queue(QueueUrl=url, Tags={"load": "1"})
|
||||||
|
sqs.list_queue_tags(QueueUrl=url)
|
||||||
|
sqs.untag_queue(QueueUrl=url, TagKeys=["load"])
|
||||||
|
|
||||||
|
# атрибуты
|
||||||
|
attrs = sqs.get_queue_attributes(QueueUrl=url, AttributeNames=["All"])["Attributes"]
|
||||||
|
assert "VisibilityTimeout" in attrs
|
||||||
|
sqs.set_queue_attributes(QueueUrl=url, Attributes={"VisibilityTimeout": str(random.randint(0, 43200))})
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sqs.delete_queue(QueueUrl=url)
|
||||||
|
except ClientError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fifo_cycle(sqs, wid, it):
|
||||||
|
q = f"load-{wid}-{it}.fifo"
|
||||||
|
url = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
|
||||||
|
try:
|
||||||
|
sqs.send_message(QueueUrl=url, MessageBody="fifo", MessageGroupId="g", MessageDeduplicationId=str(it))
|
||||||
|
# дубль с тем же DedupId — сервис должен не создать дубль (допустим любой из ответов)
|
||||||
|
sqs.send_message(QueueUrl=url, MessageBody="fifo-dup", MessageGroupId="g", MessageDeduplicationId=str(it))
|
||||||
|
msgs = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10).get("Messages", [])
|
||||||
|
if msgs:
|
||||||
|
sqs.delete_message(QueueUrl=url, ReceiptHandle=msgs[0]["ReceiptHandle"])
|
||||||
|
bump("valid_ok")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sqs.delete_queue(QueueUrl=url)
|
||||||
|
except ClientError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_cases(sqs):
|
||||||
|
cases = []
|
||||||
|
# несуществующая очередь
|
||||||
|
cases.append(lambda: sqs.send_message(QueueUrl=ENDPOINT + "/no-such-queue", MessageBody="x"))
|
||||||
|
cases.append(lambda: sqs.receive_message(QueueUrl=ENDPOINT + "/no-such-queue"))
|
||||||
|
cases.append(lambda: sqs.delete_queue(QueueUrl=ENDPOINT + "/no-such-queue"))
|
||||||
|
cases.append(lambda: sqs.get_queue_url(QueueName="no-such-queue-load-test"))
|
||||||
|
# битый ReceiptHandle
|
||||||
|
cases.append(lambda: sqs.delete_message(QueueUrl=ENDPOINT + "/no-such-queue", ReceiptHandle="broken"))
|
||||||
|
# некорректные атрибуты
|
||||||
|
q = sqs.create_queue(QueueName=f"invalid-{random.randint(0, 10**9)}")["QueueUrl"]
|
||||||
|
try:
|
||||||
|
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"VisibilityTimeout": "43201"}))
|
||||||
|
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"VisibilityTimeout": "not-a-number"}))
|
||||||
|
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"BadAttribute": "1"}))
|
||||||
|
# повторный purge
|
||||||
|
def purge_twice():
|
||||||
|
sqs.purge_queue(QueueUrl=q)
|
||||||
|
sqs.purge_queue(QueueUrl=q)
|
||||||
|
cases.append(purge_twice)
|
||||||
|
# batch-нарушения
|
||||||
|
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[{"Id": str(i), "MessageBody": "x"} for i in range(11)]))
|
||||||
|
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[{"Id": "dup", "MessageBody": "a"}, {"Id": "dup", "MessageBody": "b"}]))
|
||||||
|
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[]))
|
||||||
|
# пустое тело
|
||||||
|
cases.append(lambda: sqs.send_message(QueueUrl=q, MessageBody=""))
|
||||||
|
# слишком длинное сообщение
|
||||||
|
cases.append(lambda: sqs.send_message(QueueUrl=q, MessageBody="x" * (300 * 1024)))
|
||||||
|
# FIFO без MessageGroupId
|
||||||
|
fq = sqs.create_queue(QueueName=f"invalid-{random.randint(0, 10**9)}.fifo", Attributes={"FifoQueue": "true"})["QueueUrl"]
|
||||||
|
try:
|
||||||
|
cases.append(lambda: sqs.send_message(QueueUrl=fq, MessageBody="x", MessageDeduplicationId="d"))
|
||||||
|
finally:
|
||||||
|
sqs.delete_queue(QueueUrl=fq)
|
||||||
|
finally:
|
||||||
|
sqs.delete_queue(QueueUrl=q)
|
||||||
|
|
||||||
|
for c in cases:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
c()
|
||||||
|
bump("soft_warn")
|
||||||
|
except ClientError as e:
|
||||||
|
add_latency(time.time() - t0)
|
||||||
|
code = e.response.get("Error", {}).get("Code", "Unknown")
|
||||||
|
if code in EXPECTED_CODES:
|
||||||
|
record_expected(e)
|
||||||
|
else:
|
||||||
|
record_unexpected(e)
|
||||||
|
except Exception as e:
|
||||||
|
record_unexpected(e)
|
||||||
|
|
||||||
|
|
||||||
|
def worker(wid):
|
||||||
|
sqs = make_client()
|
||||||
|
it = 0
|
||||||
|
while not stop.is_set():
|
||||||
|
it += 1
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
valid_cycle(sqs, wid, it)
|
||||||
|
add_latency(time.time() - t0)
|
||||||
|
except ClientError as e:
|
||||||
|
code = e.response.get("Error", {}).get("Code", "Unknown")
|
||||||
|
if code in EXPECTED_CODES:
|
||||||
|
record_expected(e)
|
||||||
|
else:
|
||||||
|
record_unexpected(e)
|
||||||
|
except Exception as e:
|
||||||
|
record_unexpected(e)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
fifo_cycle(sqs, wid, it)
|
||||||
|
add_latency(time.time() - t0)
|
||||||
|
except ClientError as e:
|
||||||
|
code = e.response.get("Error", {}).get("Code", "Unknown")
|
||||||
|
if code in EXPECTED_CODES:
|
||||||
|
record_expected(e)
|
||||||
|
else:
|
||||||
|
record_unexpected(e)
|
||||||
|
except Exception as e:
|
||||||
|
record_unexpected(e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
invalid_cases(sqs)
|
||||||
|
except Exception as e:
|
||||||
|
record_unexpected(e)
|
||||||
|
|
||||||
|
time.sleep(random.uniform(0.01, 0.05))
|
||||||
|
|
||||||
|
|
||||||
|
def progress_printer():
|
||||||
|
while not stop.is_set():
|
||||||
|
stop.wait(30)
|
||||||
|
with stats_lock:
|
||||||
|
s = dict(stats)
|
||||||
|
el = int(time.time() - start_ts)
|
||||||
|
print(f"[{el:>5}s] ops={s['ops_total']} ok={s['valid_ok']} "
|
||||||
|
f"expected_err={s['expected_err']} unexpected={s['unexpected_fail']} "
|
||||||
|
f"soft_warn={s['soft_warn']} health_fail={s['health_fail']}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Нагрузочный тест: {ENDPOINT}, длительность {DURATION}s, воркеров {WORKERS}", flush=True)
|
||||||
|
if not os.environ.get("AWS_ACCESS_KEY_ID") or not os.environ.get("AWS_SECRET_ACCESS_KEY"):
|
||||||
|
print("Нужны AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker, args=(i,), daemon=True) for i in range(WORKERS)]
|
||||||
|
hthread = threading.Thread(target=health_monitor, daemon=True)
|
||||||
|
pthread = threading.Thread(target=progress_printer, daemon=True)
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
hthread.start()
|
||||||
|
pthread.start()
|
||||||
|
|
||||||
|
stop.wait(DURATION)
|
||||||
|
stop.set()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=30)
|
||||||
|
|
||||||
|
el = int(time.time() - start_ts)
|
||||||
|
with stats_lock:
|
||||||
|
s = dict(stats)
|
||||||
|
print(f"\n=== Завершено через {el}s ===", flush=True)
|
||||||
|
print(f"Операций всего: {s['ops_total']}")
|
||||||
|
print(f"Корректных (ok): {s['valid_ok']}")
|
||||||
|
print(f"Ожидаемых отказов: {s['expected_err']}")
|
||||||
|
print(f"НЕОЖИДАННЫХ отказов: {s['unexpected_fail']}")
|
||||||
|
print(f"Некорректное прошло: {s['soft_warn']}")
|
||||||
|
print(f"Сбоев /health: {s['health_fail']}")
|
||||||
|
if s["err_by_code"]:
|
||||||
|
print("Коды ошибок:")
|
||||||
|
for code, n in sorted(s["err_by_code"].items(), key=lambda x: -x[1]):
|
||||||
|
print(f" {code}: {n}")
|
||||||
|
with LATENCY_LOCK:
|
||||||
|
if LATENCY:
|
||||||
|
print(f"Латентность операций: min={min(LATENCY):.3f}s avg={sum(LATENCY)/len(LATENCY):.3f}s max={max(LATENCY):.3f}s")
|
||||||
|
|
||||||
|
verdict = "УСТОЙЧИВО" if s["unexpected_fail"] == 0 and s["health_fail"] == 0 else "ЕСТЬ ПРОБЛЕМЫ"
|
||||||
|
print(f"Вердикт: {verdict}")
|
||||||
|
return 0 if verdict == "УСТОЙЧИВО" else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user