Files
galaxy-game/notification/internal/adapters/redisstate/codecs.go
T
2026-04-22 08:49:45 +02:00

548 lines
20 KiB
Go

package redisstate
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
"galaxy/notification/internal/service/malformedintent"
)
// StreamOffset stores the persisted progress of the plain-XREAD intent
// consumer.
type StreamOffset struct {
// Stream stores the Redis Stream name.
Stream string
// LastProcessedEntryID stores the last durably processed Redis Stream entry
// identifier.
LastProcessedEntryID string
// UpdatedAt stores when the offset record was last updated.
UpdatedAt time.Time
}
// DeadLetterEntry stores one terminal route-publication failure recorded for
// later operator inspection.
type DeadLetterEntry struct {
// NotificationID stores the owning notification identifier.
NotificationID string
// RouteID stores the exhausted route identifier.
RouteID string
// Channel stores the failed route channel.
Channel intentstream.Channel
// RecipientRef stores the stable failed recipient slot identifier.
RecipientRef string
// FinalAttemptCount stores how many publication attempts were consumed.
FinalAttemptCount int
// MaxAttempts stores the configured retry budget for Channel.
MaxAttempts int
// FailureClassification stores the stable classified failure reason.
FailureClassification string
// FailureMessage stores the last failure detail.
FailureMessage string
// CreatedAt stores when the route moved to dead_letter.
CreatedAt time.Time
// RecoveryHint stores the optional operator-facing recovery hint.
RecoveryHint string
}
type notificationRecordJSON struct {
NotificationID string `json:"notification_id"`
NotificationType intentstream.NotificationType `json:"notification_type"`
Producer intentstream.Producer `json:"producer"`
AudienceKind intentstream.AudienceKind `json:"audience_kind"`
RecipientUserIDs []string `json:"recipient_user_ids,omitempty"`
PayloadJSON string `json:"payload_json"`
IdempotencyKey string `json:"idempotency_key"`
RequestFingerprint string `json:"request_fingerprint"`
RequestID string `json:"request_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
OccurredAtMS int64 `json:"occurred_at_ms"`
AcceptedAtMS int64 `json:"accepted_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type notificationRouteJSON struct {
NotificationID string `json:"notification_id"`
RouteID string `json:"route_id"`
Channel intentstream.Channel `json:"channel"`
RecipientRef string `json:"recipient_ref"`
Status acceptintent.RouteStatus `json:"status"`
AttemptCount int `json:"attempt_count"`
MaxAttempts int `json:"max_attempts"`
NextAttemptAtMS *int64 `json:"next_attempt_at_ms,omitempty"`
ResolvedEmail string `json:"resolved_email,omitempty"`
ResolvedLocale string `json:"resolved_locale,omitempty"`
LastErrorClassification string `json:"last_error_classification,omitempty"`
LastErrorMessage string `json:"last_error_message,omitempty"`
LastErrorAtMS *int64 `json:"last_error_at_ms,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
PublishedAtMS *int64 `json:"published_at_ms,omitempty"`
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
SkippedAtMS *int64 `json:"skipped_at_ms,omitempty"`
}
type idempotencyRecordJSON struct {
Producer intentstream.Producer `json:"producer"`
IdempotencyKey string `json:"idempotency_key"`
NotificationID string `json:"notification_id"`
RequestFingerprint string `json:"request_fingerprint"`
CreatedAtMS int64 `json:"created_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
}
type malformedIntentJSON struct {
StreamEntryID string `json:"stream_entry_id"`
NotificationType string `json:"notification_type,omitempty"`
Producer string `json:"producer,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
FailureCode malformedintent.FailureCode `json:"failure_code"`
FailureMessage string `json:"failure_message"`
RawFields map[string]any `json:"raw_fields_json"`
RecordedAtMS int64 `json:"recorded_at_ms"`
}
type streamOffsetJSON struct {
Stream string `json:"stream"`
LastProcessedEntryID string `json:"last_processed_entry_id"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type deadLetterEntryJSON struct {
NotificationID string `json:"notification_id"`
RouteID string `json:"route_id"`
Channel intentstream.Channel `json:"channel"`
RecipientRef string `json:"recipient_ref"`
FinalAttemptCount int `json:"final_attempt_count"`
MaxAttempts int `json:"max_attempts"`
FailureClassification string `json:"failure_classification"`
FailureMessage string `json:"failure_message"`
CreatedAtMS int64 `json:"created_at_ms"`
RecoveryHint string `json:"recovery_hint,omitempty"`
}
// MarshalNotification marshals one notification record into the strict JSON
// representation owned by Notification Service.
func MarshalNotification(record acceptintent.NotificationRecord) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal notification record: %w", err)
}
return marshalStrictJSON(notificationRecordJSON{
NotificationID: record.NotificationID,
NotificationType: record.NotificationType,
Producer: record.Producer,
AudienceKind: record.AudienceKind,
RecipientUserIDs: append([]string(nil), record.RecipientUserIDs...),
PayloadJSON: record.PayloadJSON,
IdempotencyKey: record.IdempotencyKey,
RequestFingerprint: record.RequestFingerprint,
RequestID: record.RequestID,
TraceID: record.TraceID,
OccurredAtMS: unixMilli(record.OccurredAt),
AcceptedAtMS: unixMilli(record.AcceptedAt),
UpdatedAtMS: unixMilli(record.UpdatedAt),
})
}
// UnmarshalNotification unmarshals one strict JSON notification record.
func UnmarshalNotification(payload []byte) (acceptintent.NotificationRecord, error) {
var wire notificationRecordJSON
if err := unmarshalStrictJSON(payload, &wire); err != nil {
return acceptintent.NotificationRecord{}, fmt.Errorf("unmarshal notification record: %w", err)
}
record := acceptintent.NotificationRecord{
NotificationID: wire.NotificationID,
NotificationType: wire.NotificationType,
Producer: wire.Producer,
AudienceKind: wire.AudienceKind,
RecipientUserIDs: append([]string(nil), wire.RecipientUserIDs...),
PayloadJSON: wire.PayloadJSON,
IdempotencyKey: wire.IdempotencyKey,
RequestFingerprint: wire.RequestFingerprint,
RequestID: wire.RequestID,
TraceID: wire.TraceID,
OccurredAt: time.UnixMilli(wire.OccurredAtMS).UTC(),
AcceptedAt: time.UnixMilli(wire.AcceptedAtMS).UTC(),
UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(),
}
if err := record.Validate(); err != nil {
return acceptintent.NotificationRecord{}, fmt.Errorf("unmarshal notification record: %w", err)
}
return record, nil
}
// MarshalRoute marshals one notification route into the strict JSON
// representation owned by Notification Service.
func MarshalRoute(route acceptintent.NotificationRoute) ([]byte, error) {
if err := route.Validate(); err != nil {
return nil, fmt.Errorf("marshal notification route: %w", err)
}
return marshalStrictJSON(notificationRouteJSON{
NotificationID: route.NotificationID,
RouteID: route.RouteID,
Channel: route.Channel,
RecipientRef: route.RecipientRef,
Status: route.Status,
AttemptCount: route.AttemptCount,
MaxAttempts: route.MaxAttempts,
NextAttemptAtMS: optionalUnixMilli(route.NextAttemptAt),
ResolvedEmail: route.ResolvedEmail,
ResolvedLocale: route.ResolvedLocale,
LastErrorClassification: route.LastErrorClassification,
LastErrorMessage: route.LastErrorMessage,
LastErrorAtMS: optionalUnixMilli(route.LastErrorAt),
CreatedAtMS: unixMilli(route.CreatedAt),
UpdatedAtMS: unixMilli(route.UpdatedAt),
PublishedAtMS: optionalUnixMilli(route.PublishedAt),
DeadLetteredAtMS: optionalUnixMilli(route.DeadLetteredAt),
SkippedAtMS: optionalUnixMilli(route.SkippedAt),
})
}
// UnmarshalRoute unmarshals one strict JSON notification route.
func UnmarshalRoute(payload []byte) (acceptintent.NotificationRoute, error) {
var wire notificationRouteJSON
if err := unmarshalStrictJSON(payload, &wire); err != nil {
return acceptintent.NotificationRoute{}, fmt.Errorf("unmarshal notification route: %w", err)
}
route := acceptintent.NotificationRoute{
NotificationID: wire.NotificationID,
RouteID: wire.RouteID,
Channel: wire.Channel,
RecipientRef: wire.RecipientRef,
Status: wire.Status,
AttemptCount: wire.AttemptCount,
MaxAttempts: wire.MaxAttempts,
ResolvedEmail: wire.ResolvedEmail,
ResolvedLocale: wire.ResolvedLocale,
LastErrorClassification: wire.LastErrorClassification,
LastErrorMessage: wire.LastErrorMessage,
CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(),
UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(),
}
if wire.NextAttemptAtMS != nil {
route.NextAttemptAt = time.UnixMilli(*wire.NextAttemptAtMS).UTC()
}
if wire.LastErrorAtMS != nil {
route.LastErrorAt = time.UnixMilli(*wire.LastErrorAtMS).UTC()
}
if wire.PublishedAtMS != nil {
route.PublishedAt = time.UnixMilli(*wire.PublishedAtMS).UTC()
}
if wire.DeadLetteredAtMS != nil {
route.DeadLetteredAt = time.UnixMilli(*wire.DeadLetteredAtMS).UTC()
}
if wire.SkippedAtMS != nil {
route.SkippedAt = time.UnixMilli(*wire.SkippedAtMS).UTC()
}
if err := route.Validate(); err != nil {
return acceptintent.NotificationRoute{}, fmt.Errorf("unmarshal notification route: %w", err)
}
return route, nil
}
// MarshalIdempotency marshals one idempotency record into the strict JSON
// representation owned by Notification Service.
func MarshalIdempotency(record acceptintent.IdempotencyRecord) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal notification idempotency record: %w", err)
}
return marshalStrictJSON(idempotencyRecordJSON{
Producer: record.Producer,
IdempotencyKey: record.IdempotencyKey,
NotificationID: record.NotificationID,
RequestFingerprint: record.RequestFingerprint,
CreatedAtMS: unixMilli(record.CreatedAt),
ExpiresAtMS: unixMilli(record.ExpiresAt),
})
}
// UnmarshalIdempotency unmarshals one strict JSON idempotency record.
func UnmarshalIdempotency(payload []byte) (acceptintent.IdempotencyRecord, error) {
var wire idempotencyRecordJSON
if err := unmarshalStrictJSON(payload, &wire); err != nil {
return acceptintent.IdempotencyRecord{}, fmt.Errorf("unmarshal notification idempotency record: %w", err)
}
record := acceptintent.IdempotencyRecord{
Producer: wire.Producer,
IdempotencyKey: wire.IdempotencyKey,
NotificationID: wire.NotificationID,
RequestFingerprint: wire.RequestFingerprint,
CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(),
ExpiresAt: time.UnixMilli(wire.ExpiresAtMS).UTC(),
}
if err := record.Validate(); err != nil {
return acceptintent.IdempotencyRecord{}, fmt.Errorf("unmarshal notification idempotency record: %w", err)
}
return record, nil
}
// MarshalDeadLetter marshals one dead-letter entry into the strict JSON
// representation owned by Notification Service.
func MarshalDeadLetter(entry DeadLetterEntry) ([]byte, error) {
if err := entry.Validate(); err != nil {
return nil, fmt.Errorf("marshal dead letter entry: %w", err)
}
return marshalStrictJSON(deadLetterEntryJSON{
NotificationID: entry.NotificationID,
RouteID: entry.RouteID,
Channel: entry.Channel,
RecipientRef: entry.RecipientRef,
FinalAttemptCount: entry.FinalAttemptCount,
MaxAttempts: entry.MaxAttempts,
FailureClassification: entry.FailureClassification,
FailureMessage: entry.FailureMessage,
CreatedAtMS: unixMilli(entry.CreatedAt),
RecoveryHint: entry.RecoveryHint,
})
}
// UnmarshalDeadLetter unmarshals one strict JSON dead-letter entry.
func UnmarshalDeadLetter(payload []byte) (DeadLetterEntry, error) {
var wire deadLetterEntryJSON
if err := unmarshalStrictJSON(payload, &wire); err != nil {
return DeadLetterEntry{}, fmt.Errorf("unmarshal dead letter entry: %w", err)
}
entry := DeadLetterEntry{
NotificationID: wire.NotificationID,
RouteID: wire.RouteID,
Channel: wire.Channel,
RecipientRef: wire.RecipientRef,
FinalAttemptCount: wire.FinalAttemptCount,
MaxAttempts: wire.MaxAttempts,
FailureClassification: wire.FailureClassification,
FailureMessage: wire.FailureMessage,
CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(),
RecoveryHint: wire.RecoveryHint,
}
if err := entry.Validate(); err != nil {
return DeadLetterEntry{}, fmt.Errorf("unmarshal dead letter entry: %w", err)
}
return entry, nil
}
// MarshalMalformedIntent marshals one malformed-intent entry into the strict
// JSON representation owned by Notification Service.
func MarshalMalformedIntent(entry malformedintent.Entry) ([]byte, error) {
if err := entry.Validate(); err != nil {
return nil, fmt.Errorf("marshal malformed intent: %w", err)
}
return marshalStrictJSON(malformedIntentJSON{
StreamEntryID: entry.StreamEntryID,
NotificationType: entry.NotificationType,
Producer: entry.Producer,
IdempotencyKey: entry.IdempotencyKey,
FailureCode: entry.FailureCode,
FailureMessage: entry.FailureMessage,
RawFields: cloneJSONObject(entry.RawFields),
RecordedAtMS: unixMilli(entry.RecordedAt),
})
}
// UnmarshalMalformedIntent unmarshals one strict JSON malformed-intent entry.
func UnmarshalMalformedIntent(payload []byte) (malformedintent.Entry, error) {
var wire malformedIntentJSON
if err := unmarshalStrictJSON(payload, &wire); err != nil {
return malformedintent.Entry{}, fmt.Errorf("unmarshal malformed intent: %w", err)
}
entry := malformedintent.Entry{
StreamEntryID: wire.StreamEntryID,
NotificationType: wire.NotificationType,
Producer: wire.Producer,
IdempotencyKey: wire.IdempotencyKey,
FailureCode: wire.FailureCode,
FailureMessage: wire.FailureMessage,
RawFields: cloneJSONObject(wire.RawFields),
RecordedAt: time.UnixMilli(wire.RecordedAtMS).UTC(),
}
if err := entry.Validate(); err != nil {
return malformedintent.Entry{}, fmt.Errorf("unmarshal malformed intent: %w", err)
}
return entry, nil
}
// MarshalStreamOffset marshals one stream-offset record into the strict JSON
// representation owned by Notification Service.
func MarshalStreamOffset(offset StreamOffset) ([]byte, error) {
if err := offset.Validate(); err != nil {
return nil, fmt.Errorf("marshal stream offset: %w", err)
}
return marshalStrictJSON(streamOffsetJSON{
Stream: offset.Stream,
LastProcessedEntryID: offset.LastProcessedEntryID,
UpdatedAtMS: unixMilli(offset.UpdatedAt),
})
}
// UnmarshalStreamOffset unmarshals one strict JSON stream-offset record.
func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) {
var wire streamOffsetJSON
if err := unmarshalStrictJSON(payload, &wire); err != nil {
return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err)
}
offset := StreamOffset{
Stream: wire.Stream,
LastProcessedEntryID: wire.LastProcessedEntryID,
UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(),
}
if err := offset.Validate(); err != nil {
return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err)
}
return offset, nil
}
// Validate reports whether offset contains a complete persisted consumer
// progress record.
func (offset StreamOffset) Validate() error {
if offset.Stream == "" {
return fmt.Errorf("stream offset stream must not be empty")
}
if offset.LastProcessedEntryID == "" {
return fmt.Errorf("stream offset last processed entry id must not be empty")
}
if offset.UpdatedAt.IsZero() {
return fmt.Errorf("stream offset updated at must not be zero")
}
if !offset.UpdatedAt.Equal(offset.UpdatedAt.UTC()) {
return fmt.Errorf("stream offset updated at must be UTC")
}
if !offset.UpdatedAt.Equal(offset.UpdatedAt.Truncate(time.Millisecond)) {
return fmt.Errorf("stream offset updated at must use millisecond precision")
}
return nil
}
// Validate reports whether entry contains a complete dead-letter record.
func (entry DeadLetterEntry) Validate() error {
if entry.NotificationID == "" {
return fmt.Errorf("dead letter entry notification id must not be empty")
}
if entry.RouteID == "" {
return fmt.Errorf("dead letter entry route id must not be empty")
}
if !entry.Channel.IsKnown() {
return fmt.Errorf("dead letter entry channel %q is unsupported", entry.Channel)
}
if entry.RecipientRef == "" {
return fmt.Errorf("dead letter entry recipient ref must not be empty")
}
if entry.FinalAttemptCount <= 0 {
return fmt.Errorf("dead letter entry final attempt count must be positive")
}
if entry.MaxAttempts <= 0 {
return fmt.Errorf("dead letter entry max attempts must be positive")
}
if entry.FailureClassification == "" {
return fmt.Errorf("dead letter entry failure classification must not be empty")
}
if entry.FailureMessage == "" {
return fmt.Errorf("dead letter entry failure message must not be empty")
}
if entry.CreatedAt.IsZero() {
return fmt.Errorf("dead letter entry created at must not be zero")
}
if !entry.CreatedAt.Equal(entry.CreatedAt.UTC()) {
return fmt.Errorf("dead letter entry created at must be UTC")
}
if !entry.CreatedAt.Equal(entry.CreatedAt.Truncate(time.Millisecond)) {
return fmt.Errorf("dead letter entry created at must use millisecond precision")
}
return nil
}
func marshalStrictJSON(value any) ([]byte, error) {
return json.Marshal(value)
}
func unmarshalStrictJSON(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewBuffer(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("unexpected trailing JSON input")
}
return err
}
return nil
}
func unixMilli(value time.Time) int64 {
return value.UTC().UnixMilli()
}
func optionalUnixMilli(value time.Time) *int64 {
if value.IsZero() {
return nil
}
millis := unixMilli(value)
return &millis
}
func cloneJSONObject(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
}
cloned := make(map[string]any, len(value))
for key, raw := range value {
cloned[key] = cloneJSONValue(raw)
}
return cloned
}
func cloneJSONValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneJSONObject(typed)
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = cloneJSONValue(item)
}
return cloned
default:
return typed
}
}