// Package acceptintent implements durable idempotent acceptance of normalized // notification intents. package acceptintent import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" netmail "net/mail" "strings" "time" "galaxy/notification/internal/api/intentstream" "galaxy/notification/internal/config" "galaxy/notification/internal/logging" ) var ( // ErrConflict reports that an idempotency scope already exists for // different normalized content. ErrConflict = errors.New("accept intent conflict") // ErrRecipientNotFound reports that at least one user-targeted recipient // does not exist in the trusted User Service directory. ErrRecipientNotFound = errors.New("accept intent recipient not found") // ErrServiceUnavailable reports that durable acceptance could not be // completed or recovered safely. ErrServiceUnavailable = errors.New("accept intent service unavailable") ) // Outcome identifies the coarse intent-acceptance outcome. type Outcome string const ( // OutcomeAccepted reports that the intent was durably accepted into local // notification state. OutcomeAccepted Outcome = "accepted" // OutcomeDuplicate reports that the intent matched already accepted // normalized content and therefore became a replay no-op. OutcomeDuplicate Outcome = "duplicate" ) // RouteStatus identifies one stable notification-route state. type RouteStatus string const ( // RouteStatusPending reports that the route is ready for first publication. RouteStatusPending RouteStatus = "pending" // RouteStatusPublished reports that the route was durably handed off. RouteStatusPublished RouteStatus = "published" // RouteStatusFailed reports that the last publish attempt failed and a // retry is scheduled. RouteStatusFailed RouteStatus = "failed" // RouteStatusDeadLetter reports that the route exhausted its retry budget. RouteStatusDeadLetter RouteStatus = "dead_letter" // RouteStatusSkipped reports that the route slot was durably materialized // but intentionally not emitted. RouteStatusSkipped RouteStatus = "skipped" ) // Result stores the coarse outcome of one intent-acceptance attempt. type Result struct { // Outcome stores the stable intent-acceptance outcome. Outcome Outcome } // NotificationRecord stores the primary durable notification record accepted // from one normalized intent. type NotificationRecord struct { // NotificationID stores the stable notification identifier. NotificationID string // NotificationType stores the frozen notification vocabulary value. NotificationType intentstream.NotificationType // Producer stores the frozen producer identifier. Producer intentstream.Producer // AudienceKind stores the normalized audience selector. AudienceKind intentstream.AudienceKind // RecipientUserIDs stores the normalized recipient user set for // user-targeted intents. RecipientUserIDs []string // PayloadJSON stores the canonical normalized payload JSON string. PayloadJSON string // IdempotencyKey stores the producer-owned idempotency key. IdempotencyKey string // RequestFingerprint stores the stable normalized request fingerprint. RequestFingerprint string // RequestID stores the optional tracing request identifier. RequestID string // TraceID stores the optional tracing trace identifier. TraceID string // OccurredAt stores when the producer says the event happened. OccurredAt time.Time // AcceptedAt stores when Notification Service durably accepted the intent. AcceptedAt time.Time // UpdatedAt stores the last notification-record mutation timestamp. UpdatedAt time.Time } // NotificationRoute stores one durable route slot derived from an accepted // notification. type NotificationRoute struct { // NotificationID stores the owning notification identifier. NotificationID string // RouteID stores the stable `:` identifier. RouteID string // Channel stores the route channel slot. Channel intentstream.Channel // RecipientRef stores the stable target slot identifier. RecipientRef string // Status stores the current route status. Status RouteStatus // AttemptCount stores how many publication attempts already ran. AttemptCount int // MaxAttempts stores the total retry budget for Channel. MaxAttempts int // NextAttemptAt stores the next scheduled publication time when Status is // RouteStatusPending or RouteStatusFailed. NextAttemptAt time.Time // ResolvedEmail stores the already-known email target when available. ResolvedEmail string // ResolvedLocale stores the already-known locale when available. ResolvedLocale string // LastErrorClassification stores the optional last classified route error. LastErrorClassification string // LastErrorMessage stores the optional last route error message. LastErrorMessage string // LastErrorAt stores when the last route error happened. LastErrorAt time.Time // CreatedAt stores when the route was materialized. CreatedAt time.Time // UpdatedAt stores the last route mutation timestamp. UpdatedAt time.Time // PublishedAt stores when the route reached published. PublishedAt time.Time // DeadLetteredAt stores when the route reached dead_letter. DeadLetteredAt time.Time // SkippedAt stores when the route reached skipped. SkippedAt time.Time } // IdempotencyRecord stores one durable `(producer, idempotency_key)` // reservation. type IdempotencyRecord struct { // Producer stores the owning producer identifier. Producer intentstream.Producer // IdempotencyKey stores the producer-owned idempotency key. IdempotencyKey string // NotificationID stores the accepted notification identifier. NotificationID string // RequestFingerprint stores the stable normalized request fingerprint. RequestFingerprint string // CreatedAt stores when the reservation was created. CreatedAt time.Time // ExpiresAt stores when the reservation expires. ExpiresAt time.Time } // AcceptInput stores one normalized intent plus its chosen notification // identifier. type AcceptInput struct { // NotificationID stores the stable accepted notification identifier. NotificationID string // Intent stores the normalized decoded ingress intent. Intent intentstream.Intent } // CreateAcceptanceInput stores the durable write set required to accept one // notification intent. type CreateAcceptanceInput struct { // Notification stores the accepted notification record. Notification NotificationRecord // Routes stores every durable route slot derived from Notification. Routes []NotificationRoute // Idempotency stores the idempotency reservation bound to Notification. Idempotency IdempotencyRecord } // Store describes the durable storage required by the intent-acceptance use // case. type Store interface { // CreateAcceptance stores the complete durable write set for one intent // acceptance attempt. Implementations must wrap ErrConflict when the write // set races with already accepted state. CreateAcceptance(context.Context, CreateAcceptanceInput) error // GetIdempotency loads one existing idempotency reservation. GetIdempotency(context.Context, intentstream.Producer, string) (IdempotencyRecord, bool, error) // GetNotification loads one accepted notification by NotificationID. GetNotification(context.Context, string) (NotificationRecord, bool, error) } // UserRecord stores the enrichment data resolved for one recipient user. type UserRecord struct { // Email stores the current user email address. Email string // PreferredLanguage stores the current user preferred language tag. PreferredLanguage string } // Validate reports whether record contains usable recipient enrichment data. func (record UserRecord) Validate() error { if strings.TrimSpace(record.Email) == "" { return errors.New("user record email must not be empty") } if _, err := netmail.ParseAddress(record.Email); err != nil { return fmt.Errorf("user record email: %w", err) } return nil } // UserDirectory resolves trusted recipient data from User Service. Missing // users must wrap ErrRecipientNotFound. Other failures are treated as // dependency unavailability. type UserDirectory interface { // GetUserByID loads one user by stable user identifier. GetUserByID(context.Context, string) (UserRecord, error) } // Telemetry records low-cardinality intent-acceptance and user-enrichment // outcomes. type Telemetry interface { // RecordIntentOutcome records one accepted notification-intent outcome. RecordIntentOutcome(context.Context, string, string, string, string) // RecordUserEnrichmentAttempt records one User Service enrichment lookup // outcome. RecordUserEnrichmentAttempt(context.Context, string, string) } // Clock provides the current wall-clock time. type Clock interface { // Now returns the current time. Now() time.Time } type systemClock struct{} func (systemClock) Now() time.Time { return time.Now() } // Config stores the dependencies and policies used by Service. type Config struct { // Store owns the durable accepted state. Store Store // UserDirectory resolves recipient email and locale from User Service. UserDirectory UserDirectory // Clock provides wall-clock timestamps. Clock Clock // Logger writes structured acceptance logs. Logger *slog.Logger // Telemetry records low-cardinality acceptance and enrichment outcomes. Telemetry Telemetry // PushMaxAttempts stores the retry budget for push routes. PushMaxAttempts int // EmailMaxAttempts stores the retry budget for email routes. EmailMaxAttempts int // IdempotencyTTL stores how long accepted idempotency scopes remain valid. IdempotencyTTL time.Duration // AdminRouting stores the type-specific administrator email lists. AdminRouting config.AdminRoutingConfig } // Service durably accepts normalized notification intents. type Service struct { store Store userDirectory UserDirectory clock Clock logger *slog.Logger telemetry Telemetry pushMaxAttempts int emailMaxAttempts int idempotencyTTL time.Duration adminRouting config.AdminRoutingConfig } // New constructs Service from cfg. func New(cfg Config) (*Service, error) { if cfg.Store == nil { return nil, errors.New("new accept intent service: nil store") } if cfg.UserDirectory == nil { return nil, errors.New("new accept intent service: nil user directory") } if cfg.Clock == nil { cfg.Clock = systemClock{} } if cfg.PushMaxAttempts <= 0 { return nil, errors.New("new accept intent service: push max attempts must be positive") } if cfg.EmailMaxAttempts <= 0 { return nil, errors.New("new accept intent service: email max attempts must be positive") } if cfg.IdempotencyTTL <= 0 { return nil, errors.New("new accept intent service: idempotency ttl must be positive") } if cfg.Logger == nil { cfg.Logger = slog.Default() } if err := cfg.AdminRouting.Validate(); err != nil { return nil, fmt.Errorf("new accept intent service: %w", err) } return &Service{ store: cfg.Store, userDirectory: cfg.UserDirectory, clock: cfg.Clock, logger: cfg.Logger.With("component", "accept_intent"), telemetry: cfg.Telemetry, pushMaxAttempts: cfg.PushMaxAttempts, emailMaxAttempts: cfg.EmailMaxAttempts, idempotencyTTL: cfg.IdempotencyTTL, adminRouting: cfg.AdminRouting, }, nil } // Execute durably accepts one normalized intent. func (service *Service) Execute(ctx context.Context, input AcceptInput) (Result, error) { if ctx == nil { return Result{}, errors.New("accept intent: nil context") } if service == nil { return Result{}, errors.New("accept intent: nil service") } if err := input.Validate(); err != nil { return Result{}, fmt.Errorf("accept intent: %w", err) } fingerprint, err := requestFingerprint(input.Intent) if err != nil { return Result{}, fmt.Errorf("accept intent: %w", err) } if result, handled, err := service.resolveReplay(ctx, input, fingerprint); handled { return result, err } createInput, result, err := service.buildCreateInput(ctx, input, fingerprint) if err != nil { switch { case errors.Is(err, ErrRecipientNotFound): return Result{}, err case errors.Is(err, ErrServiceUnavailable): return Result{}, err default: return Result{}, fmt.Errorf("accept intent: %w", err) } } if err := service.store.CreateAcceptance(ctx, createInput); err != nil { if !errors.Is(err, ErrConflict) { return Result{}, fmt.Errorf("%w: create acceptance: %v", ErrServiceUnavailable, err) } if replayResult, handled, replayErr := service.resolveReplay(ctx, input, fingerprint); handled { return replayResult, replayErr } return Result{}, fmt.Errorf("%w: create acceptance conflict without replay state", ErrServiceUnavailable) } service.recordIntentOutcome(ctx, createInput.Notification, string(result.Outcome)) logArgs := logging.NotificationAttrs( createInput.Notification.NotificationID, createInput.Notification.NotificationType, createInput.Notification.Producer, createInput.Notification.AudienceKind, createInput.Notification.IdempotencyKey, createInput.Notification.RequestID, createInput.Notification.TraceID, ) logArgs = append(logArgs, "route_count", len(createInput.Routes), "outcome", string(result.Outcome), ) logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...) service.logger.Info("notification intent accepted", logArgs...) return result, nil } // Validate reports whether result stores a supported intent-acceptance // outcome. func (result Result) Validate() error { switch result.Outcome { case OutcomeAccepted, OutcomeDuplicate: return nil default: return fmt.Errorf("accept intent outcome %q is unsupported", result.Outcome) } } // Validate reports whether input contains a usable acceptance request. func (input AcceptInput) Validate() error { if strings.TrimSpace(input.NotificationID) == "" { return errors.New("accept input notification id must not be empty") } if err := input.Intent.Validate(); err != nil { return fmt.Errorf("accept input intent: %w", err) } return nil } // Validate reports whether record contains a complete notification record. func (record NotificationRecord) Validate() error { if strings.TrimSpace(record.NotificationID) == "" { return errors.New("notification record notification id must not be empty") } if !record.NotificationType.IsKnown() { return fmt.Errorf("notification record type %q is unsupported", record.NotificationType) } if !record.Producer.IsKnown() { return fmt.Errorf("notification record producer %q is unsupported", record.Producer) } if !record.AudienceKind.IsKnown() { return fmt.Errorf("notification record audience kind %q is unsupported", record.AudienceKind) } if strings.TrimSpace(record.PayloadJSON) == "" { return errors.New("notification record payload json must not be empty") } if strings.TrimSpace(record.IdempotencyKey) == "" { return errors.New("notification record idempotency key must not be empty") } if strings.TrimSpace(record.RequestFingerprint) == "" { return errors.New("notification record request fingerprint must not be empty") } if err := validateTimestamp("notification record occurred at", record.OccurredAt); err != nil { return err } if err := validateTimestamp("notification record accepted at", record.AcceptedAt); err != nil { return err } if err := validateTimestamp("notification record updated at", record.UpdatedAt); err != nil { return err } if record.AudienceKind == intentstream.AudienceKindUser && len(record.RecipientUserIDs) == 0 { return errors.New("notification record recipient user ids must not be empty for audience kind user") } if record.AudienceKind == intentstream.AudienceKindAdminEmail && len(record.RecipientUserIDs) > 0 { return errors.New("notification record recipient user ids must be empty for audience kind admin_email") } return nil } // Validate reports whether route contains a complete route record. func (route NotificationRoute) Validate() error { if strings.TrimSpace(route.NotificationID) == "" { return errors.New("notification route notification id must not be empty") } if strings.TrimSpace(route.RouteID) == "" { return errors.New("notification route route id must not be empty") } if !route.Channel.IsKnown() { return fmt.Errorf("notification route channel %q is unsupported", route.Channel) } if strings.TrimSpace(route.RecipientRef) == "" { return errors.New("notification route recipient ref must not be empty") } if !route.Status.IsKnown() { return fmt.Errorf("notification route status %q is unsupported", route.Status) } if route.AttemptCount < 0 { return errors.New("notification route attempt count must not be negative") } if route.MaxAttempts <= 0 { return errors.New("notification route max attempts must be positive") } if err := validateTimestamp("notification route created at", route.CreatedAt); err != nil { return err } if err := validateTimestamp("notification route updated at", route.UpdatedAt); err != nil { return err } switch route.Status { case RouteStatusPending, RouteStatusFailed: if err := validateTimestamp("notification route next attempt at", route.NextAttemptAt); err != nil { return err } case RouteStatusSkipped: if !route.NextAttemptAt.IsZero() { return errors.New("notification route next attempt at must be zero for skipped routes") } if err := validateTimestamp("notification route skipped at", route.SkippedAt); err != nil { return err } } return nil } // IsKnown reports whether status belongs to the frozen route-status surface. func (status RouteStatus) IsKnown() bool { switch status { case RouteStatusPending, RouteStatusPublished, RouteStatusFailed, RouteStatusDeadLetter, RouteStatusSkipped: return true default: return false } } // Validate reports whether record contains a complete idempotency record. func (record IdempotencyRecord) Validate() error { if !record.Producer.IsKnown() { return fmt.Errorf("idempotency record producer %q is unsupported", record.Producer) } if strings.TrimSpace(record.IdempotencyKey) == "" { return errors.New("idempotency record idempotency key must not be empty") } if strings.TrimSpace(record.NotificationID) == "" { return errors.New("idempotency record notification id must not be empty") } if strings.TrimSpace(record.RequestFingerprint) == "" { return errors.New("idempotency record request fingerprint must not be empty") } if err := validateTimestamp("idempotency record created at", record.CreatedAt); err != nil { return err } if err := validateTimestamp("idempotency record expires at", record.ExpiresAt); err != nil { return err } if !record.ExpiresAt.After(record.CreatedAt) { return errors.New("idempotency record expires at must be after created at") } return nil } // Validate reports whether input contains a consistent durable write set. func (input CreateAcceptanceInput) Validate() error { if err := input.Notification.Validate(); err != nil { return fmt.Errorf("notification: %w", err) } if err := input.Idempotency.Validate(); err != nil { return fmt.Errorf("idempotency: %w", err) } if input.Idempotency.NotificationID != input.Notification.NotificationID { return errors.New("idempotency notification id must match notification record") } if input.Idempotency.Producer != input.Notification.Producer { return errors.New("idempotency producer must match notification record") } if input.Idempotency.IdempotencyKey != input.Notification.IdempotencyKey { return errors.New("idempotency key must match notification record") } if input.Idempotency.RequestFingerprint != input.Notification.RequestFingerprint { return errors.New("idempotency request fingerprint must match notification record") } seenRouteIDs := make(map[string]struct{}, len(input.Routes)) for index, route := range input.Routes { if err := route.Validate(); err != nil { return fmt.Errorf("routes[%d]: %w", index, err) } if route.NotificationID != input.Notification.NotificationID { return fmt.Errorf("routes[%d]: notification id must match notification record", index) } if _, ok := seenRouteIDs[route.RouteID]; ok { return fmt.Errorf("routes[%d]: route id %q is duplicated", index, route.RouteID) } seenRouteIDs[route.RouteID] = struct{}{} if input.Notification.AudienceKind == intentstream.AudienceKindUser { if !strings.HasPrefix(route.RecipientRef, "user:") { return fmt.Errorf("routes[%d]: recipient ref must use user: prefix for audience kind user", index) } if strings.TrimSpace(route.ResolvedEmail) == "" { return fmt.Errorf("routes[%d]: resolved email must not be empty for audience kind user", index) } if strings.TrimSpace(route.ResolvedLocale) == "" { return fmt.Errorf("routes[%d]: resolved locale must not be empty for audience kind user", index) } } } return nil } func (service *Service) buildCreateInput(ctx context.Context, input AcceptInput, fingerprint string) (CreateAcceptanceInput, Result, error) { now := service.clock.Now().UTC().Truncate(time.Millisecond) record := NotificationRecord{ NotificationID: input.NotificationID, NotificationType: input.Intent.NotificationType, Producer: input.Intent.Producer, AudienceKind: input.Intent.AudienceKind, RecipientUserIDs: append([]string(nil), input.Intent.RecipientUserIDs...), PayloadJSON: input.Intent.PayloadJSON, IdempotencyKey: input.Intent.IdempotencyKey, RequestFingerprint: fingerprint, RequestID: input.Intent.RequestID, TraceID: input.Intent.TraceID, OccurredAt: input.Intent.OccurredAt, AcceptedAt: now, UpdatedAt: now, } routes, err := service.materializeRoutes(ctx, record, now) if err != nil { return CreateAcceptanceInput{}, Result{}, fmt.Errorf("materialize routes: %w", err) } createInput := CreateAcceptanceInput{ Notification: record, Routes: routes, Idempotency: IdempotencyRecord{ Producer: record.Producer, IdempotencyKey: record.IdempotencyKey, NotificationID: record.NotificationID, RequestFingerprint: fingerprint, CreatedAt: now, ExpiresAt: now.Add(service.idempotencyTTL), }, } if err := createInput.Validate(); err != nil { return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build create acceptance input: %w", err) } result := Result{Outcome: OutcomeAccepted} if err := result.Validate(); err != nil { return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build acceptance result: %w", err) } return createInput, result, nil } func (service *Service) materializeRoutes(ctx context.Context, record NotificationRecord, now time.Time) ([]NotificationRoute, error) { switch record.AudienceKind { case intentstream.AudienceKindUser: recipients, err := service.resolveRecipients(ctx, record.NotificationType, record.RecipientUserIDs) if err != nil { return nil, err } routes := make([]NotificationRoute, 0, len(record.RecipientUserIDs)*2) for _, userID := range record.RecipientUserIDs { recipient := recipients[userID] recipientRef := "user:" + userID routes = append(routes, service.newRoute(record, now, intentstream.ChannelPush, recipientRef, recipient.Email, resolveLocale(recipient.PreferredLanguage)), service.newRoute(record, now, intentstream.ChannelEmail, recipientRef, recipient.Email, resolveLocale(recipient.PreferredLanguage)), ) } return routes, nil case intentstream.AudienceKindAdminEmail: adminEmails := service.adminEmailsFor(record.NotificationType) if len(adminEmails) == 0 { return []NotificationRoute{ service.newSyntheticAdminConfigRoute(record, now), }, nil } routes := make([]NotificationRoute, 0, len(adminEmails)*2) for _, email := range adminEmails { recipientRef := "email:" + email routes = append(routes, service.newRoute(record, now, intentstream.ChannelPush, recipientRef, email, intentstream.DefaultResolvedLocale()), service.newRoute(record, now, intentstream.ChannelEmail, recipientRef, email, intentstream.DefaultResolvedLocale()), ) } return routes, nil default: return nil, fmt.Errorf("unsupported audience kind %q", record.AudienceKind) } } func (service *Service) resolveRecipients(ctx context.Context, notificationType intentstream.NotificationType, userIDs []string) (map[string]UserRecord, error) { recipients := make(map[string]UserRecord, len(userIDs)) for _, userID := range userIDs { record, err := service.userDirectory.GetUserByID(ctx, userID) switch { case err == nil: if err := record.Validate(); err != nil { service.recordUserEnrichmentAttempt(ctx, notificationType, "service_unavailable") return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrServiceUnavailable, userID, err) } service.recordUserEnrichmentAttempt(ctx, notificationType, "success") recipients[userID] = record case errors.Is(err, ErrRecipientNotFound): service.recordUserEnrichmentAttempt(ctx, notificationType, "recipient_not_found") return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrRecipientNotFound, userID, err) default: service.recordUserEnrichmentAttempt(ctx, notificationType, "service_unavailable") return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrServiceUnavailable, userID, err) } } return recipients, nil } func (service *Service) newRoute( record NotificationRecord, now time.Time, channel intentstream.Channel, recipientRef string, resolvedEmail string, resolvedLocale string, ) NotificationRoute { route := NotificationRoute{ NotificationID: record.NotificationID, RouteID: string(channel) + ":" + recipientRef, Channel: channel, RecipientRef: recipientRef, AttemptCount: 0, MaxAttempts: service.maxAttempts(channel), ResolvedEmail: resolvedEmail, ResolvedLocale: resolvedLocale, CreatedAt: now, UpdatedAt: now, } if record.NotificationType.SupportsChannel(record.AudienceKind, channel) { route.Status = RouteStatusPending route.NextAttemptAt = now return route } route.Status = RouteStatusSkipped route.SkippedAt = now return route } func (service *Service) newSyntheticAdminConfigRoute(record NotificationRecord, now time.Time) NotificationRoute { recipientRef := "config:" + string(record.NotificationType) return NotificationRoute{ NotificationID: record.NotificationID, RouteID: string(intentstream.ChannelEmail) + ":" + recipientRef, Channel: intentstream.ChannelEmail, RecipientRef: recipientRef, Status: RouteStatusSkipped, AttemptCount: 0, MaxAttempts: service.emailMaxAttempts, CreatedAt: now, UpdatedAt: now, SkippedAt: now, } } func (service *Service) adminEmailsFor(notificationType intentstream.NotificationType) []string { switch notificationType { case intentstream.NotificationTypeGeoReviewRecommended: return append([]string(nil), service.adminRouting.GeoReviewRecommended...) case intentstream.NotificationTypeGameGenerationFailed: return append([]string(nil), service.adminRouting.GameGenerationFailed...) case intentstream.NotificationTypeLobbyRuntimePausedAfterStart: return append([]string(nil), service.adminRouting.LobbyRuntimePausedAfterStart...) case intentstream.NotificationTypeLobbyApplicationSubmitted: return append([]string(nil), service.adminRouting.LobbyApplicationSubmitted...) default: return nil } } func (service *Service) maxAttempts(channel intentstream.Channel) int { switch channel { case intentstream.ChannelPush: return service.pushMaxAttempts case intentstream.ChannelEmail: return service.emailMaxAttempts default: return 0 } } func resolveLocale(preferredLanguage string) string { if preferredLanguage == intentstream.DefaultResolvedLocale() { return intentstream.DefaultResolvedLocale() } return intentstream.DefaultResolvedLocale() } func (service *Service) resolveReplay(ctx context.Context, input AcceptInput, fingerprint string) (Result, bool, error) { record, found, err := service.store.GetIdempotency(ctx, input.Intent.Producer, input.Intent.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) } notificationRecord, found, err := service.store.GetNotification(ctx, record.NotificationID) if err != nil { return Result{}, true, fmt.Errorf("%w: load notification: %v", ErrServiceUnavailable, err) } if !found { return Result{}, true, fmt.Errorf("%w: notification %q is missing for idempotency scope", ErrServiceUnavailable, record.NotificationID) } if notificationRecord.NotificationID != record.NotificationID { return Result{}, true, fmt.Errorf("%w: replay notification id mismatch", ErrServiceUnavailable) } result := Result{Outcome: OutcomeDuplicate} if err := result.Validate(); err != nil { return Result{}, true, fmt.Errorf("%w: %v", ErrServiceUnavailable, err) } service.recordIntentOutcome(ctx, notificationRecord, string(result.Outcome)) logArgs := logging.NotificationAttrs( notificationRecord.NotificationID, notificationRecord.NotificationType, notificationRecord.Producer, notificationRecord.AudienceKind, notificationRecord.IdempotencyKey, notificationRecord.RequestID, notificationRecord.TraceID, ) logArgs = append(logArgs, "outcome", string(result.Outcome), ) logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...) service.logger.Info("notification intent replay resolved", logArgs...) return result, true, nil } func requestFingerprint(intent intentstream.Intent) (string, error) { if err := intent.Validate(); err != nil { return "", err } normalized := struct { NotificationType intentstream.NotificationType `json:"notification_type"` AudienceKind intentstream.AudienceKind `json:"audience_kind"` RecipientUserIDs []string `json:"recipient_user_ids,omitempty"` PayloadJSON json.RawMessage `json:"payload_json"` }{ NotificationType: intent.NotificationType, AudienceKind: intent.AudienceKind, RecipientUserIDs: append([]string(nil), intent.RecipientUserIDs...), PayloadJSON: json.RawMessage(intent.PayloadJSON), } payload, err := json.Marshal(normalized) if err != nil { return "", fmt.Errorf("marshal request fingerprint: %w", err) } sum := sha256.Sum256(payload) return "sha256:" + hex.EncodeToString(sum[:]), nil } func (service *Service) recordIntentOutcome(ctx context.Context, record NotificationRecord, outcome string) { if service == nil || service.telemetry == nil || strings.TrimSpace(outcome) == "" { return } service.telemetry.RecordIntentOutcome( ctx, string(record.NotificationType), string(record.Producer), string(record.AudienceKind), outcome, ) } func (service *Service) recordUserEnrichmentAttempt(ctx context.Context, notificationType intentstream.NotificationType, result string) { if service == nil || service.telemetry == nil || strings.TrimSpace(result) == "" { return } service.telemetry.RecordUserEnrichmentAttempt(ctx, string(notificationType), result) } func validateTimestamp(name string, value time.Time) error { if value.IsZero() { return fmt.Errorf("%s must not be zero", name) } if !value.Equal(value.UTC()) { return fmt.Errorf("%s must be UTC", name) } if !value.Equal(value.Truncate(time.Millisecond)) { return fmt.Errorf("%s must use millisecond precision", name) } return nil }