Files
SQS-service/app/persistence/redis.go
T
Repinoid fd8ffc329d 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
больше не держит глобальный лок.
2026-10-06 18:58:16 +03:00

574 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app/persistence/redis.go
// Redis persistence layer для shared-sqs — write-through cache.
// Стратегия: память — источник правды для чтения (быстро),
// Redis — источник правды для восстановления после рестарта.
// Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции.
// Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот.
//
// Схема хранения 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
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
"shared-sqs/app/models"
)
// Client — глобальный Redis клиент.
// nil означает режим "только память" — все функции тихо no-op.
var Client *redis.Client
// Параметры подключения — сохраняются для переподключения после обрыва.
var (
redisAddr string
redisUsername string
redisPassword string
)
var (
seqMu sync.Mutex
queueWriteSeq = map[string]uint64{}
tenantWriteSeq = map[string]uint64{}
)
const (
// redisHashTenants — HASH: tenantID → JSON тенанта
redisHashTenants = "ssq:tenants"
// redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2)
redisHashQueues = "ssq:queues"
// redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage)
redisMsgHashPrefix = "ssq:msg:"
)
// Connect — подключается к Redis и проверяет ping.
// Если addr пустой — не подключается, остаёмся в memory-only режиме.
func Connect(addr, username, password string) error {
if addr == "" {
log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме")
return nil
}
redisAddr = addr
redisUsername = username
redisPassword = password
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Username: username,
Password: password,
DB: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("redis ping %s: %w", addr, err)
}
Client = rdb
log.Infof("persistence: подключились к Redis %s", addr)
startRedisHealthCheck()
return nil
}
// startRedisHealthCheck — фоновая проверка соединения и переподключение.
// После обрыва Redis-клиент остаётся не-nil, но записи молча падают. Здесь мы
// периодически пингуем Redis и при сбое создаём новый клиент (reconnect), чтобы
// персистентность восстановилась автоматически.
func startRedisHealthCheck() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if Client == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := Client.Ping(ctx).Err()
cancel()
if err == nil {
continue
}
log.Warnf("persistence: Redis недоступен (%v), пробуем переподключиться", err)
if reErr := reconnect(); reErr != nil {
log.Errorf("persistence: переподключение не удалось: %v", reErr)
} else {
log.Infof("persistence: переподключились к Redis %s", redisAddr)
}
}
}()
}
// reconnect — создаёт новый Redis-клиент с сохранёнными параметрами подключения.
func reconnect() error {
if redisAddr == "" {
return fmt.Errorf("redis addr пуст")
}
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
Username: redisUsername,
Password: redisPassword,
DB: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return err
}
Client = rdb
return nil
}
// asyncWrite — запускает fn в горутине, перехватывает panic и логирует.
// Используется для записей в Redis чтобы не задерживать SQS-ответы.
func asyncWrite(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Errorf("persistence: panic в asyncWrite: %v", r)
}
}()
fn()
}()
}
// queueMetaDTO — сериализуемая копия очереди БЕЗ Messages (и без сообщений DLQ).
// Отдельный тип, т.к. models.Queue содержит sync.RWMutex и его нельзя копировать/
// сериализовать целиком, а временное зануление 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) {
return json.Marshal(queueToMetaDTO(queue))
}
// SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно.
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда marshalQueueMeta
// создаёт консистентный снапшот. Горутина только делает сетевой вызов.
// Для сохранения сообщений используй SaveMessage/SaveMessages.
func SaveQueue(key string, queue *models.Queue) {
if Client == nil {
return
}
// Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот
data, err := marshalQueueMeta(queue)
if err != nil {
log.Errorf("persistence: marshal queue meta %q: %v", key, err)
return
}
writeSeq := nextQueueWriteSeq(key)
asyncWrite(func() {
if !isLatestQueueWriteSeq(key, writeSeq) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet queue meta %q: %v", key, err)
}
})
}
// 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)
}
})
}
// 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) {
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 СИНХРОННО.
// Синхронность обязательна: если удаление потеряется при рестарте пода,
// сообщение «воскреснет» из Redis (факт: удалённые очереди возвращались
// после рестарта, когда удаления были асинхронными).
func DeleteMessagePersist(queueKey string, msgUuid string) {
if Client == nil || msgUuid == "" {
return
}
hashKey := redisMsgHashPrefix + queueKey
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 СИНХРОННО
// (иначе удалённые сообщения воскреснут после рестарта пода).
func DeleteMessagesPersist(queueKey string, uuids []string) {
if Client == nil || len(uuids) == 0 {
return
}
hashKey := redisMsgHashPrefix + queueKey
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, uuids...).Err(); err != nil {
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(uuids), err)
}
}
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis СИНХРОННО.
// Синхронность обязательна: при рестарте пода удалённые сообщения не должны
// воскреснуть (факт: асинхронное удаление очередей приводило к их возврату).
func PurgeMessagesPersist(queueKey string) {
if Client == nil {
return
}
hashKey := redisMsgHashPrefix + queueKey
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) {
if Client == nil {
return
}
writeSeq := nextQueueWriteSeq(key)
msgHashKey := redisMsgHashPrefix + key
// СИНХРОННО: удаление очереди обязано попасть в Redis до ответа клиенту.
// Асинхронное удаление приводило к «воскрешению» удалённых очередей после
// рестарта пода (факт 2026-08-15: 50 мусорных очередей вернулись из Redis).
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !isLatestQueueWriteSeq(key, writeSeq) {
return
}
// Удаляем метаданные из общего HASH
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
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 в память при старте сервиса.
// Схема v2: метаданные из ssq:queues, сообщения из ssq:msg:{key}.
// Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages,
// они извлекаются в отдельный HASH и метаданные пересохраняются без Messages.
func LoadAllQueues() (map[string]*models.Queue, error) {
if Client == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 1. Загружаем метаданные очередей
raw, err := Client.HGetAll(ctx, redisHashQueues).Result()
if err != nil {
return nil, fmt.Errorf("redis HGetAll queues: %w", err)
}
queues := make(map[string]*models.Queue, len(raw))
migrateKeys := make([]string, 0) // ключи для миграции v1→v2
for k, v := range raw {
var q models.Queue
if err := json.Unmarshal([]byte(v), &q); err != nil {
log.Errorf("persistence: unmarshal queue %q: %v", k, err)
continue
}
// Инициализируем nil-maps — их json.Unmarshal не создаёт если поле было nil
if q.Duplicates == nil {
q.Duplicates = make(map[string]time.Time)
}
if q.FIFOMessages == nil {
q.FIFOMessages = make(map[string]int)
}
if q.FIFOSequenceNumbers == nil {
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
}
// 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
}
// SaveTenantRaw — сохраняет тенанта (сырой JSON) в Redis асинхронно.
// Принимает []byte чтобы избежать циклического импорта с пакетом tenant.
// Сериализацию делает вызывающий (tenant_store.go).
func SaveTenantRaw(id string, jsonData []byte) {
if Client == nil {
return
}
// Копируем bytes — вызывающий может переиспользовать буфер
dataCopy := make([]byte, len(jsonData))
copy(dataCopy, jsonData)
writeSeq := nextTenantWriteSeq(id)
asyncWrite(func() {
if !isLatestTenantWriteSeq(id, writeSeq) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil {
log.Errorf("persistence: HSet tenant %q: %v", id, err)
}
})
}
// DeleteTenant — удаляет тенанта из Redis асинхронно.
func DeleteTenant(id string) {
if Client == nil {
return
}
writeSeq := nextTenantWriteSeq(id)
asyncWrite(func() {
if !isLatestTenantWriteSeq(id, writeSeq) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HDel(ctx, redisHashTenants, id).Err(); err != nil {
log.Errorf("persistence: HDel tenant %q: %v", id, err)
}
})
}
func nextQueueWriteSeq(key string) uint64 {
seqMu.Lock()
defer seqMu.Unlock()
queueWriteSeq[key]++
return queueWriteSeq[key]
}
func isLatestQueueWriteSeq(key string, seq uint64) bool {
seqMu.Lock()
defer seqMu.Unlock()
return queueWriteSeq[key] == seq
}
func nextTenantWriteSeq(id string) uint64 {
seqMu.Lock()
defer seqMu.Unlock()
tenantWriteSeq[id]++
return tenantWriteSeq[id]
}
func isLatestTenantWriteSeq(id string, seq uint64) bool {
seqMu.Lock()
defer seqMu.Unlock()
return tenantWriteSeq[id] == seq
}
// LoadAllTenantsRaw — загружает всех тенантов из Redis при старте.
// Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go.
func LoadAllTenantsRaw() (map[string][]byte, error) {
if Client == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
raw, err := Client.HGetAll(ctx, redisHashTenants).Result()
if err != nil {
return nil, fmt.Errorf("redis HGetAll tenants: %w", err)
}
result := make(map[string][]byte, len(raw))
for id, v := range raw {
result[id] = []byte(v)
}
log.Infof("persistence: загружено %d тенантов из Redis", len(result))
return result, nil
}