Files
galaxy-game/mail/internal/service/acceptgenericdelivery/service.go
T
2026-04-17 18:39:16 +02:00

599 lines
18 KiB
Go

// Package acceptgenericdelivery implements durable asynchronous acceptance of
// generic delivery commands consumed from Redis Streams.
package acceptgenericdelivery
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"galaxy/mail/internal/api/streamcommand"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/logging"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
oteltrace "go.opentelemetry.io/otel/trace"
)
var (
// ErrConflict reports that the idempotency scope already belongs to a
// different normalized generic request.
ErrConflict = errors.New("accept generic delivery conflict")
// ErrServiceUnavailable reports that durable generic acceptance could not
// be completed or recovered safely.
ErrServiceUnavailable = errors.New("accept generic delivery service unavailable")
)
const tracerName = "galaxy/mail/acceptgenericdelivery"
// Outcome identifies the coarse generic-delivery acceptance outcome.
type Outcome string
const (
// OutcomeAccepted reports that the command was durably accepted into the
// internal delivery pipeline.
OutcomeAccepted Outcome = "accepted"
// OutcomeDuplicate reports that the command matched an already accepted
// idempotent request and therefore became a no-op replay.
OutcomeDuplicate Outcome = "duplicate"
)
// IsKnown reports whether outcome belongs to the supported generic-acceptance
// outcome surface.
func (outcome Outcome) IsKnown() bool {
switch outcome {
case OutcomeAccepted, OutcomeDuplicate:
return true
default:
return false
}
}
// Result stores the coarse generic-delivery acceptance outcome.
type Result struct {
// Outcome stores the stable generic-acceptance result.
Outcome Outcome
}
// Validate reports whether result contains a supported generic-acceptance
// outcome.
func (result Result) Validate() error {
if !result.Outcome.IsKnown() {
return fmt.Errorf("accept generic delivery outcome %q is unsupported", result.Outcome)
}
return nil
}
// AttachmentPayload stores one durably persisted raw attachment payload owned
// by a generic delivery.
type AttachmentPayload struct {
// Filename stores the user-facing attachment filename.
Filename string
// ContentType stores the MIME media type used for SMTP body construction.
ContentType string
// ContentBase64 stores the exact accepted inline base64 payload.
ContentBase64 string
// SizeBytes stores the decoded attachment size in bytes.
SizeBytes int64
}
// Validate reports whether payload contains a complete attachment body.
func (payload AttachmentPayload) Validate() error {
metadata := common.AttachmentMetadata{
Filename: payload.Filename,
ContentType: payload.ContentType,
SizeBytes: payload.SizeBytes,
}
if err := metadata.Validate(); err != nil {
return err
}
decoded, err := base64.StdEncoding.DecodeString(payload.ContentBase64)
if err != nil {
return fmt.Errorf("attachment content_base64 must be valid base64: %w", err)
}
if int64(len(decoded)) != payload.SizeBytes {
return fmt.Errorf(
"attachment size bytes must match decoded content size: got %d, want %d",
payload.SizeBytes,
len(decoded),
)
}
return nil
}
// DeliveryPayload stores the raw attachment payloads that must survive stream
// offset advancement.
type DeliveryPayload struct {
// DeliveryID identifies the owning accepted delivery.
DeliveryID common.DeliveryID
// Attachments stores the raw inline attachment payloads.
Attachments []AttachmentPayload
}
// Validate reports whether payload contains a complete attachment bundle.
func (payload DeliveryPayload) Validate() error {
if err := payload.DeliveryID.Validate(); err != nil {
return fmt.Errorf("delivery payload delivery id: %w", err)
}
if len(payload.Attachments) == 0 {
return fmt.Errorf("delivery payload attachments must not be empty")
}
for index, attachment := range payload.Attachments {
if err := attachment.Validate(); err != nil {
return fmt.Errorf("delivery payload attachments[%d]: %w", index, err)
}
}
return nil
}
// CreateAcceptanceInput stores the durable write set required for one
// generic-delivery acceptance attempt.
type CreateAcceptanceInput struct {
// Delivery stores the accepted delivery record.
Delivery deliverydomain.Delivery
// FirstAttempt stores the first scheduled attempt.
FirstAttempt attempt.Attempt
// DeliveryPayload stores the optional raw attachment payload bundle.
DeliveryPayload *DeliveryPayload
// Idempotency stores the idempotency reservation bound to Delivery.
Idempotency idempotency.Record
}
// Validate reports whether input contains a consistent durable write set.
func (input CreateAcceptanceInput) Validate() error {
if err := input.Delivery.Validate(); err != nil {
return fmt.Errorf("delivery: %w", err)
}
if err := input.FirstAttempt.Validate(); err != nil {
return fmt.Errorf("first attempt: %w", err)
}
if input.FirstAttempt.DeliveryID != input.Delivery.DeliveryID {
return errors.New("first attempt delivery id must match delivery id")
}
if input.FirstAttempt.Status != attempt.StatusScheduled {
return fmt.Errorf("first attempt status must be %q", attempt.StatusScheduled)
}
if err := input.Idempotency.Validate(); err != nil {
return fmt.Errorf("idempotency: %w", err)
}
if input.Idempotency.DeliveryID != input.Delivery.DeliveryID {
return errors.New("idempotency delivery id must match delivery id")
}
if input.Idempotency.Source != input.Delivery.Source {
return errors.New("idempotency source must match delivery source")
}
if input.Idempotency.IdempotencyKey != input.Delivery.IdempotencyKey {
return errors.New("idempotency key must match delivery idempotency key")
}
if input.DeliveryPayload != nil {
if err := input.DeliveryPayload.Validate(); err != nil {
return fmt.Errorf("delivery payload: %w", err)
}
if input.DeliveryPayload.DeliveryID != input.Delivery.DeliveryID {
return errors.New("delivery payload delivery id must match delivery id")
}
}
return nil
}
// Store describes the durable storage required by the generic-delivery use
// case.
type Store interface {
// CreateAcceptance stores the complete durable write set for one generic
// acceptance attempt. Implementations must wrap ErrConflict when the write
// set races with an already accepted idempotency scope or delivery key.
CreateAcceptance(context.Context, CreateAcceptanceInput) error
// GetIdempotency loads the idempotency reservation for one generic-delivery
// scope.
GetIdempotency(context.Context, deliverydomain.Source, common.IdempotencyKey) (idempotency.Record, bool, error)
// GetDelivery loads one accepted delivery by its identifier.
GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error)
}
// Clock provides the current wall-clock time.
type Clock interface {
// Now returns the current time.
Now() time.Time
}
// Telemetry records low-cardinality generic-delivery outcomes.
type Telemetry interface {
// RecordGenericDeliveryOutcome records one coarse generic-acceptance
// outcome.
RecordGenericDeliveryOutcome(context.Context, string)
// RecordAcceptedGenericDelivery records one newly accepted generic
// delivery.
RecordAcceptedGenericDelivery(context.Context)
// RecordDeliveryStatusTransition records one durable delivery status
// transition.
RecordDeliveryStatusTransition(context.Context, string, string)
}
// Config stores the dependencies and policy used by Service.
type Config struct {
// Store owns the durable accepted state.
Store Store
// Clock provides wall-clock timestamps.
Clock Clock
// Telemetry records low-cardinality acceptance outcomes.
Telemetry Telemetry
// TracerProvider constructs the application span recorder used by the
// generic acceptance flow.
TracerProvider oteltrace.TracerProvider
// Logger writes structured generic acceptance logs.
Logger *slog.Logger
// IdempotencyTTL stores how long accepted idempotency scopes remain valid.
IdempotencyTTL time.Duration
}
// Service durably accepts generic asynchronous delivery commands.
type Service struct {
store Store
clock Clock
telemetry Telemetry
tracerProvider oteltrace.TracerProvider
logger *slog.Logger
idempotencyTTL time.Duration
}
// New constructs Service from cfg.
func New(cfg Config) (*Service, error) {
switch {
case cfg.Store == nil:
return nil, errors.New("new accept generic delivery service: nil store")
case cfg.Clock == nil:
return nil, errors.New("new accept generic delivery service: nil clock")
case cfg.IdempotencyTTL <= 0:
return nil, errors.New("new accept generic delivery service: non-positive idempotency ttl")
default:
tracerProvider := cfg.TracerProvider
if tracerProvider == nil {
tracerProvider = otel.GetTracerProvider()
}
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Service{
store: cfg.Store,
clock: cfg.Clock,
telemetry: cfg.Telemetry,
tracerProvider: tracerProvider,
logger: logger.With("component", "accept_generic_delivery"),
idempotencyTTL: cfg.IdempotencyTTL,
}, nil
}
}
// Execute accepts one normalized generic-delivery command.
func (service *Service) Execute(ctx context.Context, command streamcommand.Command) (Result, error) {
if ctx == nil {
return Result{}, errors.New("accept generic delivery: nil context")
}
if service == nil {
return Result{}, errors.New("accept generic delivery: nil service")
}
if err := command.Validate(); err != nil {
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
}
ctx, span := service.tracerProvider.Tracer(tracerName).Start(ctx, "mail.accept_generic_delivery")
defer span.End()
span.SetAttributes(
attribute.String("mail.delivery_id", command.DeliveryID.String()),
attribute.String("mail.source", string(command.Source)),
attribute.String("mail.payload_mode", string(command.PayloadMode)),
)
if strings.TrimSpace(command.TraceID) != "" {
span.SetAttributes(attribute.String("mail.command_trace_id", command.TraceID))
}
if !command.TemplateID.IsZero() {
span.SetAttributes(attribute.String("mail.template_id", command.TemplateID.String()))
}
fingerprint, err := command.Fingerprint()
if err != nil {
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
}
if result, handled, err := service.resolveReplay(ctx, command, fingerprint); handled {
if err != nil {
service.recordOutcome(ctx, replayOutcomeForError(err))
return Result{}, err
}
service.recordOutcome(ctx, string(result.Outcome))
return result, nil
}
createInput, result, err := service.buildCreateInput(command, fingerprint)
if err != nil {
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
}
if err := service.store.CreateAcceptance(ctx, createInput); err != nil {
if !errors.Is(err, ErrConflict) {
service.recordOutcome(ctx, "service_unavailable")
return Result{}, fmt.Errorf("%w: create acceptance: %v", ErrServiceUnavailable, err)
}
if replayResult, handled, replayErr := service.resolveReplay(ctx, command, fingerprint); handled {
if replayErr != nil {
service.recordOutcome(ctx, replayOutcomeForError(replayErr))
return Result{}, replayErr
}
service.recordOutcome(ctx, string(replayResult.Outcome))
return replayResult, nil
}
service.recordOutcome(ctx, "service_unavailable")
return Result{}, fmt.Errorf("%w: create acceptance conflict without replay state", ErrServiceUnavailable)
}
service.recordOutcome(ctx, string(result.Outcome))
service.recordAcceptedDelivery(ctx)
service.recordStatusTransition(ctx, createInput.Delivery)
span.SetAttributes(
attribute.String("mail.status", string(createInput.Delivery.Status)),
attribute.String("mail.outcome", string(result.Outcome)),
)
logArgs := logging.CommandAttrs(command)
logArgs = append(logArgs,
"status", string(createInput.Delivery.Status),
"outcome", string(result.Outcome),
"payload_mode", string(command.PayloadMode),
)
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
service.logger.Info("generic delivery accepted", logArgs...)
return result, nil
}
func (service *Service) buildCreateInput(command streamcommand.Command, fingerprint string) (CreateAcceptanceInput, Result, error) {
now := service.clock.Now().UTC().Truncate(time.Millisecond)
deliveryRecord := deliverydomain.Delivery{
DeliveryID: command.DeliveryID,
Source: command.Source,
PayloadMode: command.PayloadMode,
Envelope: command.Envelope,
Attachments: attachmentMetadata(command.Attachments),
IdempotencyKey: command.IdempotencyKey,
Status: deliverydomain.StatusQueued,
AttemptCount: 1,
CreatedAt: now,
UpdatedAt: now,
}
switch command.PayloadMode {
case deliverydomain.PayloadModeRendered:
deliveryRecord.Content = deliverydomain.Content{
Subject: command.Subject,
TextBody: command.TextBody,
HTMLBody: command.HTMLBody,
}
case deliverydomain.PayloadModeTemplate:
deliveryRecord.TemplateID = command.TemplateID
deliveryRecord.Locale = command.Locale
deliveryRecord.TemplateVariables = cloneJSONObject(command.Variables)
default:
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery record: unsupported payload mode %q", command.PayloadMode)
}
if err := deliveryRecord.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery record: %w", err)
}
firstAttempt := attempt.Attempt{
DeliveryID: command.DeliveryID,
AttemptNo: 1,
ScheduledFor: now,
Status: attempt.StatusScheduled,
}
if err := firstAttempt.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic first attempt: %w", err)
}
createInput := CreateAcceptanceInput{
Delivery: deliveryRecord,
FirstAttempt: firstAttempt,
Idempotency: idempotency.Record{
Source: command.Source,
IdempotencyKey: command.IdempotencyKey,
DeliveryID: command.DeliveryID,
RequestFingerprint: fingerprint,
CreatedAt: now,
ExpiresAt: now.Add(service.idempotencyTTL),
},
}
if len(command.Attachments) > 0 {
createInput.DeliveryPayload = &DeliveryPayload{
DeliveryID: command.DeliveryID,
Attachments: attachmentPayloads(command.Attachments),
}
}
if err := createInput.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic create input: %w", err)
}
result := Result{Outcome: OutcomeAccepted}
if err := result.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery result: %w", err)
}
return createInput, result, nil
}
func (service *Service) resolveReplay(ctx context.Context, command streamcommand.Command, fingerprint string) (Result, bool, error) {
record, found, err := service.store.GetIdempotency(ctx, command.Source, command.IdempotencyKey)
if err != nil {
return Result{}, true, fmt.Errorf("%w: load idempotency: %v", ErrServiceUnavailable, err)
}
if !found {
return Result{}, false, nil
}
if record.RequestFingerprint != fingerprint {
return Result{}, true, fmt.Errorf("%w: request conflicts with current state", ErrConflict)
}
deliveryRecord, found, err := service.store.GetDelivery(ctx, record.DeliveryID)
if err != nil {
return Result{}, true, fmt.Errorf("%w: load delivery: %v", ErrServiceUnavailable, err)
}
if !found {
return Result{}, true, fmt.Errorf("%w: delivery %q is missing for idempotency scope", ErrServiceUnavailable, record.DeliveryID)
}
if deliveryRecord.DeliveryID != command.DeliveryID {
return Result{}, true, fmt.Errorf("%w: idempotency delivery %q mismatches command delivery %q", ErrServiceUnavailable, deliveryRecord.DeliveryID, command.DeliveryID)
}
return deriveReplayResult(deliveryRecord)
}
func deriveReplayResult(record deliverydomain.Delivery) (Result, bool, error) {
switch record.Status {
case deliverydomain.StatusAccepted,
deliverydomain.StatusQueued,
deliverydomain.StatusRendered,
deliverydomain.StatusSending,
deliverydomain.StatusSent,
deliverydomain.StatusSuppressed,
deliverydomain.StatusFailed,
deliverydomain.StatusDeadLetter:
return Result{Outcome: OutcomeDuplicate}, true, nil
default:
return Result{}, true, fmt.Errorf("%w: unsupported replay delivery status %q", ErrServiceUnavailable, record.Status)
}
}
func (service *Service) recordAcceptedDelivery(ctx context.Context) {
if service == nil || service.telemetry == nil {
return
}
service.telemetry.RecordAcceptedGenericDelivery(ctx)
}
func (service *Service) recordStatusTransition(ctx context.Context, record deliverydomain.Delivery) {
if service == nil || service.telemetry == nil {
return
}
service.telemetry.RecordDeliveryStatusTransition(ctx, string(record.Status), string(record.Source))
}
func (service *Service) recordOutcome(ctx context.Context, outcome string) {
if service == nil || service.telemetry == nil || strings.TrimSpace(outcome) == "" {
return
}
service.telemetry.RecordGenericDeliveryOutcome(ctx, outcome)
}
func replayOutcomeForError(err error) string {
switch {
case errors.Is(err, ErrConflict):
return "conflict"
case errors.Is(err, ErrServiceUnavailable):
return "service_unavailable"
default:
return ""
}
}
func attachmentMetadata(values []streamcommand.Attachment) []common.AttachmentMetadata {
if values == nil {
return nil
}
result := make([]common.AttachmentMetadata, len(values))
for index, value := range values {
result[index] = common.AttachmentMetadata{
Filename: value.Filename,
ContentType: value.ContentType,
SizeBytes: value.SizeBytes,
}
}
return result
}
func attachmentPayloads(values []streamcommand.Attachment) []AttachmentPayload {
result := make([]AttachmentPayload, len(values))
for index, value := range values {
result[index] = AttachmentPayload{
Filename: value.Filename,
ContentType: value.ContentType,
ContentBase64: value.ContentBase64,
SizeBytes: value.SizeBytes,
}
}
return result
}
func cloneJSONObject(value map[string]any) map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(value))
for key, item := range value {
cloned[key] = cloneJSONValue(item)
}
return cloned
}
func cloneJSONValue(value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for key, item := range typed {
cloned[key] = cloneJSONValue(item)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = cloneJSONValue(item)
}
return cloned
default:
return typed
}
}