P1.1: per-queue блокировки вместо глобального лока на операции с очередью

Все операции с содержимым очереди (send/receive/delete/visibility/purge/attributes/
tags/admin/gauge/tick) переведены на per-queue RWMutex (QueueStore.GetLocked/
GetRLocked + queue.LockMu). Глобальный SyncQueues-лок остался только для map и
неизменяемых полей. DLQ-переносы берут лок DLQ отдельно (порядок main→DLQ).
marshalQueueMeta больше не мутирует очередь (DTO-снапшот). Устранён дедлок
create_queue (setQueueAttributesV1 вызывается вне глобального лока). Добавлены
конкурентные тесты; go test -race зелёный. Снимает боттлнек: синхронный Redis-I/O
больше не держит глобальный лок.
This commit is contained in:
Repinoid
2026-10-06 18:58:16 +03:00
parent ed17c6cbd0
commit fd8ffc329d
21 changed files with 252 additions and 181 deletions
+44 -24
View File
@@ -445,15 +445,23 @@ 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)
models.SyncQueues.RLock() type nameQueue struct {
for _, name := range models.SyncQueues.TenantQueueNames(t.AccessKey) { name string
q := models.SyncQueues.Queues[prefix+name] q *models.Queue
if q == nil {
continue
} }
models.SyncQueues.RLock()
pairs := make([]nameQueue, 0)
for _, name := range models.SyncQueues.TenantQueueNames(t.AccessKey) {
if q := models.SyncQueues.Queues[prefix+name]; q != nil {
pairs = append(pairs, nameQueue{name, q})
}
}
models.SyncQueues.RUnlock()
for _, p := range pairs {
p.q.RLockMu()
visible := 0 visible := 0
notVisible := 0 notVisible := 0
for _, msg := range q.Messages { for _, msg := range p.q.Messages {
if msg.ReceiptHandle != "" { if msg.ReceiptHandle != "" {
notVisible++ notVisible++
} else { } else {
@@ -461,15 +469,15 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
} }
} }
queues = append(queues, queueInfo{ queues = append(queues, queueInfo{
Name: name, Name: p.name,
Messages: visible, Messages: visible,
NotVisible: notVisible, NotVisible: notVisible,
VisibilityTimeout: q.VisibilityTimeout, VisibilityTimeout: p.q.VisibilityTimeout,
MaxMessageSize: q.MaximumMessageSize, MaxMessageSize: p.q.MaximumMessageSize,
RetentionPeriod: q.MessageRetentionPeriod, RetentionPeriod: p.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)
} }
@@ -594,7 +602,7 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
models.SyncQueues.RLock() q.RLockMu()
result := make([]peekMessageItem, 0, len(q.Messages)) result := make([]peekMessageItem, 0, len(q.Messages))
for i, msg := range q.Messages { for i, msg := range q.Messages {
if i >= limit { if i >= limit {
@@ -609,7 +617,7 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
InFlight: msg.ReceiptHandle != "", InFlight: msg.ReceiptHandle != "",
}) })
} }
models.SyncQueues.RUnlock() q.RUnlockMu()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(result)
} }
@@ -645,12 +653,12 @@ func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
Uuid: uuid.NewString(), Uuid: uuid.NewString(),
SentTime: time.Now(), SentTime: time.Now(),
} }
models.SyncQueues.Lock() q.LockMu()
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) q.Messages = append(q.Messages, msg)
// Персистим одно сообщение отдельно // Персистим одно сообщение отдельно
persistence.SaveMessage(key, &msg) persistence.SaveMessage(key, &msg)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, q)
models.SyncQueues.Unlock() 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"})
@@ -671,12 +679,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
} }
models.SyncQueues.Lock() q.LockMu()
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0] q.Messages = q.Messages[:0]
// Удаляем все сообщения из Redis одной командой // Удаляем все сообщения из Redis одной командой
persistence.PurgeMessagesPersist(key) persistence.PurgeMessagesPersist(key)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, q)
models.SyncQueues.Unlock() q.UnlockMu()
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)
} }
@@ -704,13 +712,19 @@ 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++
msgCount += len(q.Messages) uiQueues = append(uiQueues, q)
} }
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
for _, q := range uiQueues {
q.RLockMu()
msgCount += len(q.Messages)
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{
@@ -725,11 +739,17 @@ 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)
msgCount := 0 allQueues := make([]*models.Queue, 0, queueCount)
for _, q := range models.SyncQueues.Queues { for _, q := range models.SyncQueues.Queues {
msgCount += len(q.Messages) allQueues = append(allQueues, q)
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
msgCount := 0
for _, q := range allQueues {
q.RLockMu()
msgCount += len(q.Messages)
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{
+5 -10
View File
@@ -47,18 +47,11 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// ВСЯ работа с очередью — под одним Lock. queue := models.SyncQueues.GetLocked(key)
// Раньше проверка существования шла БЕЗ блокировки (чтение map) — if queue == nil {
// concurrent map read and map write при параллельном DeleteQueue
// (fatal error, под падал под нагрузкой).
// defer Unlock: при любой панике в секции мьютекс не останется захваченным.
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
messageFound := false messageFound := false
var changedMsgUuid string var changedMsgUuid string
@@ -76,7 +69,9 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
queue.DeadLetterQueue != nil && queue.DeadLetterQueue != nil &&
msgs[i].Retry >= queue.MaxReceiveCount { msgs[i].Retry >= queue.MaxReceiveCount {
changedMsgUuid = msgs[i].Uuid changedMsgUuid = msgs[i].Uuid
queue.DeadLetterQueue.LockMu()
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i]) queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i])
queue.DeadLetterQueue.UnlockMu()
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...) queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
msgRemoved = true msgRemoved = true
} else { } else {
+3 -7
View File
@@ -59,15 +59,11 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
ids[entry.Id] = true ids[entry.Id] = true
} }
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) 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)
+12 -9
View File
@@ -49,14 +49,9 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
queueUrl := tenantQueueURL(t, queueName) queueUrl := tenantQueueURL(t, queueName)
queueArn := tenantQueueARN(t, queueName) queueArn := tenantQueueARN(t, queueName)
models.SyncQueues.Lock() // Готовим очередь и применяем атрибуты ВНЕ глобального лока: setQueueAttributesV1
if _, exists := models.SyncQueues.Queues[key]; !exists { // берёт SyncQueues.RLock (поиск DLQ), а RWMutex не реентрантен — вызов под
// Проверка лимита очередей тенанта // уже захваченным 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,
@@ -72,9 +67,17 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
provided = utils.ExtractQueueAttributes(req.PostForm) provided = utils.ExtractQueueAttributes(req.PostForm)
} }
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil { if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1(err.Error(), true) return utils.CreateErrorResponseV1(err.Error(), true)
} }
models.SyncQueues.Lock()
if _, exists := models.SyncQueues.Queues[key]; !exists {
// Проверка лимита очередей тенанта
if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("LimitExceeded", true)
}
log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.AddQueue(key, queue) models.SyncQueues.AddQueue(key, queue)
} }
// Сохраняем очередь в Redis пока держим Lock — консистентный снапшот // Сохраняем очередь в Redis пока держим Lock — консистентный снапшот
+12 -11
View File
@@ -42,19 +42,23 @@ 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)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
if _, ok := models.SyncQueues.Queues[key]; ok { log.Warning("Queue not found")
for i, msg := range models.SyncQueues.Queues[key].Messages { return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
}
defer queue.UnlockMu()
for i, msg := range queue.Messages {
if msg.ReceiptHandle == receiptHandle { if msg.ReceiptHandle == receiptHandle {
msgUuid := msg.Uuid msgUuid := msg.Uuid
models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID) queue.UnlockGroup(msg.GroupID)
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...) queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID) delete(queue.Duplicates, msg.DeduplicationID)
// Удаляем одно сообщение из Redis — O(1) // Удаляем одно сообщение из Redis — O(1)
persistence.DeleteMessagePersist(key, msgUuid) persistence.DeleteMessagePersist(key, msgUuid)
// Метаданные очереди (duplicates, FIFO state) // Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, queue)
respStruct := models.DeleteMessageResponse{ respStruct := models.DeleteMessageResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata, Metadata: models.BaseResponseMetadata,
@@ -63,9 +67,6 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
} }
} }
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)
} }
+3 -7
View File
@@ -56,15 +56,11 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
ids[v.Id] = true ids[v.Id] = true
} }
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
deleteMessageMap := make(map[string]*deleteEntry) deleteMessageMap := make(map[string]*deleteEntry)
for _, entry := range requestBody.Entries { for _, entry := range requestBody.Entries {
+3 -4
View File
@@ -64,13 +64,12 @@ 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)
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
defer models.SyncQueues.RUnlock() if queue == nil {
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)})
+5 -3
View File
@@ -48,9 +48,9 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
// Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной // Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной
// очереди не мешает обработке остальных и HTTP-запросам. // очереди не мешает обработке остальных и HTTP-запросам.
for _, qi := range queues { for _, qi := range queues {
models.SyncQueues.Lock() qi.queue.LockMu()
processQueueTick(qi.key, qi.queue) processQueueTick(qi.key, qi.queue)
models.SyncQueues.Unlock() qi.queue.UnlockMu()
} }
case <-quit: case <-quit:
ticker.Stop() ticker.Stop()
@@ -61,7 +61,7 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
// processQueueTick — обработка ОДНОЙ очереди за один такт. // processQueueTick — обработка ОДНОЙ очереди за один такт.
// //
// ВЫЗЫВАТЬ ТОЛЬКО под удержанием SyncQueues.Lock. // ВЫЗЫВАТЬ под per-queue блокировкой этой очереди (queue.LockMu).
// //
// Логическая схема: // Логическая схема:
// 0. Эвикция сообщений по MessageRetentionPeriod (старше retention удаляются). // 0. Эвикция сообщений по MessageRetentionPeriod (старше retention удаляются).
@@ -129,7 +129,9 @@ func processQueueTick(key string, queue *models.Queue) {
if queue.MaxReceiveCount > 0 && if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil && queue.DeadLetterQueue != nil &&
msg.Retry >= queue.MaxReceiveCount { msg.Retry >= queue.MaxReceiveCount {
queue.DeadLetterQueue.LockMu()
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg) queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
queue.DeadLetterQueue.UnlockMu()
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...) queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
i-- // элемент на позиции i удалён — повторно проверяем новый элемент на ней i-- // элемент на позиции i удалён — повторно проверяем новый элемент на ней
} }
+3 -5
View File
@@ -40,13 +40,11 @@ func ListQueueTagsV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
defer models.SyncQueues.RUnlock() if queue == nil {
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 {
+9 -9
View File
@@ -45,29 +45,29 @@ 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)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
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 секунд на очередь. // AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь.
// Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает. // Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает.
if time.Since(models.SyncQueues.Queues[key].LastPurgeTime) < 60*time.Second { if time.Since(queue.LastPurgeTime) < 60*time.Second {
log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, models.SyncQueues.Queues[key].LastPurgeTime.Format(time.RFC3339)) log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, queue.LastPurgeTime.Format(time.RFC3339))
return utils.CreateErrorResponseV1("PurgeQueueInProgress", true) 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 queue.Messages = nil
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time) queue.Duplicates = make(map[string]time.Time)
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд. // Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
models.SyncQueues.Queues[key].LastPurgeTime = time.Now() queue.LastPurgeTime = time.Now()
// Удаляем все сообщения из Redis одной командой DEL // Удаляем все сообщения из Redis одной командой DEL
persistence.PurgeMessagesPersist(key) persistence.PurgeMessagesPersist(key)
// Сохраняем пустые метаданные очереди // Сохраняем пустые метаданные очереди
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, queue)
respStruct := models.PurgeQueueResponse{ respStruct := models.PurgeQueueResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
+2
View File
@@ -75,7 +75,9 @@ func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided
queueName := arnArray[len(arnArray)-1] queueName := arnArray[len(arnArray)-1]
// DLQ ищем по tenant-scoped ключу — как хранятся все очереди. // DLQ ищем по tenant-scoped ключу — как хранятся все очереди.
dlqKey := tenantAccessKey + ":" + queueName dlqKey := tenantAccessKey + ":" + queueName
models.SyncQueues.RLock()
deadLetterQueue, ok := models.SyncQueues.Queues[dlqKey] 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")
+6 -10
View File
@@ -86,17 +86,15 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
// ОДИН RLock на проверку существования И чтение атрибута очереди: // ОДИН RLock на проверку существования И чтение атрибута очереди:
// раньше было два отдельных RLock — между ними очередь могла быть удалена // раньше было два отдельных RLock — между ними очередь могла быть удалена
// (nil-deref при чтении ReceiveMessageWaitTimeSeconds). // (nil-deref при чтении ReceiveMessageWaitTimeSeconds).
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
queue, queueExists := models.SyncQueues.Queues[key] if queue == nil {
if !queueExists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
waitTimeSeconds := requestBody.WaitTimeSeconds waitTimeSeconds := requestBody.WaitTimeSeconds
if waitTimeSeconds == 0 { if waitTimeSeconds == 0 {
waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds
} }
models.SyncQueues.RUnlock() queue.RUnlockMu()
if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds { if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds {
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
@@ -155,13 +153,11 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
// (ApproximateReceiveCount в ответе); // (ApproximateReceiveCount в ответе);
// - для FIFO-очереди блокируется группа (LockGroup) до DeleteMessage/таймаута. // - для FIFO-очереди блокируется группа (LockGroup) до DeleteMessage/таймаута.
func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages int) ([]*models.ResultMessage, bool) { func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages int) ([]*models.ResultMessage, bool) {
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil || len(queue.Messages) == 0 {
queue, queueFound := models.SyncQueues.Queues[key]
if !queueFound || len(queue.Messages) == 0 {
return nil, false return nil, false
} }
defer queue.UnlockMu()
// Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение. // Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
if !queueHasReceivableMessages(queue) { if !queueHasReceivableMessages(queue) {
return nil, false return nil, false
+6 -17
View File
@@ -83,17 +83,16 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.RLock() queue := models.SyncQueues.GetLocked(key)
queue, exists := models.SyncQueues.Queues[key] if queue == nil {
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 := len(queue.Messages) currentMsgCount := len(queue.Messages)
queueIsFIFO := queue.IsFIFO queueIsFIFO := queue.IsFIFO
delaySecs := queue.DelaySeconds delaySecs := queue.DelaySeconds
models.SyncQueues.RUnlock()
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой // FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы). // отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
@@ -128,18 +127,9 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
msg.SentTime = time.Now() msg.SentTime = time.Now()
msg.DelaySecs = delaySecs msg.DelaySecs = delaySecs
models.SyncQueues.Lock() // Повторная проверка лимита под per-queue Lock (между снимком выше и текущим
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой // моментом другие горутины могли добавить сообщения — мягкий OOM-лимит).
// выше и захватом Lock (иначе nil-pointer deref).
queue = models.SyncQueues.Queues[key]
if queue == nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
// Повторная проверка лимита под Lock: между RLock-снимком и захватом Lock
// другие горутины могли добавить сообщения (мягкий OOM-лимит).
if len(queue.Messages) >= MaxMessagesForQueue(queue.IsFIFO) { if len(queue.Messages) >= MaxMessagesForQueue(queue.IsFIFO) {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("OverLimit", true) return utils.CreateErrorResponseV1("OverLimit", true)
} }
@@ -167,7 +157,6 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
queue.InitDuplicatation(messageDeduplicationID) queue.InitDuplicatation(messageDeduplicationID)
// Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages // Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock()
if persistErr { if persistErr {
return utils.CreateErrorResponseV1("InternalError", true) return utils.CreateErrorResponseV1("InternalError", true)
+5 -14
View File
@@ -43,16 +43,14 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
queue, exists := models.SyncQueues.Queues[key] if queue == nil {
if !exists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
maxMsgSize := queue.MaximumMessageSize maxMsgSize := queue.MaximumMessageSize
currentMsgCount := len(queue.Messages) currentMsgCount := len(queue.Messages)
queueIsFIFO := queue.IsFIFO queueIsFIFO := queue.IsFIFO
models.SyncQueues.RUnlock() queue.RUnlockMu()
sendEntries := requestBody.Entries sendEntries := requestBody.Entries
@@ -104,14 +102,8 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
return utils.CreateErrorResponseV1("OverLimit", true) return utils.CreateErrorResponseV1("OverLimit", true)
} }
models.SyncQueues.Lock() queue.LockMu()
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой defer queue.UnlockMu()
// выше и захватом Lock (иначе nil-pointer deref).
queue = models.SyncQueues.Queues[key]
if queue == nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
newMsgs := make([]models.SqsMessage, 0, len(sendEntries)) newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
for _, sendEntry := range sendEntries { for _, sendEntry := range sendEntries {
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody} msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
@@ -151,7 +143,6 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
persistence.SaveMessages(key, newMsgs) persistence.SaveMessages(key, newMsgs)
// Метаданные очереди (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,
+3 -4
View File
@@ -45,13 +45,12 @@ 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)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
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()
// Список имён переданных атрибутов — для whitelist-валидации. // Список имён переданных атрибутов — для whitelist-валидации.
// Query-протокол: имена видны в form (Attribute.N.Name). // Query-протокол: имена видны в form (Attribute.N.Name).
// JSON-протокол: структура уже разобрана из тела, список имён недоступен → nil // JSON-протокол: структура уже разобрана из тела, список имён недоступен → nil
+3 -5
View File
@@ -41,13 +41,11 @@ func TagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
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 {
+3 -5
View File
@@ -41,13 +41,11 @@ func UntagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
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 {
+10 -3
View File
@@ -37,18 +37,25 @@ func StartGaugeUpdater(interval time.Duration, quit <-chan bool) {
// updateGauges — пересчитывает sqs_queues_count и sqs_messages_count per tenant. // updateGauges — пересчитывает sqs_queues_count и sqs_messages_count per tenant.
// Формат ключа очереди: "{accessKey}:{queueName}" — берём часть до первого ":". // Формат ключа очереди: "{accessKey}:{queueName}" — берём часть до первого ":".
func updateGauges() { func updateGauges() {
// Собираем данные под RLock — не блокируем запись // Снимок указателей под коротким RLock, затем содержимое — под per-queue RLock
models.SyncQueues.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) tenantQueues := make(map[string]int)
tenantMessages := make(map[string]int) tenantMessages := make(map[string]int)
for key, queue := range models.SyncQueues.Queues { for key, queue := range snapshot {
// Ключ формата "accessKey:queueName" — tenant определяется по accessKey // Ключ формата "accessKey:queueName" — tenant определяется по accessKey
parts := strings.SplitN(key, ":", 2) parts := strings.SplitN(key, ":", 2)
tenantKey := parts[0] tenantKey := parts[0]
tenantQueues[tenantKey]++ tenantQueues[tenantKey]++
queue.RLockMu()
tenantMessages[tenantKey] += len(queue.Messages) tenantMessages[tenantKey] += len(queue.Messages)
queue.RUnlockMu()
} }
models.SyncQueues.RUnlock()
// Сбрасываем старые значения и записываем новые // Сбрасываем старые значения и записываем новые
QueuesGauge.Reset() QueuesGauge.Reset()
+25
View File
@@ -74,6 +74,31 @@ func (s *QueueStore) RemoveQueue(key string) {
} }
} }
// 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). // CountTenantQueues — число очередей тенанта (по accessKey) за O(1).
// Вызывать под SyncQueues.RLock() или Lock(). // Вызывать под SyncQueues.RLock() или Lock().
func (s *QueueStore) CountTenantQueues(accessKey string) int { func (s *QueueStore) CountTenantQueues(accessKey string) int {
+58 -16
View File
@@ -142,23 +142,65 @@ func asyncWrite(fn func()) {
}() }()
} }
// marshalQueueMeta — сериализует очередь БЕЗ Messages (и без Messages в DLQ). // queueMetaDTO — сериализуемая копия очереди БЕЗ Messages (и без сообщений DLQ).
// DeadLetterQueue сохраняется как ссылка (Name/URL/Arn), но без сообщений. // Отдельный тип, т.к. models.Queue содержит sync.RWMutex и его нельзя копировать/
// ВАЖНО: вызывать под SyncQueues.Lock() — временно nil'ит Messages. // сериализовать целиком, а временное зануление 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
DeadLetterQueue *queueMetaDTO
}
// queueToMetaDTO — строит снимок очереди без сообщений (и без сообщений DLQ),
// НЕ мутируя исходную очередь. Рекурсия ограничена глубиной 1.
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,
}
if q.DeadLetterQueue != nil {
dto.DeadLetterQueue = queueToMetaDTO(q.DeadLetterQueue)
}
return dto
}
// marshalQueueMeta — сериализует метаданные очереди БЕЗ сообщений,
// НЕ мутируя исходную очередь (см. queueMetaDTO). Безопасно вызывать
// под per-queue блокировкой только этой очереди.
func marshalQueueMeta(queue *models.Queue) ([]byte, error) { func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
savedMsgs := queue.Messages return json.Marshal(queueToMetaDTO(queue))
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 асинхронно.
@@ -47,6 +47,20 @@ DLQ), а не построчная замена. Делать её «вслеп
рискованно: ошибка = дедлоки/потеря данных на проде. Рекомендуется отдельный рискованно: ошибка = дедлоки/потеря данных на проде. Рекомендуется отдельный
проход: сначала тесты на FIFO/DLQ/batch, затем атомарный перевод. проход: сначала тесты на FIFO/DLQ/batch, затем атомарный перевод.
## СТАТУС: РЕАЛИЗОВАНО (2026-10-06)
Перевод выполнен атомарно. Что сделано:
- `marshalQueueMeta` больше НЕ мутирует очередь (DTO-снапшот queueMetaDTO).
- Все операции с содержимым очереди используют per-queue лок
(`QueueStore.GetLocked/GetRLocked` + `queue.LockMu/RUnlockMu`) в gosqs, admin,
metrics/gauge_updater, PeriodicTasks.
- Глобальный `SyncQueues`-лок остался ТОЛЬКО для доступа к map и неизменяемых
полей (create/delete/list/find/snapshot).
- DLQ-переносы берут лок DLQ отдельно (порядок main→DLQ).
- Устранён дедлок в create_queue (setQueueAttributesV1 вызывается ВНЕ глобального лока).
- Добавлены конкурентные тесты (два тенанта / одна очередь), прогон `go test -race` — зелёный.
## P1.2 (индексация сообщений) — та же природа ## P1.2 (индексация сообщений) — та же природа
Индексация `Messages` (receipt→index) меняет структуру хранения, затронутую теми Индексация `Messages` (receipt→index) меняет структуру хранения, затронутую теми