package redisstate import ( "context" "errors" "fmt" "time" "galaxy/notification/internal/service/acceptintent" "github.com/redis/go-redis/v9" ) // AcceptanceConfig stores the retention settings applied to accepted durable // notification state. type AcceptanceConfig struct { // RecordTTL stores the retention period applied to notification and route // records. RecordTTL time.Duration // DeadLetterTTL stores the retention period applied to route dead-letter // entries. DeadLetterTTL time.Duration // IdempotencyTTL stores the retention period applied to idempotency // reservations. IdempotencyTTL time.Duration } // Validate reports whether cfg contains usable retention settings. func (cfg AcceptanceConfig) Validate() error { switch { case cfg.RecordTTL <= 0: return fmt.Errorf("record ttl must be positive") case cfg.DeadLetterTTL <= 0: return fmt.Errorf("dead-letter ttl must be positive") case cfg.IdempotencyTTL <= 0: return fmt.Errorf("idempotency ttl must be positive") default: return nil } } // AtomicWriter performs the minimal multi-key Redis mutations required by // notification intent acceptance. type AtomicWriter struct { client *redis.Client keys Keyspace cfg AcceptanceConfig } // NewAtomicWriter constructs a low-level Redis mutation helper. func NewAtomicWriter(client *redis.Client, cfg AcceptanceConfig) (*AtomicWriter, error) { if client == nil { return nil, errors.New("new notification redis atomic writer: nil client") } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("new notification redis atomic writer: %w", err) } return &AtomicWriter{ client: client, keys: Keyspace{}, cfg: cfg, }, nil } // CreateAcceptance stores one notification record, all derived routes, and // the matching idempotency reservation in one optimistic Redis transaction. func (writer *AtomicWriter) CreateAcceptance(ctx context.Context, input acceptintent.CreateAcceptanceInput) error { if writer == nil || writer.client == nil { return errors.New("create notification acceptance in redis: nil writer") } if ctx == nil { return errors.New("create notification acceptance in redis: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("create notification acceptance in redis: %w", err) } notificationPayload, err := MarshalNotification(input.Notification) if err != nil { return fmt.Errorf("create notification acceptance in redis: %w", err) } idempotencyPayload, err := MarshalIdempotency(input.Idempotency) if err != nil { return fmt.Errorf("create notification acceptance in redis: %w", err) } routePayloads := make([][]byte, len(input.Routes)) routeKeys := make([]string, len(input.Routes)) scheduledRouteKeys := make([]string, 0, len(input.Routes)) scheduledRouteScores := make([]float64, 0, len(input.Routes)) for index, route := range input.Routes { payload, err := MarshalRoute(route) if err != nil { return fmt.Errorf("create notification acceptance in redis: route %d: %w", index, err) } routePayloads[index] = payload routeKeys[index] = writer.keys.Route(route.NotificationID, route.RouteID) if route.Status == acceptintent.RouteStatusPending { scheduledRouteKeys = append(scheduledRouteKeys, routeKeys[index]) scheduledRouteScores = append(scheduledRouteScores, float64(route.NextAttemptAt.UTC().UnixMilli())) } } notificationKey := writer.keys.Notification(input.Notification.NotificationID) idempotencyKey := writer.keys.Idempotency(input.Idempotency.Producer, input.Idempotency.IdempotencyKey) watchKeys := append([]string{notificationKey, idempotencyKey}, routeKeys...) watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error { for _, key := range watchKeys { if err := ensureKeyAbsent(ctx, tx, key); err != nil { return err } } _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, notificationKey, notificationPayload, writer.cfg.RecordTTL) pipe.Set(ctx, idempotencyKey, idempotencyPayload, writer.cfg.IdempotencyTTL) for index, routeKey := range routeKeys { pipe.Set(ctx, routeKey, routePayloads[index], writer.cfg.RecordTTL) } for index, routeKey := range scheduledRouteKeys { pipe.ZAdd(ctx, writer.keys.RouteSchedule(), redis.Z{ Score: scheduledRouteScores[index], Member: routeKey, }) } return nil }) return err }, watchKeys...) switch { case errors.Is(watchErr, ErrConflict), errors.Is(watchErr, redis.TxFailedErr): return ErrConflict case watchErr != nil: return fmt.Errorf("create notification acceptance in redis: %w", watchErr) default: return nil } } func ensureKeyAbsent(ctx context.Context, tx *redis.Tx, key string) error { exists, err := tx.Exists(ctx, key).Result() if err != nil { return err } if exists > 0 { return ErrConflict } return nil }