feat: persist messages separately and update ingress docs

This commit is contained in:
Naeel
2026-04-12 08:44:46 +03:00
parent d5d3519836
commit 4a83575491
11 changed files with 359 additions and 104 deletions
+4
View File
@@ -575,6 +575,8 @@ func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
} }
models.SyncQueues.Lock() models.SyncQueues.Lock()
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
// Персистим одно сообщение отдельно
persistence.SaveMessage(key, &msg)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -599,6 +601,8 @@ func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
} }
models.SyncQueues.Lock() models.SyncQueues.Lock()
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0] models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0]
// Удаляем все сообщения из Redis одной командой
persistence.PurgeMessagesPersist(key)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID) log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
+20 -1
View File
@@ -53,6 +53,8 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
models.SyncQueues.Lock() models.SyncQueues.Lock()
messageFound := false messageFound := false
var changedMsgUuid string
var msgRemoved bool
for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ { for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ {
queue := models.SyncQueues.Queues[key] queue := models.SyncQueues.Queues[key]
msgs := queue.Messages msgs := queue.Messages
@@ -66,18 +68,35 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
if queue.MaxReceiveCount > 0 && if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil && queue.DeadLetterQueue != nil &&
msgs[i].Retry >= queue.MaxReceiveCount { msgs[i].Retry >= queue.MaxReceiveCount {
changedMsgUuid = msgs[i].Uuid
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i]) queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i])
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...) queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
msgRemoved = true
} else {
changedMsgUuid = msgs[i].Uuid
} }
} else { } else {
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second) msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
changedMsgUuid = msgs[i].Uuid
} }
messageFound = true messageFound = true
break break
} }
} }
// Персистим изменение в Redis под Lock // Персистим изменения в Redis под Lock
if messageFound { if messageFound {
if msgRemoved {
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis
persistence.DeleteMessagePersist(key, changedMsgUuid)
} else {
// Сообщение обновлено — перезаписываем в Redis
for i := range models.SyncQueues.Queues[key].Messages {
if models.SyncQueues.Queues[key].Messages[i].Uuid == changedMsgUuid {
persistence.SaveMessage(key, &models.SyncQueues.Queues[key].Messages[i])
break
}
}
}
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
} }
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
@@ -93,6 +93,8 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
} else { } else {
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second) queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second)
} }
// Персистим изменённое сообщение отдельно
persistence.SaveMessage(key, &queue.Messages[i])
messageFound = true messageFound = true
break break
} }
+4 -1
View File
@@ -47,10 +47,13 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
if _, ok := models.SyncQueues.Queues[key]; ok { if _, ok := models.SyncQueues.Queues[key]; ok {
for i, msg := range models.SyncQueues.Queues[key].Messages { for i, msg := range models.SyncQueues.Queues[key].Messages {
if msg.ReceiptHandle == receiptHandle { if msg.ReceiptHandle == receiptHandle {
msgUuid := msg.Uuid
models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID) models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID)
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...) models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...)
delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID) delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID)
// Сохраняем очередь в Redis пока держим Lock // Удаляем одно сообщение из Redis — O(1)
persistence.DeleteMessagePersist(key, msgUuid)
// Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.DeleteMessageResponse{ respStruct := models.DeleteMessageResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
+5 -1
View File
@@ -73,6 +73,7 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
} }
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0) deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
deletedUuids := make([]string, 0)
remainingMessages := make([]models.SqsMessage, 0, len(models.SyncQueues.Queues[key].Messages)) remainingMessages := make([]models.SqsMessage, 0, len(models.SyncQueues.Queues[key].Messages))
for _, message := range models.SyncQueues.Queues[key].Messages { for _, message := range models.SyncQueues.Queues[key].Messages {
@@ -82,13 +83,16 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID) delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID)
de.Deleted = true de.Deleted = true
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id}) deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
deletedUuids = append(deletedUuids, message.Uuid)
} else { } else {
remainingMessages = append(remainingMessages, message) remainingMessages = append(remainingMessages, message)
} }
} }
models.SyncQueues.Queues[key].Messages = remainingMessages models.SyncQueues.Queues[key].Messages = remainingMessages
// Персистим обновлённое состояние очереди, чтобы не терять batch-delete после рестарта. // Удаляем сообщения из Redis отдельно — O(batch_size)
persistence.DeleteMessagesPersist(key, deletedUuids)
// Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
notFoundEntries := make([]models.BatchResultErrorEntry, 0) notFoundEntries := make([]models.BatchResultErrorEntry, 0)
+79 -78
View File
@@ -3,35 +3,36 @@
package gosqs package gosqs
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"shared-sqs/app/interfaces" "shared-sqs/app/interfaces"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/utils" "shared-sqs/app/utils"
log "github.com/sirupsen/logrus"
log "github.com/sirupsen/logrus"
) )
func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) { func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewGetQueueAttributesRequest() requestBody := models.NewGetQueueAttributesRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
if !ok { if !ok {
log.Error("Invalid Request - GetQueueAttributesV1") log.Error("Invalid Request - GetQueueAttributesV1")
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
if requestBody.QueueUrl == "" { if requestBody.QueueUrl == "" {
log.Error("Missing QueueUrl - GetQueueAttributesV1") log.Error("Missing QueueUrl - GetQueueAttributesV1")
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
t := getTenantFromContext(req) t := getTenantFromContext(req)
if t == nil { if t == nil {
return utils.CreateErrorResponseV1("InvalidClientTokenId", true) return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
} }
// Определяем набор запрошенных атрибутов (или All) // Определяем набор запрошенных атрибутов (или All)
requestedAttributes := func() map[string]bool { requestedAttributes := func() map[string]bool {
attrs := map[string]bool{} attrs := map[string]bool{}
if len(requestBody.AttributeNames) == 0 { if len(requestBody.AttributeNames) == 0 {
@@ -54,64 +55,64 @@ return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
} }
_, ok := requestedAttributes[attr] _, ok := requestedAttributes[attr]
return ok return ok
} }
uriSegments := strings.Split(requestBody.QueueUrl, "/") uriSegments := strings.Split(requestBody.QueueUrl, "/")
queueName := uriSegments[len(uriSegments)-1] queueName := uriSegments[len(uriSegments)-1]
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
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() models.SyncQueues.RLock()
defer models.SyncQueues.RUnlock() defer models.SyncQueues.RUnlock()
queue, ok := models.SyncQueues.Queues[key] queue, ok := models.SyncQueues.Queues[key]
if !ok { 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)
} }
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)})
} }
if shouldInclude("MaximumMessageSize") { if shouldInclude("MaximumMessageSize") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "MaximumMessageSize", Value: strconv.Itoa(queue.MaximumMessageSize)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "MaximumMessageSize", Value: strconv.Itoa(queue.MaximumMessageSize)})
} }
if shouldInclude("MessageRetentionPeriod") { if shouldInclude("MessageRetentionPeriod") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "MessageRetentionPeriod", Value: strconv.Itoa(queue.MessageRetentionPeriod)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "MessageRetentionPeriod", Value: strconv.Itoa(queue.MessageRetentionPeriod)})
} }
if shouldInclude("ReceiveMessageWaitTimeSeconds") { if shouldInclude("ReceiveMessageWaitTimeSeconds") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ReceiveMessageWaitTimeSeconds", Value: strconv.Itoa(queue.ReceiveMessageWaitTimeSeconds)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "ReceiveMessageWaitTimeSeconds", Value: strconv.Itoa(queue.ReceiveMessageWaitTimeSeconds)})
} }
if shouldInclude("VisibilityTimeout") { if shouldInclude("VisibilityTimeout") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)})
} }
if shouldInclude("ApproximateNumberOfMessages") { if shouldInclude("ApproximateNumberOfMessages") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))}) queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))})
} }
if shouldInclude("ApproximateNumberOfMessagesNotVisible") { if shouldInclude("ApproximateNumberOfMessagesNotVisible") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(*queue))}) queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(*queue))})
} }
if shouldInclude("CreatedTimestamp") { if shouldInclude("CreatedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"}) queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"})
} }
if shouldInclude("LastModifiedTimestamp") { if shouldInclude("LastModifiedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"}) queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"})
} }
if shouldInclude("QueueArn") { if shouldInclude("QueueArn") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn}) queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn})
} }
if shouldInclude("RedrivePolicy") && queue.DeadLetterQueue != nil { if shouldInclude("RedrivePolicy") && queue.DeadLetterQueue != nil {
queueAttributes = append(queueAttributes, models.Attribute{ queueAttributes = append(queueAttributes, models.Attribute{
Name: "RedrivePolicy", Name: "RedrivePolicy",
Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn), Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn),
}) })
} }
respStruct := models.GetQueueAttributesResponse{ respStruct := models.GetQueueAttributesResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
Result: models.GetQueueAttributesResult{Attrs: queueAttributes}, Result: models.GetQueueAttributesResult{Attrs: queueAttributes},
Metadata: models.BaseResponseMetadata, Metadata: models.BaseResponseMetadata,
} }
return http.StatusOK, respStruct return http.StatusOK, respStruct
} }
+3 -1
View File
@@ -42,7 +42,9 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
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)
// Сохраняем пустую очередь в Redis пока держим Lock // Удаляем все сообщения из Redis одной командой DEL
persistence.PurgeMessagesPersist(key)
// Сохраняем пустые метаданные очереди
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.PurgeQueueResponse{ respStruct := models.PurgeQueueResponse{
+3 -1
View File
@@ -112,12 +112,14 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
if !models.SyncQueues.Queues[key].IsDuplicate(messageDeduplicationID) { if !models.SyncQueues.Queues[key].IsDuplicate(messageDeduplicationID) {
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
// Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size)
persistence.SaveMessage(key, &msg)
} else { } else {
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName) log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
} }
models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID) models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID)
// Сохраняем очередь в Redis пока держим Lock // Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
// Логируем только метаданные — тело сообщения не логируется (perf + security) // Логируем только метаданные — тело сообщения не логируется (perf + security)
+5 -1
View File
@@ -106,6 +106,7 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
models.SyncQueues.Lock() models.SyncQueues.Lock()
queue = models.SyncQueues.Queues[key] queue = models.SyncQueues.Queues[key]
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}
if len(sendEntry.MessageAttributes) > 0 { if len(sendEntry.MessageAttributes) > 0 {
@@ -136,10 +137,13 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
MD5OfMessageAttributes: msg.MD5OfMessageAttributes, MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
SequenceNumber: fifoSeqNumber, SequenceNumber: fifoSeqNumber,
}) })
newMsgs = append(newMsgs, msg)
// Логируем только метаданные — тело сообщения не логируется (perf + security) // Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody)) log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody))
} }
// Персистим батч-изменение одним снапшотом под lock. // Персистим новые сообщения отдельно — O(batch_size*msg_size) вместо O(N*msg_size)
persistence.SaveMessages(key, newMsgs)
// Метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
+226 -19
View File
@@ -4,7 +4,13 @@
// Redis — источник правды для восстановления после рестарта. // Redis — источник правды для восстановления после рестарта.
// Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции. // Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции.
// Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот. // Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот.
// Created: 2026-04-10 //
// Схема хранения v2 (2026-04-11):
// ssq:queues (HASH) — queueKey → JSON(метаданные очереди без Messages)
// ssq:msg:{queueKey} (HASH) — uuid → JSON(SqsMessage)
// Это позволяет O(1) на каждое сообщение вместо O(N×msg_size) при SaveQueue.
// Миграция со старого формата (Messages внутри ssq:queues) — автоматическая при LoadAllQueues.
// Created: 2026-04-10 | Modified: 2026-04-11
package persistence package persistence
@@ -34,8 +40,10 @@ var (
const ( const (
// redisHashTenants — HASH: tenantID → JSON тенанта // redisHashTenants — HASH: tenantID → JSON тенанта
redisHashTenants = "ssq:tenants" redisHashTenants = "ssq:tenants"
// redisHashQueues — HASH: queueKey → JSON очереди (включая сообщения) // redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2)
redisHashQueues = "ssq:queues" redisHashQueues = "ssq:queues"
// redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage)
redisMsgHashPrefix = "ssq:msg:"
) )
// Connect — подключается к Redis и проверяет ping. // Connect — подключается к Redis и проверяет ping.
@@ -74,22 +82,37 @@ func asyncWrite(fn func()) {
}() }()
} }
// SaveQueue — сохраняет очередь (с сообщениями) в Redis асинхронно. // marshalQueueMeta — сериализует очередь БЕЗ Messages (и без Messages в DLQ).
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда json.Marshal // DeadLetterQueue сохраняется как ссылка (Name/URL/Arn), но без сообщений.
// ВАЖНО: вызывать под SyncQueues.Lock() — временно nil'ит Messages.
func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
savedMsgs := queue.Messages
queue.Messages = nil
var savedDLQMsgs []models.SqsMessage
if queue.DeadLetterQueue != nil {
savedDLQMsgs = queue.DeadLetterQueue.Messages
queue.DeadLetterQueue.Messages = nil
}
data, err := json.Marshal(queue)
queue.Messages = savedMsgs
if queue.DeadLetterQueue != nil {
queue.DeadLetterQueue.Messages = savedDLQMsgs
}
return data, err
}
// SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно.
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда marshalQueueMeta
// создаёт консистентный снапшот. Горутина только делает сетевой вызов. // создаёт консистентный снапшот. Горутина только делает сетевой вызов.
// Для сохранения сообщений используй SaveMessage/SaveMessages.
func SaveQueue(key string, queue *models.Queue) { func SaveQueue(key string, queue *models.Queue) {
if Client == nil { if Client == nil {
return return
} }
// Сериализуем синхронно под мьютексом вызывающего → консистентный снапшот // Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот
data, err := json.Marshal(queue) data, err := marshalQueueMeta(queue)
if err != nil { if err != nil {
log.Errorf("persistence: marshal queue %q: %v", key, err) log.Errorf("persistence: marshal queue meta %q: %v", key, err)
return
}
// Fix #4.3: Redis size guard — не сохранять если > 50MB (OOM protection)
if len(data) > 50*1024*1024 {
log.Warnf("persistence: queue %q too large for Redis (%d bytes), skipping", key, len(data))
return return
} }
writeSeq := nextQueueWriteSeq(key) writeSeq := nextQueueWriteSeq(key)
@@ -100,42 +123,162 @@ func SaveQueue(key string, queue *models.Queue) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil { if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet queue %q: %v", key, err) log.Errorf("persistence: HSet queue meta %q: %v", key, err)
} }
}) })
} }
// DeleteQueue — удаляет очередь из Redis асинхронно. // SaveMessage — сохраняет ОДНО сообщение в Redis асинхронно.
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
// Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения.
func SaveMessage(queueKey string, msg *models.SqsMessage) {
if Client == nil {
return
}
data, err := json.Marshal(msg)
if err != nil {
log.Errorf("persistence: marshal message %s/%s: %v", queueKey, msg.Uuid, err)
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet message %s/%s: %v", queueKey, msg.Uuid, err)
}
})
}
// SaveMessages — сохраняет несколько сообщений в Redis одним pipeline.
// Вызывать под Lock.
func SaveMessages(queueKey string, msgs []models.SqsMessage) {
if Client == nil || len(msgs) == 0 {
return
}
// Сериализуем все сообщения синхронно под Lock
fields := make(map[string]string, len(msgs))
for i := range msgs {
data, err := json.Marshal(&msgs[i])
if err != nil {
log.Errorf("persistence: marshal message %s/%s: %v", queueKey, msgs[i].Uuid, err)
continue
}
fields[msgs[i].Uuid] = string(data)
}
if len(fields) == 0 {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Конвертируем map[string]string → []interface{} для HSet
args := make([]interface{}, 0, len(fields)*2)
for k, v := range fields {
args = append(args, k, v)
}
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err)
}
})
}
// DeleteMessagePersist — удаляет одно сообщение из Redis асинхронно.
// Вызывать при DeleteMessage (после удаления из in-memory).
func DeleteMessagePersist(queueKey string, msgUuid string) {
if Client == nil || msgUuid == "" {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil {
log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err)
}
})
}
// DeleteMessagesPersist — удаляет несколько сообщений из Redis.
// Вызывать при DeleteMessageBatch.
func DeleteMessagesPersist(queueKey string, uuids []string) {
if Client == nil || len(uuids) == 0 {
return
}
hashKey := redisMsgHashPrefix + queueKey
// Копируем uuids — вызывающий может переиспользовать slice
ids := make([]string, len(uuids))
copy(ids, uuids)
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, ids...).Err(); err != nil {
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(ids), err)
}
})
}
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis (для PurgeQueue).
// Удаляет весь HASH ssq:msg:{queueKey}.
func PurgeMessagesPersist(queueKey string) {
if Client == nil {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.Del(ctx, hashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
}
})
}
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
func DeleteQueue(key string) { func DeleteQueue(key string) {
if Client == nil { if Client == nil {
return return
} }
writeSeq := nextQueueWriteSeq(key) writeSeq := nextQueueWriteSeq(key)
msgHashKey := redisMsgHashPrefix + key
asyncWrite(func() { asyncWrite(func() {
if !isLatestQueueWriteSeq(key, writeSeq) { if !isLatestQueueWriteSeq(key, writeSeq) {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
// Удаляем метаданные из общего HASH
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil { if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
log.Errorf("persistence: HDel queue %q: %v", key, err) log.Errorf("persistence: HDel queue meta %q: %v", key, err)
}
// Удаляем весь HASH с сообщениями
if err := Client.Del(ctx, msgHashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %q: %v", key, err)
} }
}) })
} }
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса. // LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
// Инициализирует nil-maps чтобы избежать panic при deduplication/FIFO операциях. // Схема v2: метаданные из ssq:queues, сообщения из ssq:msg:{key}.
// Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages,
// они извлекаются в отдельный HASH и метаданные пересохраняются без Messages.
func LoadAllQueues() (map[string]*models.Queue, error) { func LoadAllQueues() (map[string]*models.Queue, error) {
if Client == nil { if Client == nil {
return nil, nil return nil, nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
// 1. Загружаем метаданные очередей
raw, err := Client.HGetAll(ctx, redisHashQueues).Result() raw, err := Client.HGetAll(ctx, redisHashQueues).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("redis HGetAll queues: %w", err) return nil, fmt.Errorf("redis HGetAll queues: %w", err)
} }
queues := make(map[string]*models.Queue, len(raw)) queues := make(map[string]*models.Queue, len(raw))
migrateKeys := make([]string, 0) // ключи для миграции v1→v2
for k, v := range raw { for k, v := range raw {
var q models.Queue var q models.Queue
if err := json.Unmarshal([]byte(v), &q); err != nil { if err := json.Unmarshal([]byte(v), &q); err != nil {
@@ -152,9 +295,73 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
if q.FIFOSequenceNumbers == nil { if q.FIFOSequenceNumbers == nil {
q.FIFOSequenceNumbers = make(map[string]int) q.FIFOSequenceNumbers = make(map[string]int)
} }
// Миграция v1→v2: если в JSON есть Messages — это старый формат
if len(q.Messages) > 0 {
migrateKeys = append(migrateKeys, k)
log.Infof("persistence: миграция v1→v2 для %q (%d сообщений)", k, len(q.Messages))
}
queues[k] = &q queues[k] = &q
} }
log.Infof("persistence: загружено %d очередей из Redis", len(queues))
// 2. Миграция: выносим Messages из старого формата в отдельные HASH'ы
for _, k := range migrateKeys {
q := queues[k]
hashKey := redisMsgHashPrefix + k
// Сохраняем каждое сообщение отдельно
if len(q.Messages) > 0 {
args := make([]interface{}, 0, len(q.Messages)*2)
for i := range q.Messages {
data, err := json.Marshal(&q.Messages[i])
if err != nil {
log.Errorf("persistence: migrate marshal msg %s/%s: %v", k, q.Messages[i].Uuid, err)
continue
}
args = append(args, q.Messages[i].Uuid, string(data))
}
if len(args) > 0 {
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: migrate HSet messages %q: %v", k, err)
}
}
}
// Пересохраняем метаданные без Messages
metaData, err := marshalQueueMeta(q)
if err == nil {
if err := Client.HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil {
log.Errorf("persistence: migrate HSet meta %q: %v", k, err)
}
}
log.Infof("persistence: миграция %q завершена — %d сообщений вынесены в %s", k, len(q.Messages), hashKey)
}
// 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди
for k, q := range queues {
hashKey := redisMsgHashPrefix + k
msgRaw, err := Client.HGetAll(ctx, hashKey).Result()
if err != nil {
log.Errorf("persistence: HGetAll messages %q: %v", k, err)
continue
}
if len(msgRaw) > 0 {
msgs := make([]models.SqsMessage, 0, len(msgRaw))
for uuid, v := range msgRaw {
var m models.SqsMessage
if err := json.Unmarshal([]byte(v), &m); err != nil {
log.Errorf("persistence: unmarshal message %s/%s: %v", k, uuid, err)
continue
}
msgs = append(msgs, m)
}
q.Messages = msgs
} else if len(q.Messages) == 0 {
// Пустая очередь — Messages уже nil, оставляем
q.Messages = nil
}
}
log.Infof("persistence: загружено %d очередей из Redis (миграций: %d)", len(queues), len(migrateKeys))
return queues, nil return queues, nil
} }
+8 -1
View File
@@ -1,6 +1,6 @@
# deployments/k8s/ingress.yaml # deployments/k8s/ingress.yaml
# Ingress для shared-sqs на домене qu.kube5s.ru # Ingress для shared-sqs на домене qu.kube5s.ru
# Created: 2026-04-09, Updated: 2026-04-10 # Created: 2026-04-09, Updated: 2026-04-11
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
@@ -11,6 +11,13 @@ metadata:
nginx.ingress.kubernetes.io/proxy-body-size: 10m nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "30" nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
nginx.ingress.kubernetes.io/proxy-send-timeout: "30" nginx.ingress.kubernetes.io/proxy-send-timeout: "30"
# Fix: 64KB+ payload (boto3/urllib3 2.0 шлёт headers и body отдельными TCP write,
# botocore убирает TCP_NODELAY → последние ~16KB body не доходят за client_body_timeout).
# Решение: proxy-request-buffering=off стримит body напрямую в backend без ожидания полного тела.
nginx.ingress.kubernetes.io/client-body-buffer-size: "512k"
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
nginx.ingress.kubernetes.io/server-snippet: |
client_body_timeout 120s;
spec: spec:
ingressClassName: nginx ingressClassName: nginx
rules: rules: