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