P0.3: синхронный персист SendMessage до ответа 200

Добавлен persistence.SaveMessageSync (синхронный HSet, возвращает ошибку).
SendMessage теперь пишет сообщение в Redis ДО возврата клиенту; при ошибке Redis
сообщение откатывается из памяти и возвращается InternalError (новая ошибка в
models/errors.go). Устраняет окно потери подтверждённого сообщения при краше пода
между 200 OK и асинхронной записью.
This commit is contained in:
Repinoid
2026-10-06 18:04:22 +03:00
parent 928a3fb85d
commit c19a056de3
3 changed files with 39 additions and 2 deletions
+14 -2
View File
@@ -148,10 +148,18 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
fifoSeqNumber = queue.NextSequenceNumber(messageGroupID)
}
persistErr := false
if !queue.IsDuplicate(messageDeduplicationID) {
queue.Messages = append(queue.Messages, msg)
// Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size)
persistence.SaveMessage(key, &msg)
// Персистим сообщение СИНХРОННО до ответа клиенту: краш пода в окне
// между 200 OK и async-записью приводил бы к потере подтверждённого
// сообщения. При ошибке — откатываем append (не оставляем в памяти
// сообщение, которого нет в Redis).
if err := persistence.SaveMessageSync(key, &msg); err != nil {
log.Errorf("SendMessage: не удалось персистить сообщение %s в очередь %s: %v", msg.Uuid, key, err)
queue.Messages = queue.Messages[:len(queue.Messages)-1]
persistErr = true
}
} else {
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
}
@@ -160,6 +168,10 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
// Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock()
if persistErr {
return utils.CreateErrorResponseV1("InternalError", true)
}
// Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody))
+2
View File
@@ -30,6 +30,8 @@ func init() {
"PurgeQueueInProgress": {HttpError: http.StatusForbidden, Type: "Sender", Code: "AWS.SimpleQueueService.PurgeQueueInProgress", Message: "Only one PurgeQueue operation on a queue is allowed every 60 seconds."},
// OverLimit — превышен лимит сообщений в очереди (OOM-защита)
"OverLimit": {HttpError: http.StatusBadRequest, Type: "OverLimit", Code: "OverLimit", Message: "The queue contains the maximum number of messages."},
// InternalError — внутренняя ошибка сервера (например, не удалось персистить сообщение)
"InternalError": {HttpError: http.StatusInternalServerError, Type: "InternalError", Code: "AWS.SimpleQueueService.InternalError", Message: "We encountered an internal error. Please try again."},
}
}
+23
View File
@@ -150,6 +150,29 @@ func SaveMessage(queueKey string, msg *models.SqsMessage) {
})
}
// SaveMessageSync — сохраняет ОДНО сообщение в Redis СИНХРОННО и возвращает ошибку.
// Используется на пути SendMessage: ответ 200 клиенту должен выдаваться ТОЛЬКО
// после фактической записи сообщения в Redis, иначе краш пода в окне между
// ответом и async-записью приведёт к потере подтверждённого сообщения.
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
// Если Client == nil (memory-only режим) — возвращает nil (персистить некуда).
func SaveMessageSync(queueKey string, msg *models.SqsMessage) error {
if Client == nil {
return nil
}
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msg.Uuid, err)
}
hashKey := redisMsgHashPrefix + queueKey
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
return fmt.Errorf("HSet message %s/%s: %w", queueKey, msg.Uuid, err)
}
return nil
}
// SaveMessages — сохраняет несколько сообщений в Redis одним pipeline.
// Вызывать под Lock.
func SaveMessages(queueKey string, msgs []models.SqsMessage) {