package outbox import ( "context" "errors" "fmt" "strconv" "time" "github.com/redis/go-redis/v9" ) var ErrPublisherUnavailable = errors.New("outbox publisher unavailable") type PublishResult struct { Published bool StreamID string } type RedisPublisher struct { client *redis.Client stream string maxLength int64 markerTTL time.Duration script *redis.Script } func NewRedisPublisher(client *redis.Client, stream string, maxLength int64, markerTTL time.Duration) *RedisPublisher { return &RedisPublisher{client: client, stream: stream, maxLength: maxLength, markerTTL: markerTTL, script: redis.NewScript(publishScript)} } // Ping reports whether the downstream Redis stream is reachable. The outbox // worker calls it before claiming events so a Redis outage never consumes the // events' delivery budget (claiming increments attempts; dead-lettering then // burns the whole queue for a fault that was never the events'). func (p *RedisPublisher) Ping(ctx context.Context) error { if p == nil || p.client == nil { return ErrPublisherUnavailable } if err := p.client.Ping(ctx).Err(); err != nil { return fmt.Errorf("%w: %v", ErrPublisherUnavailable, err) } return nil } func (p *RedisPublisher) Publish(ctx context.Context, event Event) (PublishResult, error) { if p == nil || p.client == nil { return PublishResult{}, ErrPublisherUnavailable } marker := "gateway:{outbox}:published:" + event.EventID result, err := p.script.Run(ctx, p.client, []string{marker, p.stream}, int64(p.markerTTL.Seconds()), p.maxLength, event.EventID, event.EventType, event.EventVersion, valueOrEmpty(event.TenantID), event.AggregateType, event.AggregateID, string(event.Payload), string(event.TraceContext), event.OccurredAt.UTC().Format(time.RFC3339Nano), ).Slice() if err != nil || len(result) != 2 { return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err) } published, err := redisInt(result[0]) if err != nil { return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err) } streamID := "duplicate" if result[1] != nil && fmt.Sprint(result[1]) != "" { streamID = fmt.Sprint(result[1]) } return PublishResult{Published: published == 1, StreamID: streamID}, nil } func valueOrEmpty(value *string) string { if value == nil { return "" } return *value } func redisInt(value any) (int64, error) { switch typed := value.(type) { case int64: return typed, nil case string: return strconv.ParseInt(typed, 10, 64) case []byte: return strconv.ParseInt(string(typed), 10, 64) default: return 0, fmt.Errorf("unexpected redis integer %T", value) } } const publishScript = ` if redis.call('SET', KEYS[1], '1', 'NX', 'EX', tonumber(ARGV[1])) then local id = redis.call('XADD', KEYS[2], 'MAXLEN', '~', tonumber(ARGV[2]), '*', 'event_id', ARGV[3], 'event_type', ARGV[4], 'event_version', ARGV[5], 'tenant_id', ARGV[6], 'aggregate_type', ARGV[7], 'aggregate_id', ARGV[8], 'payload', ARGV[9], 'trace_context', ARGV[10], 'occurred_at', ARGV[11]) return {1, id} end return {0, ''} `