- app/leader: Elector (SETNX lock + PEXPIRE heartbeat via Lua, IsLeader) - app/persistence: TryAcquireLock/RefreshLock/ReleaseLock (no-op when Redis absent) - app/router: /ready -> 200 if ReadyFunc nil/true, else 503 (both planes) - app/cmd: --leader-election / SHARED_SQS_LEADER_ELECTION, --leader-ttl, --leader-interval; ReadyFunc wired only when enabled, lock released on graceful shutdown - default OFF: single-instance behaviour unchanged
716 lines
28 KiB
Go
716 lines
28 KiB
Go
// 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"
|
||
"strings"
|
||
"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"
|
||
// redisHashTenantAK — HASH: accessKey → JSON тенанта (индекс read-through в auth)
|
||
redisHashTenantAK = "ssq:tenant_ak"
|
||
// 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
|
||
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
|
||
}
|
||
|
||
// queueToMetaDTO — строит снимок очереди без сообщений, НЕ мутируя исходную очередь.
|
||
// DLQ сохраняется как ИМЯ (не вложенный объект): иначе — detached-копия после
|
||
// загрузки и неограниченная рекурсия при циклической DLQ.
|
||
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,
|
||
DeadLetterQueueName: q.DeadLetterQueueName,
|
||
}
|
||
if q.DeadLetterQueue != nil {
|
||
dto.DeadLetterQueueName = q.DeadLetterQueue.Name
|
||
}
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
|
||
// SaveMessagesSync — сохраняет несколько сообщений в Redis СИНХРОННО (pipeline).
|
||
// Используется на пути SendMessageBatch: ответ 200 выдаётся только после записи
|
||
// всех сообщений (как в одиночном SendMessage). Возвращает ошибку для отката.
|
||
func SaveMessagesSync(queueKey string, msgs []models.SqsMessage) error {
|
||
if Client == nil || len(msgs) == 0 {
|
||
return nil
|
||
}
|
||
fields := make(map[string]string, len(msgs))
|
||
for i := range msgs {
|
||
data, err := json.Marshal(&msgs[i])
|
||
if err != nil {
|
||
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msgs[i].Uuid, err)
|
||
}
|
||
fields[msgs[i].Uuid] = string(data)
|
||
}
|
||
if len(fields) == 0 {
|
||
return nil
|
||
}
|
||
hashKey := redisMsgHashPrefix + queueKey
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
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 {
|
||
return fmt.Errorf("HSet messages %s (%d msgs): %w", queueKey, len(fields), err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// Строим индексы (P1.2): receiptIdx/uuidIdx/deadIdx по загруженным
|
||
// сообщениям. Без этого первый же receive/delete перестроил бы их лениво.
|
||
q.RebuildIndexes()
|
||
}
|
||
|
||
// 4. Релинк DLQ: в метаданных хранится только имя DLQ — связываем указатель
|
||
// с реальным объектом из map. Иначе DeadLetterQueue оказался бы detached-копией
|
||
// (переносы в DLQ писали бы в неё, а не в живую очередь; мьютекс — чужой).
|
||
for k, q := range queues {
|
||
if q.DeadLetterQueueName == "" {
|
||
continue
|
||
}
|
||
idx := strings.Index(k, ":")
|
||
if idx <= 0 {
|
||
continue
|
||
}
|
||
if dlq, ok := queues[k[:idx+1]+q.DeadLetterQueueName]; ok {
|
||
q.DeadLetterQueue = dlq
|
||
} else {
|
||
log.Warnf("persistence: DLQ %q для очереди %q не найдена при загрузке", q.DeadLetterQueueName, k)
|
||
}
|
||
}
|
||
|
||
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)
|
||
// Извлекаем accessKey для индекса read-through (без импорта пакета tenant)
|
||
var meta struct {
|
||
AccessKey string
|
||
}
|
||
_ = json.Unmarshal(dataCopy, &meta)
|
||
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)
|
||
}
|
||
if meta.AccessKey != "" {
|
||
if err := Client.HSet(ctx, redisHashTenantAK, meta.AccessKey, string(dataCopy)).Err(); err != nil {
|
||
log.Errorf("persistence: HSet tenant_ak %q: %v", meta.AccessKey, err)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// LoadTenantByAccessKey — read-through тенанта по accessKey (для auth-middleware).
|
||
// Возвращает сырой JSON; десериализацию делает вызывающий.
|
||
func LoadTenantByAccessKey(accessKey string) ([]byte, bool) {
|
||
if Client == nil || accessKey == "" {
|
||
return nil, false
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
v, err := Client.HGet(ctx, redisHashTenantAK, accessKey).Result()
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
return []byte(v), true
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// ---- Leader election (active/passive) ----
|
||
//
|
||
// Простейший HA-механизм (вариант C): один инстанс — активный (держит лок),
|
||
// остальные — пассивные. При падении активного лок истекает по TTL и пассивный
|
||
// его перехватывает (см. app/leader). Инстанс сообщает готовность через /ready.
|
||
//
|
||
// ВАЖНО: при Client == nil (memory-only) — считаем себя лидером (мы единственные).
|
||
|
||
// TryAcquireLock — пытается взять лок (SETNX с TTL). true — лок наш.
|
||
func TryAcquireLock(key, value string, ttl time.Duration) (bool, error) {
|
||
if Client == nil {
|
||
return true, nil
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
return Client.SetNX(ctx, key, value, ttl).Result()
|
||
}
|
||
|
||
// refreshLockScript — продлить TTL, ТОЛЬКО если лок всё ещё наш (атомарно).
|
||
var refreshLockScript = redis.NewScript(`
|
||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||
else
|
||
return 0
|
||
end`)
|
||
|
||
// releaseLockScript — удалить лок, ТОЛЬКО если он наш.
|
||
var releaseLockScript = redis.NewScript(`
|
||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||
return redis.call("DEL", KEYS[1])
|
||
else
|
||
return 0
|
||
end`)
|
||
|
||
// RefreshLock — продлевает TTL лока, если он всё ещё наш. false — лок потерян.
|
||
func RefreshLock(key, value string, ttl time.Duration) (bool, error) {
|
||
if Client == nil {
|
||
return true, nil
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
n, err := refreshLockScript.Run(ctx, Client, []string{key}, value, ttl.Milliseconds()).Int()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return n == 1, nil
|
||
}
|
||
|
||
// ReleaseLock — освобождает лок (если он наш). Вызывается при graceful shutdown.
|
||
func ReleaseLock(key, value string) {
|
||
if Client == nil {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
if _, err := releaseLockScript.Run(ctx, Client, []string{key}, value).Int(); err != nil {
|
||
log.Errorf("persistence: release lock %q: %v", key, err)
|
||
}
|
||
}
|