feat(shared-sqs): Redis write-through persistence, RollingUpdate deploy (v0.1.11)

This commit is contained in:
“Naeel”
2026-04-10 07:22:41 +03:00
parent ab1e47bd35
commit c9bce7e751
13 changed files with 343 additions and 18 deletions
+44 -1
View File
@@ -1,10 +1,11 @@
// app/cmd/goaws.go
// Entry point — shared-sqs server
// Updated: 2026-04-09 — добавлены TenantStore, admin token, graceful shutdown (Trap #13)
// Updated: 2026-04-10 — добавлена Redis persistence (write-through cache)
package main
import (
"context"
"encoding/json"
"flag"
"net/http"
"os"
@@ -15,6 +16,7 @@ import (
"shared-sqs/app/conf"
"shared-sqs/app/gosqs"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/router"
"shared-sqs/app/tenant"
@@ -77,6 +79,47 @@ func main() {
// Инициализация in-memory TenantStore
tenantStore := tenant.NewTenantStore()
// Подключение к Redis (если задан REDIS_ADDR)
// При ошибке — предупреждение, но продолжаем в memory-only режиме
redisAddr := os.Getenv("REDIS_ADDR")
redisUser := os.Getenv("REDIS_USER")
redisPass := os.Getenv("REDIS_PASSWORD")
if redisAddr != "" {
if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil {
log.Warnf("Не удалось подключиться к Redis: %v — работаем в memory-only режиме", err)
}
}
// Восстановление состояния из Redis (тенанты + очереди)
if persistence.Client != nil {
// Загружаем тенантов
tenantsRaw, err := persistence.LoadAllTenantsRaw()
if err != nil {
log.Warnf("Ошибка загрузки тенантов из Redis: %v", err)
} else {
for _, jsonBytes := range tenantsRaw {
var t tenant.Tenant
if err := json.Unmarshal(jsonBytes, &t); err != nil {
log.Errorf("Ошибка десериализации тенанта: %v", err)
continue
}
tenantStore.LoadTenant(&t)
}
}
// Загружаем очереди
queues, err := persistence.LoadAllQueues()
if err != nil {
log.Warnf("Ошибка загрузки очередей из Redis: %v", err)
} else {
models.SyncQueues.Lock()
for k, q := range queues {
models.SyncQueues.Queues[k] = q
}
models.SyncQueues.Unlock()
}
}
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
seedDemoData(tenantStore)
+4 -3
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-10 — добавлена Redis persistence
// CreateQueueV1 — создаёт очередь для тенанта из request context.
// Ключ в SyncQueues: "{tenantAccessKey}:{queueName}" для изоляции между тенантами.
package gosqs
@@ -9,6 +9,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
log "github.com/sirupsen/logrus"
)
@@ -54,8 +55,8 @@ return utils.CreateErrorResponseV1(err.Error(), true)
}
models.SyncQueues.Queues[key] = queue
}
models.SyncQueues.Unlock()
// Сохраняем очередь в Redis пока держим Lock — консистентный снапшот
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.CreateQueueResponse{
Xmlns: models.BaseXmlns,
Result: models.CreateQueueResult{QueueUrl: queueUrl},
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-10 — добавлена Redis persistence
// DeleteMessageV1 — удаляет сообщение из очереди тенанта по ReceiptHandle.
package gosqs
@@ -8,6 +8,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
"github.com/gorilla/mux"
@@ -49,6 +50,8 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
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:]...)
delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID)
// Сохраняем очередь в Redis пока держим Lock
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.DeleteMessageResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
+5 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-10 — добавлена Redis persistence
// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу.
package gosqs
@@ -8,6 +8,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
log "github.com/sirupsen/logrus"
)
@@ -35,6 +36,9 @@ models.SyncQueues.Lock()
delete(models.SyncQueues.Queues, key)
models.SyncQueues.Unlock()
// Удаляем из Redis асинхронно
persistence.DeleteQueue(key)
respStruct := models.DeleteQueueResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-10 — добавлена Redis persistence
// PurgeQueueV1 — очищает все сообщения в очереди тенанта.
package gosqs
@@ -9,6 +9,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
log "github.com/sirupsen/logrus"
)
@@ -40,6 +41,8 @@ return utils.CreateErrorResponseV1("QueueNotFound", true)
log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Queues[key].Messages = nil
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
// Сохраняем пустую очередь в Redis пока держим Lock
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.PurgeQueueResponse{
Xmlns: models.BaseXmlns,
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-10 — добавлена Redis persistence
// SendMessageV1 — добавляет сообщение в очередь тенанта.
// Ловушка #6: queueName извлекается как ПОСЛЕДНИЙ сегмент URL — при URL вида
// http://host/tenantID/queueName последний сегмент = queueName (правильно).
@@ -13,6 +13,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
log "github.com/sirupsen/logrus"
@@ -90,6 +91,8 @@ log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDedupl
}
models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID)
// Сохраняем очередь в Redis пока держим Lock
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock()
log.Infof("%s: Queue: %s, Message: %s\n", time.Now().Format("2006-01-02 15:04:05"), queueName, msg.MessageBody)
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-10 — добавлена Redis persistence
// SetQueueAttributesV1 — устанавливает атрибуты очереди тенанта.
// Ловушка #9: при RedrivePolicy парсим ARN DLQ и DLQ тоже должна принадлежать тому же тенанту.
package gosqs
@@ -9,6 +9,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
log "github.com/sirupsen/logrus"
)
@@ -45,6 +46,8 @@ return utils.CreateErrorResponseV1("QueueNotFound", true)
if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil {
return utils.CreateErrorResponseV1(err.Error(), true)
}
// Сохраняем атрибуты в Redis пока держим Lock (через defer)
persistence.SaveQueue(key, queue)
respStruct := models.SetQueueAttributesResponse{
Xmlns: models.BaseXmlns,
+192
View File
@@ -0,0 +1,192 @@
// app/persistence/redis.go
// Redis persistence layer для shared-sqs — write-through cache.
// Стратегия: память — источник правды для чтения (быстро),
// Redis — источник правды для восстановления после рестарта.
// Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции.
// Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот.
// Created: 2026-04-10
package persistence
import (
"context"
"encoding/json"
"fmt"
"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
const (
// redisHashTenants — HASH: tenantID → JSON тенанта
redisHashTenants = "ssq:tenants"
// redisHashQueues — HASH: queueKey → JSON очереди (включая сообщения)
redisHashQueues = "ssq:queues"
)
// Connect — подключается к Redis и проверяет ping.
// Если addr пустой — не подключается, остаёмся в memory-only режиме.
func Connect(addr, username, password string) error {
if addr == "" {
log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме")
return nil
}
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)
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()
}()
}
// SaveQueue — сохраняет очередь (с сообщениями) в Redis асинхронно.
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда json.Marshal
// создаёт консистентный снапшот. Горутина только делает сетевой вызов.
func SaveQueue(key string, queue *models.Queue) {
if Client == nil {
return
}
// Сериализуем синхронно под мьютексом вызывающего → консистентный снапшот
data, err := json.Marshal(queue)
if err != nil {
log.Errorf("persistence: marshal queue %q: %v", key, err)
return
}
asyncWrite(func() {
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 %q: %v", key, err)
}
})
}
// DeleteQueue — удаляет очередь из Redis асинхронно.
func DeleteQueue(key string) {
if Client == nil {
return
}
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
log.Errorf("persistence: HDel queue %q: %v", key, err)
}
})
}
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
// Инициализирует nil-maps чтобы избежать panic при deduplication/FIFO операциях.
func LoadAllQueues() (map[string]*models.Queue, error) {
if Client == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
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))
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)
}
queues[k] = &q
}
log.Infof("persistence: загружено %d очередей из Redis", len(queues))
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)
asyncWrite(func() {
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
}
asyncWrite(func() {
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)
}
})
}
// 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
}
+33 -3
View File
@@ -1,13 +1,16 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-10 — добавлена Redis-персистентность через пакет persistence
// Tenant model и in-memory хранилище тенантов для shared-sqs.
package tenant
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
"shared-sqs/app/persistence"
)
// Tenant — модель тенанта shared-sqs.
@@ -68,6 +71,11 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
s.byAccessKey[t.AccessKey] = t
s.mu.Unlock()
// Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса
if data, err := json.Marshal(t); err == nil {
persistence.SaveTenantRaw(t.ID, data)
}
return t, nil
}
@@ -75,11 +83,12 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
// Используется только при инициализации демо-данных; не вызывается из user-facing API.
func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.byID[tenantID]; exists {
s.mu.Unlock()
return nil, fmt.Errorf("tenant with id %s already exists", tenantID)
}
if _, exists := s.byAccessKey[accessKey]; exists {
s.mu.Unlock()
return nil, fmt.Errorf("tenant with access key %s already exists", accessKey)
}
t := &Tenant{
@@ -93,6 +102,13 @@ func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKe
}
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
s.mu.Unlock()
// Сохраняем в Redis асинхронно — seed-данные тоже персистируем
if data, err := json.Marshal(t); err == nil {
persistence.SaveTenantRaw(t.ID, data)
}
return t, nil
}
@@ -116,16 +132,30 @@ func (s *TenantStore) GetByID(id string) (*Tenant, bool) {
// Ловушка #2: если удалить только из одного индекса — orphaned данные и memory leak.
func (s *TenantStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.byID[id]
if !ok {
s.mu.Unlock()
return false
}
delete(s.byID, t.ID)
delete(s.byAccessKey, t.AccessKey)
s.mu.Unlock()
// Удаляем из Redis асинхронно
persistence.DeleteTenant(id)
return true
}
// LoadTenant — добавляет тенанта в хранилище без сохранения в Redis.
// Используется ТОЛЬКО при старте сервиса для восстановления состояния из Redis.
// Не вызывать из user-facing кода — нет дедупликации ключей.
func (s *TenantStore) LoadTenant(t *Tenant) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
s.mu.Unlock()
}
// List — список всех тенантов (для admin GET /tenants).
func (s *TenantStore) List() []*Tenant {
s.mu.RLock()