// 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" "sync/atomic" "time" "github.com/redis/go-redis/v9" log "github.com/sirupsen/logrus" "shared-sqs/app/models" ) // Client — глобальный Redis клиент. // nil означает режим "только память" — все функции тихо no-op. // atomic.Pointer: reconnect() пишет новый клиент из health-check горутины, // а остальные горутины читают — без атомарности это data race. var Client atomic.Pointer[redis.Client] // Параметры подключения — сохраняются для переподключения после обрыва. var ( redisAddr string redisUsername string redisPassword string ) var ( seqMu sync.Mutex queueWriteSeq = map[string]uint64{} tenantWriteSeq = map[string]uint64{} // msgWriteSeq — порядковая нумерация async-записей ОТДЕЛЬНЫХ сообщений // (ключ "{queueKey}|{uuid}"). Без неё два SaveMessage одного сообщения из // разных горутин могли завершиться вразнобой: старое состояние перезаписало // бы новое. Записи удаляются при удалении сообщения (DeleteMessagePersist и др.). msgWriteSeq = 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.Store(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.Load() == nil { continue } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := Client.Load().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.Store(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"` // Создание/изменение + совместимые атрибуты (GetQueueAttributes/Terraform). CreatedAt time.Time LastModifiedAt time.Time Policy string ContentBasedDeduplication bool } // 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, CreatedAt: q.CreatedAt, LastModifiedAt: q.LastModifiedAt, Policy: q.Policy, ContentBasedDeduplication: q.ContentBasedDeduplication, } 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.Load() == 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.Load().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.Load() == 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 msgKey := queueKey + "|" + msg.Uuid writeSeq := nextMsgWriteSeq(msgKey) asyncWrite(func() { if !isLatestMsgWriteSeq(msgKey, writeSeq) { return } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := Client.Load().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.Load() == 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.Load().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.Load() == 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.Load().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.Load() == 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.Load().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) error { if Client.Load() == nil || msgUuid == "" { return nil } hashKey := redisMsgHashPrefix + queueKey ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := Client.Load().HDel(ctx, hashKey, msgUuid).Err(); err != nil { return fmt.Errorf("HDel message %s/%s: %w", queueKey, msgUuid, err) } clearMsgWriteSeq(queueKey, msgUuid) return nil } // DeleteMessagesPersist — удаляет несколько сообщений из Redis СИНХРОННО // (иначе удалённые сообщения воскреснут после рестарта пода). func DeleteMessagesPersist(queueKey string, uuids []string) error { if Client.Load() == nil || len(uuids) == 0 { return nil } hashKey := redisMsgHashPrefix + queueKey ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := Client.Load().HDel(ctx, hashKey, uuids...).Err(); err != nil { return fmt.Errorf("HDel messages %s (%d): %w", queueKey, len(uuids), err) } clearMsgWriteSeq(queueKey, uuids...) return nil } // PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis СИНХРОННО. // Синхронность обязательна: при рестарте пода удалённые сообщения не должны // воскреснуть (факт: асинхронное удаление очередей приводило к их возврату). func PurgeMessagesPersist(queueKey string) { if Client.Load() == nil { return } hashKey := redisMsgHashPrefix + queueKey ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := Client.Load().Del(ctx, hashKey).Err(); err != nil { log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err) } clearAllMsgWriteSeq(queueKey) } // DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно. func DeleteQueue(key string) { if Client.Load() == 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.Load().HDel(ctx, redisHashQueues, key).Err(); err != nil { log.Errorf("persistence: HDel queue meta %q: %v", key, err) } // Удаляем весь HASH с сообщениями if err := Client.Load().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.Load() == nil { return nil, nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 1. Загружаем метаданные очередей raw, err := Client.Load().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) } // Бэкфилл timestamp-ов: у очередей, созданных до появления полей, они нулевые. // Без этого Terraform видел бы CreatedTimestamp=0 и постоянный drift. // Заполняем сейчас и сохраним при ближайшем SaveQueue. if q.CreatedAt.IsZero() { q.CreatedAt = time.Now().UTC() } if q.LastModifiedAt.IsZero() { q.LastModifiedAt = q.CreatedAt } // Миграция 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.Load().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.Load().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.Load().HGetAll(ctx, hashKey).Result() if err != nil { // Fail-fast: скрытая пустая очередь при недоступных сообщениях // хуже явной ошибки — сообщения остались бы невидимыми в Redis. return nil, fmt.Errorf("redis HGetAll messages %q: %w", k, err) } 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.Load() == 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.Load().HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil { log.Errorf("persistence: HSet tenant %q: %v", id, err) } if meta.AccessKey != "" { if err := Client.Load().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.Load() == nil || accessKey == "" { return nil, false } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() v, err := Client.Load().HGet(ctx, redisHashTenantAK, accessKey).Result() if err != nil { return nil, false } return []byte(v), true } // DeleteTenant — удаляет тенанта из Redis асинхронно. // Удаляет из ОБОИХ индексов: ssq:tenants (по id) и ssq:tenant_ak (по accessKey), // иначе LoadTenantByAccessKey вернёт удалённого тенанта и отзыв ключей не сработает. func DeleteTenant(id, accessKey string) { if Client.Load() == 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.Load().HDel(ctx, redisHashTenants, id).Err(); err != nil { log.Errorf("persistence: HDel tenant %q: %v", id, err) } if accessKey != "" { if err := Client.Load().HDel(ctx, redisHashTenantAK, accessKey).Err(); err != nil { log.Errorf("persistence: HDel tenant_ak %q: %v", accessKey, 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 nextMsgWriteSeq(key string) uint64 { seqMu.Lock() defer seqMu.Unlock() msgWriteSeq[key]++ return msgWriteSeq[key] } func isLatestMsgWriteSeq(key string, seq uint64) bool { seqMu.Lock() defer seqMu.Unlock() return msgWriteSeq[key] == seq } // clearMsgWriteSeq — удаляет записи нумерации для удалённых сообщений // (иначе msgWriteSeq рос бы бесконечно). func clearMsgWriteSeq(queueKey string, uuids ...string) { seqMu.Lock() defer seqMu.Unlock() for _, u := range uuids { delete(msgWriteSeq, queueKey+"|"+u) } } // clearAllMsgWriteSeq — удаляет ВСЕ записи нумерации для очереди (purge). func clearAllMsgWriteSeq(queueKey string) { prefix := queueKey + "|" seqMu.Lock() defer seqMu.Unlock() for k := range msgWriteSeq { if strings.HasPrefix(k, prefix) { delete(msgWriteSeq, k) } } } 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.Load() == nil { return nil, nil } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() raw, err := Client.Load().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.Load() == nil { return true, nil } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() return Client.Load().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.Load() == nil { return true, nil } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() n, err := refreshLockScript.Run(ctx, Client.Load(), []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.Load() == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if _, err := releaseLockScript.Run(ctx, Client.Load(), []string{key}, value).Int(); err != nil { log.Errorf("persistence: release lock %q: %v", key, err) } }