feat: notification service

This commit is contained in:
Ilia Denisov
2026-04-22 08:49:45 +02:00
committed by GitHub
parent 5b7593e6f6
commit 32dc29359a
135 changed files with 21828 additions and 130 deletions
@@ -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
}