158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
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
|
|
}
|