533 lines
16 KiB
Go
533 lines
16 KiB
Go
package redisstate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"galaxy/mail/internal/domain/attempt"
|
|
"galaxy/mail/internal/domain/common"
|
|
deliverydomain "galaxy/mail/internal/domain/delivery"
|
|
"galaxy/mail/internal/service/acceptgenericdelivery"
|
|
"galaxy/mail/internal/service/listattempts"
|
|
"galaxy/mail/internal/service/listdeliveries"
|
|
"galaxy/mail/internal/service/resenddelivery"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// OperatorStore provides the Redis-backed durable storage used by the
|
|
// operator read and resend workflows.
|
|
type OperatorStore struct {
|
|
client *redis.Client
|
|
writer *AtomicWriter
|
|
keys Keyspace
|
|
}
|
|
|
|
// NewOperatorStore constructs one Redis-backed operator store.
|
|
func NewOperatorStore(client *redis.Client) (*OperatorStore, error) {
|
|
if client == nil {
|
|
return nil, errors.New("new operator store: nil redis client")
|
|
}
|
|
|
|
writer, err := NewAtomicWriter(client)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new operator store: %w", err)
|
|
}
|
|
|
|
return &OperatorStore{
|
|
client: client,
|
|
writer: writer,
|
|
keys: Keyspace{},
|
|
}, nil
|
|
}
|
|
|
|
// GetDelivery loads one accepted delivery by its identifier.
|
|
func (store *OperatorStore) GetDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
|
if store == nil || store.client == nil {
|
|
return deliverydomain.Delivery{}, false, errors.New("get operator delivery: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return deliverydomain.Delivery{}, false, errors.New("get operator delivery: nil context")
|
|
}
|
|
if err := deliveryID.Validate(); err != nil {
|
|
return deliverydomain.Delivery{}, false, fmt.Errorf("get operator delivery: %w", err)
|
|
}
|
|
|
|
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return deliverydomain.Delivery{}, false, nil
|
|
case err != nil:
|
|
return deliverydomain.Delivery{}, false, fmt.Errorf("get operator delivery: %w", err)
|
|
}
|
|
|
|
record, err := UnmarshalDelivery(payload)
|
|
if err != nil {
|
|
return deliverydomain.Delivery{}, false, fmt.Errorf("get operator delivery: %w", err)
|
|
}
|
|
|
|
return record, true, nil
|
|
}
|
|
|
|
// GetDeadLetter loads the dead-letter entry associated with deliveryID when
|
|
// one exists.
|
|
func (store *OperatorStore) GetDeadLetter(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.DeadLetterEntry, bool, error) {
|
|
if store == nil || store.client == nil {
|
|
return deliverydomain.DeadLetterEntry{}, false, errors.New("get operator dead-letter entry: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return deliverydomain.DeadLetterEntry{}, false, errors.New("get operator dead-letter entry: nil context")
|
|
}
|
|
if err := deliveryID.Validate(); err != nil {
|
|
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get operator dead-letter entry: %w", err)
|
|
}
|
|
|
|
payload, err := store.client.Get(ctx, store.keys.DeadLetter(deliveryID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return deliverydomain.DeadLetterEntry{}, false, nil
|
|
case err != nil:
|
|
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get operator dead-letter entry: %w", err)
|
|
}
|
|
|
|
entry, err := UnmarshalDeadLetter(payload)
|
|
if err != nil {
|
|
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get operator dead-letter entry: %w", err)
|
|
}
|
|
|
|
return entry, true, nil
|
|
}
|
|
|
|
// GetDeliveryPayload loads one raw accepted attachment bundle by delivery id.
|
|
func (store *OperatorStore) GetDeliveryPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
|
if store == nil || store.client == nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get operator delivery payload: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get operator delivery payload: nil context")
|
|
}
|
|
if err := deliveryID.Validate(); err != nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get operator delivery payload: %w", err)
|
|
}
|
|
|
|
payload, err := store.client.Get(ctx, store.keys.DeliveryPayload(deliveryID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, nil
|
|
case err != nil:
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get operator delivery payload: %w", err)
|
|
}
|
|
|
|
record, err := UnmarshalDeliveryPayload(payload)
|
|
if err != nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get operator delivery payload: %w", err)
|
|
}
|
|
|
|
return record, true, nil
|
|
}
|
|
|
|
// ListAttempts loads exactly expectedCount attempts in ascending attempt
|
|
// number order. Missing attempts are treated as durable-state corruption.
|
|
func (store *OperatorStore) ListAttempts(ctx context.Context, deliveryID common.DeliveryID, expectedCount int) ([]attempt.Attempt, error) {
|
|
if store == nil || store.client == nil {
|
|
return nil, errors.New("list operator attempts: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return nil, errors.New("list operator attempts: nil context")
|
|
}
|
|
if err := deliveryID.Validate(); err != nil {
|
|
return nil, fmt.Errorf("list operator attempts: %w", err)
|
|
}
|
|
if expectedCount < 0 {
|
|
return nil, errors.New("list operator attempts: negative expected count")
|
|
}
|
|
if expectedCount == 0 {
|
|
return []attempt.Attempt{}, nil
|
|
}
|
|
|
|
result := make([]attempt.Attempt, 0, expectedCount)
|
|
for attemptNo := 1; attemptNo <= expectedCount; attemptNo++ {
|
|
payload, err := store.client.Get(ctx, store.keys.Attempt(deliveryID, attemptNo)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return nil, fmt.Errorf("list operator attempts: missing attempt %d for delivery %q", attemptNo, deliveryID)
|
|
case err != nil:
|
|
return nil, fmt.Errorf("list operator attempts: %w", err)
|
|
}
|
|
|
|
record, err := UnmarshalAttempt(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list operator attempts: %w", err)
|
|
}
|
|
result = append(result, record)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// List loads one filtered ordered page of delivery records.
|
|
func (store *OperatorStore) List(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
|
|
if store == nil || store.client == nil {
|
|
return listdeliveries.Result{}, errors.New("list operator deliveries: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return listdeliveries.Result{}, errors.New("list operator deliveries: nil context")
|
|
}
|
|
if err := input.Validate(); err != nil {
|
|
return listdeliveries.Result{}, fmt.Errorf("list operator deliveries: %w", err)
|
|
}
|
|
|
|
selection := chooseListIndex(store.keys, input.Filters)
|
|
if selection.mergeIDempotency {
|
|
return store.listMergedIdempotency(ctx, input, selection.keys)
|
|
}
|
|
|
|
return store.listSingleIndex(ctx, input, selection.keys[0])
|
|
}
|
|
|
|
// CreateResend atomically creates the cloned delivery, its first attempt, and
|
|
// the optional cloned raw payload bundle.
|
|
func (store *OperatorStore) CreateResend(ctx context.Context, input resenddelivery.CreateResendInput) error {
|
|
if store == nil || store.client == nil || store.writer == nil {
|
|
return errors.New("create operator resend: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return errors.New("create operator resend: nil context")
|
|
}
|
|
if err := input.Validate(); err != nil {
|
|
return fmt.Errorf("create operator resend: %w", err)
|
|
}
|
|
|
|
writerInput := CreateAcceptanceInput{
|
|
Delivery: input.Delivery,
|
|
FirstAttempt: &input.FirstAttempt,
|
|
}
|
|
if input.DeliveryPayload != nil {
|
|
writerInput.DeliveryPayload = input.DeliveryPayload
|
|
}
|
|
|
|
if err := store.writer.CreateAcceptance(ctx, writerInput); err != nil {
|
|
return fmt.Errorf("create operator resend: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type listSelection struct {
|
|
keys []string
|
|
mergeIDempotency bool
|
|
}
|
|
|
|
func chooseListIndex(keyspace Keyspace, filters listdeliveries.Filters) listSelection {
|
|
switch {
|
|
case filters.IdempotencyKey != "" && filters.Source != "":
|
|
return listSelection{
|
|
keys: []string{keyspace.IdempotencyIndex(filters.Source, filters.IdempotencyKey)},
|
|
}
|
|
case filters.IdempotencyKey != "":
|
|
return listSelection{
|
|
keys: []string{
|
|
keyspace.IdempotencyIndex(deliverydomain.SourceAuthSession, filters.IdempotencyKey),
|
|
keyspace.IdempotencyIndex(deliverydomain.SourceNotification, filters.IdempotencyKey),
|
|
keyspace.IdempotencyIndex(deliverydomain.SourceOperatorResend, filters.IdempotencyKey),
|
|
},
|
|
mergeIDempotency: true,
|
|
}
|
|
case filters.Recipient != "":
|
|
return listSelection{keys: []string{keyspace.RecipientIndex(filters.Recipient)}}
|
|
case filters.TemplateID != "":
|
|
return listSelection{keys: []string{keyspace.TemplateIndex(filters.TemplateID)}}
|
|
case filters.Status != "":
|
|
return listSelection{keys: []string{keyspace.StatusIndex(filters.Status)}}
|
|
case filters.Source != "":
|
|
return listSelection{keys: []string{keyspace.SourceIndex(filters.Source)}}
|
|
default:
|
|
return listSelection{keys: []string{keyspace.CreatedAtIndex()}}
|
|
}
|
|
}
|
|
|
|
func (store *OperatorStore) listSingleIndex(ctx context.Context, input listdeliveries.Input, indexKey string) (listdeliveries.Result, error) {
|
|
startIndex := int64(0)
|
|
if input.Cursor != nil {
|
|
cursorIndex, err := cursorStartIndex(ctx, store.client, indexKey, *input.Cursor)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, err
|
|
}
|
|
startIndex = cursorIndex
|
|
}
|
|
|
|
items, nextCursor, err := store.collectFromIndex(ctx, indexKey, startIndex, input.Limit, input.Filters)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, err
|
|
}
|
|
|
|
return listdeliveries.Result{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
}, nil
|
|
}
|
|
|
|
func (store *OperatorStore) listMergedIdempotency(ctx context.Context, input listdeliveries.Input, indexKeys []string) (listdeliveries.Result, error) {
|
|
iterators := make([]*redisIndexIterator, 0, len(indexKeys))
|
|
for _, key := range indexKeys {
|
|
iterators = append(iterators, &redisIndexIterator{
|
|
client: store.client,
|
|
indexKey: key,
|
|
batchSize: listBatchSize(input.Limit),
|
|
cursor: input.Cursor,
|
|
})
|
|
}
|
|
|
|
heads := make([]indexedRef, 0, len(iterators))
|
|
for index, iterator := range iterators {
|
|
ref, err := iterator.Next(ctx)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, err
|
|
}
|
|
if ref != nil {
|
|
heads = append(heads, indexedRef{streamIndex: index, ref: *ref})
|
|
}
|
|
}
|
|
|
|
items := make([]deliverydomain.Delivery, 0, input.Limit+1)
|
|
for len(heads) > 0 && len(items) <= input.Limit {
|
|
bestIndex := 0
|
|
for index := 1; index < len(heads); index++ {
|
|
if compareDeliveryOrder(heads[index].ref, heads[bestIndex].ref) < 0 {
|
|
bestIndex = index
|
|
}
|
|
}
|
|
|
|
selected := heads[bestIndex]
|
|
heads = slices.Delete(heads, bestIndex, bestIndex+1)
|
|
|
|
record, found, err := store.GetDelivery(ctx, selected.ref.DeliveryID)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, err
|
|
}
|
|
if found && input.Filters.Matches(record) {
|
|
items = append(items, record)
|
|
}
|
|
|
|
nextRef, err := iterators[selected.streamIndex].Next(ctx)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, err
|
|
}
|
|
if nextRef != nil {
|
|
heads = append(heads, indexedRef{streamIndex: selected.streamIndex, ref: *nextRef})
|
|
}
|
|
}
|
|
|
|
result := listdeliveries.Result{}
|
|
if len(items) > input.Limit {
|
|
next := cursorFromDelivery(items[input.Limit-1])
|
|
result.NextCursor = &next
|
|
items = items[:input.Limit]
|
|
}
|
|
result.Items = items
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (store *OperatorStore) collectFromIndex(
|
|
ctx context.Context,
|
|
indexKey string,
|
|
startIndex int64,
|
|
limit int,
|
|
filters listdeliveries.Filters,
|
|
) ([]deliverydomain.Delivery, *listdeliveries.Cursor, error) {
|
|
items := make([]deliverydomain.Delivery, 0, limit+1)
|
|
batchSize := listBatchSize(limit)
|
|
|
|
for len(items) <= limit {
|
|
batch, err := store.client.ZRevRangeWithScores(ctx, indexKey, startIndex, startIndex+int64(batchSize)-1).Result()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("list operator deliveries: %w", err)
|
|
}
|
|
if len(batch) == 0 {
|
|
break
|
|
}
|
|
|
|
startIndex += int64(len(batch))
|
|
for _, member := range batch {
|
|
deliveryID, err := memberDeliveryID(member.Member)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("list operator deliveries: %w", err)
|
|
}
|
|
|
|
record, found, err := store.GetDelivery(ctx, deliveryID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !found || !filters.Matches(record) {
|
|
continue
|
|
}
|
|
|
|
items = append(items, record)
|
|
if len(items) > limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var nextCursor *listdeliveries.Cursor
|
|
if len(items) > limit {
|
|
next := cursorFromDelivery(items[limit-1])
|
|
nextCursor = &next
|
|
items = items[:limit]
|
|
}
|
|
|
|
return items, nextCursor, nil
|
|
}
|
|
|
|
type indexedRef struct {
|
|
streamIndex int
|
|
ref deliveryRef
|
|
}
|
|
|
|
type deliveryRef struct {
|
|
CreatedAt time.Time
|
|
DeliveryID common.DeliveryID
|
|
}
|
|
|
|
type redisIndexIterator struct {
|
|
client *redis.Client
|
|
indexKey string
|
|
batchSize int
|
|
offset int64
|
|
cursor *listdeliveries.Cursor
|
|
batch []redis.Z
|
|
position int
|
|
}
|
|
|
|
func (iterator *redisIndexIterator) Next(ctx context.Context) (*deliveryRef, error) {
|
|
for {
|
|
if iterator.position >= len(iterator.batch) {
|
|
batch, err := iterator.client.ZRevRangeWithScores(
|
|
ctx,
|
|
iterator.indexKey,
|
|
iterator.offset,
|
|
iterator.offset+int64(iterator.batchSize)-1,
|
|
).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list operator deliveries: %w", err)
|
|
}
|
|
if len(batch) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
iterator.batch = batch
|
|
iterator.position = 0
|
|
iterator.offset += int64(len(batch))
|
|
}
|
|
|
|
ref, err := deliveryRefFromSortedSet(iterator.batch[iterator.position])
|
|
iterator.position++
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list operator deliveries: %w", err)
|
|
}
|
|
if iterator.cursor != nil && !isAfterCursor(ref, *iterator.cursor) {
|
|
continue
|
|
}
|
|
|
|
return &ref, nil
|
|
}
|
|
}
|
|
|
|
func cursorStartIndex(ctx context.Context, client *redis.Client, indexKey string, cursor listdeliveries.Cursor) (int64, error) {
|
|
score, err := client.ZScore(ctx, indexKey, cursor.DeliveryID.String()).Result()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return 0, listdeliveries.ErrInvalidCursor
|
|
case err != nil:
|
|
return 0, fmt.Errorf("list operator deliveries: %w", err)
|
|
}
|
|
if !time.UnixMilli(int64(score)).UTC().Equal(cursor.CreatedAt.UTC()) {
|
|
return 0, listdeliveries.ErrInvalidCursor
|
|
}
|
|
|
|
rank, err := client.ZRevRank(ctx, indexKey, cursor.DeliveryID.String()).Result()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return 0, listdeliveries.ErrInvalidCursor
|
|
case err != nil:
|
|
return 0, fmt.Errorf("list operator deliveries: %w", err)
|
|
default:
|
|
return rank + 1, nil
|
|
}
|
|
}
|
|
|
|
func compareDeliveryOrder(left deliveryRef, right deliveryRef) int {
|
|
switch {
|
|
case left.CreatedAt.After(right.CreatedAt):
|
|
return -1
|
|
case left.CreatedAt.Before(right.CreatedAt):
|
|
return 1
|
|
case left.DeliveryID.String() > right.DeliveryID.String():
|
|
return -1
|
|
case left.DeliveryID.String() < right.DeliveryID.String():
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func isAfterCursor(ref deliveryRef, cursor listdeliveries.Cursor) bool {
|
|
return compareDeliveryOrder(ref, deliveryRef{
|
|
CreatedAt: cursor.CreatedAt.UTC(),
|
|
DeliveryID: cursor.DeliveryID,
|
|
}) > 0
|
|
}
|
|
|
|
func cursorFromDelivery(record deliverydomain.Delivery) listdeliveries.Cursor {
|
|
return listdeliveries.Cursor{
|
|
CreatedAt: record.CreatedAt.UTC(),
|
|
DeliveryID: record.DeliveryID,
|
|
}
|
|
}
|
|
|
|
func deliveryRefFromSortedSet(member redis.Z) (deliveryRef, error) {
|
|
deliveryID, err := memberDeliveryID(member.Member)
|
|
if err != nil {
|
|
return deliveryRef{}, err
|
|
}
|
|
|
|
return deliveryRef{
|
|
CreatedAt: time.UnixMilli(int64(member.Score)).UTC(),
|
|
DeliveryID: deliveryID,
|
|
}, nil
|
|
}
|
|
|
|
func memberDeliveryID(member any) (common.DeliveryID, error) {
|
|
value, ok := member.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("unexpected delivery index member type %T", member)
|
|
}
|
|
|
|
deliveryID := common.DeliveryID(value)
|
|
if err := deliveryID.Validate(); err != nil {
|
|
return "", fmt.Errorf("delivery index member delivery id: %w", err)
|
|
}
|
|
|
|
return deliveryID, nil
|
|
}
|
|
|
|
func listBatchSize(limit int) int {
|
|
size := limit * 4
|
|
if size < limit+1 {
|
|
size = limit + 1
|
|
}
|
|
if size < 100 {
|
|
size = 100
|
|
}
|
|
|
|
return size
|
|
}
|
|
|
|
var _ listdeliveries.Store = (*OperatorStore)(nil)
|
|
var _ listattempts.Store = (*OperatorStore)(nil)
|
|
var _ resenddelivery.Store = (*OperatorStore)(nil)
|