package redisstate import ( "context" "errors" "fmt" "time" "galaxy/mail/internal/domain/attempt" deliverydomain "galaxy/mail/internal/domain/delivery" "galaxy/mail/internal/domain/idempotency" "galaxy/mail/internal/service/acceptgenericdelivery" "github.com/redis/go-redis/v9" ) // AtomicWriter performs the minimal multi-key Redis mutations that later Mail // Service acceptance flows will need. type AtomicWriter struct { client *redis.Client keyspace Keyspace } // CreateAcceptanceInput describes the frozen write set required to durably // accept one delivery into Redis-backed state. type CreateAcceptanceInput struct { // Delivery stores the accepted delivery record. Delivery deliverydomain.Delivery // FirstAttempt stores the optional first scheduled attempt record. FirstAttempt *attempt.Attempt // DeliveryPayload stores the optional raw attachment payload bundle. DeliveryPayload *acceptgenericdelivery.DeliveryPayload // Idempotency stores the optional idempotency reservation to create // together with the delivery. Resend clone creation can omit it. Idempotency *idempotency.Record } // MarkRenderedInput describes the durable mutation applied after successful // template materialization. type MarkRenderedInput struct { // Delivery stores the rendered delivery record. Delivery deliverydomain.Delivery } // Validate reports whether input contains one rendered template delivery. func (input MarkRenderedInput) Validate() error { if err := input.Delivery.Validate(); err != nil { return fmt.Errorf("delivery: %w", err) } if input.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate { return fmt.Errorf("delivery payload mode must be %q", deliverydomain.PayloadModeTemplate) } if input.Delivery.Status != deliverydomain.StatusRendered { return fmt.Errorf("delivery status must be %q", deliverydomain.StatusRendered) } return nil } // MarkRenderFailedInput describes the durable mutation applied after one // classified render failure. type MarkRenderFailedInput struct { // Delivery stores the failed delivery record. Delivery deliverydomain.Delivery // Attempt stores the terminal render-failed attempt. Attempt attempt.Attempt } // Validate reports whether input contains one failed delivery and its // terminal render-failed attempt. func (input MarkRenderFailedInput) Validate() error { if err := input.Delivery.Validate(); err != nil { return fmt.Errorf("delivery: %w", err) } if err := input.Attempt.Validate(); err != nil { return fmt.Errorf("attempt: %w", err) } if input.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate { return fmt.Errorf("delivery payload mode must be %q", deliverydomain.PayloadModeTemplate) } if input.Delivery.Status != deliverydomain.StatusFailed { return fmt.Errorf("delivery status must be %q", deliverydomain.StatusFailed) } if input.Attempt.Status != attempt.StatusRenderFailed { return fmt.Errorf("attempt status must be %q", attempt.StatusRenderFailed) } if input.Attempt.DeliveryID != input.Delivery.DeliveryID { return errors.New("attempt delivery id must match delivery id") } if input.Delivery.LastAttemptStatus != attempt.StatusRenderFailed { return fmt.Errorf("delivery last attempt status must be %q", attempt.StatusRenderFailed) } return nil } // Validate reports whether CreateAcceptanceInput is internally consistent. func (input CreateAcceptanceInput) Validate() error { if err := input.Delivery.Validate(); err != nil { return fmt.Errorf("delivery: %w", err) } switch { case input.FirstAttempt == nil: if input.Delivery.Status != deliverydomain.StatusSuppressed { return errors.New("first attempt must not be nil unless delivery status is suppressed") } case input.Delivery.Status == deliverydomain.StatusSuppressed: return errors.New("suppressed delivery must not create first attempt") default: if err := input.FirstAttempt.Validate(); err != nil { return fmt.Errorf("first attempt: %w", err) } if input.FirstAttempt.DeliveryID != input.Delivery.DeliveryID { return errors.New("first attempt delivery id must match delivery id") } if input.FirstAttempt.Status != attempt.StatusScheduled { return fmt.Errorf("first attempt status must be %q", attempt.StatusScheduled) } } if input.DeliveryPayload != nil { if err := input.DeliveryPayload.Validate(); err != nil { return fmt.Errorf("delivery payload: %w", err) } if input.DeliveryPayload.DeliveryID != input.Delivery.DeliveryID { return errors.New("delivery payload delivery id must match delivery id") } } if input.Idempotency == nil { return nil } if err := input.Idempotency.Validate(); err != nil { return fmt.Errorf("idempotency: %w", err) } if input.Idempotency.DeliveryID != input.Delivery.DeliveryID { return errors.New("idempotency delivery id must match delivery id") } if input.Idempotency.Source != input.Delivery.Source { return errors.New("idempotency source must match delivery source") } if input.Idempotency.IdempotencyKey != input.Delivery.IdempotencyKey { return errors.New("idempotency key must match delivery idempotency key") } if input.Idempotency.ExpiresAt.Sub(input.Idempotency.CreatedAt) != IdempotencyTTL { return fmt.Errorf("idempotency retention must equal %s", IdempotencyTTL) } return nil } // NewAtomicWriter constructs a low-level Redis mutation helper. func NewAtomicWriter(client *redis.Client) (*AtomicWriter, error) { if client == nil { return nil, errors.New("new redis atomic writer: nil client") } return &AtomicWriter{ client: client, keyspace: Keyspace{}, }, nil } // CreateAcceptance stores one delivery, the optional first scheduled attempt, // the optional first schedule entry, the delivery-level secondary indexes, and // an optional idempotency record in one optimistic Redis transaction. func (writer *AtomicWriter) CreateAcceptance(ctx context.Context, input CreateAcceptanceInput) error { if writer == nil || writer.client == nil { return errors.New("create acceptance in redis: nil writer") } if ctx == nil { return errors.New("create acceptance in redis: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } deliveryPayload, err := MarshalDelivery(input.Delivery) if err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } var ( attemptKey string attemptPayload []byte deliveryPayloadKey string deliveryPayloadBytes []byte scheduleScore float64 idempotencyKey string idempotencyPayload []byte idempotencyTTL time.Duration ) if input.FirstAttempt != nil { attemptPayload, err = MarshalAttempt(*input.FirstAttempt) if err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } attemptKey = writer.keyspace.Attempt(input.FirstAttempt.DeliveryID, input.FirstAttempt.AttemptNo) scheduleScore = ScheduledForScore(input.FirstAttempt.ScheduledFor) } if input.DeliveryPayload != nil { deliveryPayloadBytes, err = MarshalDeliveryPayload(*input.DeliveryPayload) if err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } deliveryPayloadKey = writer.keyspace.DeliveryPayload(input.DeliveryPayload.DeliveryID) } if input.Idempotency != nil { idempotencyPayload, err = MarshalIdempotency(*input.Idempotency) if err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } idempotencyTTL, err = ttlUntil(input.Idempotency.ExpiresAt) if err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } idempotencyKey = writer.keyspace.Idempotency(input.Idempotency.Source, input.Idempotency.IdempotencyKey) } deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID) watchKeys := []string{deliveryKey} if attemptKey != "" { watchKeys = append(watchKeys, attemptKey) } if deliveryPayloadKey != "" { watchKeys = append(watchKeys, deliveryPayloadKey) } if idempotencyKey != "" { watchKeys = append(watchKeys, idempotencyKey) } indexKeys := writer.keyspace.DeliveryIndexKeys(input.Delivery) createdAtScore := CreatedAtScore(input.Delivery.CreatedAt) deliveryMember := input.Delivery.DeliveryID.String() watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error { for _, key := range watchKeys { if err := ensureKeyAbsent(ctx, tx, key); err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } } _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, deliveryKey, deliveryPayload, DeliveryTTL) if attemptKey != "" { pipe.Set(ctx, attemptKey, attemptPayload, AttemptTTL) } if deliveryPayloadKey != "" { pipe.Set(ctx, deliveryPayloadKey, deliveryPayloadBytes, DeliveryTTL) } if idempotencyKey != "" { pipe.Set(ctx, idempotencyKey, idempotencyPayload, idempotencyTTL) } if attemptKey != "" { pipe.ZAdd(ctx, writer.keyspace.AttemptSchedule(), redis.Z{ Score: scheduleScore, Member: deliveryMember, }) } for _, indexKey := range indexKeys { pipe.ZAdd(ctx, indexKey, redis.Z{ Score: createdAtScore, Member: deliveryMember, }) } return nil }) if err != nil { return fmt.Errorf("create acceptance in redis: %w", err) } return nil }, watchKeys...) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("create acceptance in redis: %w", ErrConflict) case watchErr != nil: return watchErr default: return nil } } // MarkRendered stores the successful materialization result for one queued // template delivery and updates the delivery-status secondary index // atomically. func (writer *AtomicWriter) MarkRendered(ctx context.Context, input MarkRenderedInput) error { if writer == nil || writer.client == nil { return errors.New("mark rendered in redis: nil writer") } if ctx == nil { return errors.New("mark rendered in redis: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("mark rendered in redis: %w", err) } deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID) deliveryPayload, err := MarshalDelivery(input.Delivery) if err != nil { return fmt.Errorf("mark rendered in redis: %w", err) } watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error { currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey) if err != nil { return fmt.Errorf("mark rendered in redis: %w", err) } if currentDelivery.Status != deliverydomain.StatusQueued { return fmt.Errorf("mark rendered in redis: %w", ErrConflict) } deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL) if err != nil { return fmt.Errorf("mark rendered in redis: %w", err) } createdAtScore := CreatedAtScore(currentDelivery.CreatedAt) deliveryMember := input.Delivery.DeliveryID.String() _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL) pipe.ZRem(ctx, writer.keyspace.StatusIndex(currentDelivery.Status), deliveryMember) pipe.ZAdd(ctx, writer.keyspace.StatusIndex(input.Delivery.Status), redis.Z{ Score: createdAtScore, Member: deliveryMember, }) return nil }) if err != nil { return fmt.Errorf("mark rendered in redis: %w", err) } return nil }, deliveryKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("mark rendered in redis: %w", ErrConflict) case watchErr != nil: return watchErr default: return nil } } // MarkRenderFailed stores one terminal render-failed attempt together with // the owning failed delivery and updates the delivery-status secondary index // atomically. func (writer *AtomicWriter) MarkRenderFailed(ctx context.Context, input MarkRenderFailedInput) error { if writer == nil || writer.client == nil { return errors.New("mark render failed in redis: nil writer") } if ctx == nil { return errors.New("mark render failed in redis: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID) attemptKey := writer.keyspace.Attempt(input.Attempt.DeliveryID, input.Attempt.AttemptNo) deliveryPayload, err := MarshalDelivery(input.Delivery) if err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } attemptPayload, err := MarshalAttempt(input.Attempt) if err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error { currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey) if err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } currentAttempt, err := loadAttemptFromTx(ctx, tx, attemptKey) if err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } if currentDelivery.Status != deliverydomain.StatusQueued { return fmt.Errorf("mark render failed in redis: %w", ErrConflict) } if currentAttempt.Status != attempt.StatusScheduled { return fmt.Errorf("mark render failed in redis: %w", ErrConflict) } deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL) if err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } attemptTTL, err := ttlForExistingKey(ctx, tx, attemptKey, AttemptTTL) if err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } createdAtScore := CreatedAtScore(currentDelivery.CreatedAt) deliveryMember := input.Delivery.DeliveryID.String() _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL) pipe.Set(ctx, attemptKey, attemptPayload, attemptTTL) pipe.ZRem(ctx, writer.keyspace.StatusIndex(currentDelivery.Status), deliveryMember) pipe.ZAdd(ctx, writer.keyspace.StatusIndex(input.Delivery.Status), redis.Z{ Score: createdAtScore, Member: deliveryMember, }) pipe.ZRem(ctx, writer.keyspace.AttemptSchedule(), deliveryMember) return nil }) if err != nil { return fmt.Errorf("mark render failed in redis: %w", err) } return nil }, deliveryKey, attemptKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("mark render failed in redis: %w", ErrConflict) case watchErr != nil: return 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 } func loadDeliveryFromTx(ctx context.Context, tx *redis.Tx, key string) (deliverydomain.Delivery, error) { payload, err := tx.Get(ctx, key).Bytes() switch { case errors.Is(err, redis.Nil): return deliverydomain.Delivery{}, ErrConflict case err != nil: return deliverydomain.Delivery{}, err } record, err := UnmarshalDelivery(payload) if err != nil { return deliverydomain.Delivery{}, err } return record, nil } func loadAttemptFromTx(ctx context.Context, tx *redis.Tx, key string) (attempt.Attempt, error) { payload, err := tx.Get(ctx, key).Bytes() switch { case errors.Is(err, redis.Nil): return attempt.Attempt{}, ErrConflict case err != nil: return attempt.Attempt{}, err } record, err := UnmarshalAttempt(payload) if err != nil { return attempt.Attempt{}, err } return record, nil } func ttlForExistingKey(ctx context.Context, tx *redis.Tx, key string, fallback time.Duration) (time.Duration, error) { ttl, err := tx.PTTL(ctx, key).Result() if err != nil { return 0, err } if ttl <= 0 { return fallback, nil } return ttl, nil } func ttlUntil(expiresAt time.Time) (time.Duration, error) { ttl := time.Until(expiresAt) if ttl <= 0 { return 0, errors.New("idempotency expires at must be in the future") } return ttl, nil }