feat: notification service
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user