502 lines
16 KiB
Go
502 lines
16 KiB
Go
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
|
|
}
|