1+ package outbox
2+
3+ import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "time"
9+
10+ "github.com/prometheus/client_golang/prometheus"
11+ "github.com/redis/go-redis/v9"
12+ )
13+
14+ // RedisPublisherConfig configures the Redis Streams publisher.
15+ type RedisPublisherConfig struct {
16+ Client * redis.Client
17+ Stream string
18+ Group string
19+ Consumer string
20+ MaxLen int64
21+ MaxLenApprox bool
22+ }
23+
24+ const (
25+ defaultRedisStream = "outbox:events"
26+ defaultRedisGroup = "outbox-consumers"
27+ defaultRedisMaxLen = 1000
28+ )
29+
30+ var redisPublishDuration prometheus.Histogram
31+
32+ func init () {
33+ redisPublishDuration = prometheus .NewHistogram (prometheus.HistogramOpts {
34+ Name : "redis_publish_duration_seconds" ,
35+ Help : "Duration of Redis Streams XADD operations" ,
36+ Buckets : prometheus .DefBuckets ,
37+ })
38+ _ = prometheus .Register (redisPublishDuration )
39+ }
40+
41+ // redisStreamClient is the subset of redis.Client needed by RedisPublisher.
42+ type redisStreamClient interface {
43+ XAdd (ctx context.Context , a * redis.XAddArgs ) * redis.StringCmd
44+ XGroupCreateMkStream (ctx context.Context , stream , group , start string ) * redis.StatusCmd
45+ }
46+
47+ // RedisPublisher publishes events to a Redis Stream with consumer group support.
48+ type RedisPublisher struct {
49+ client redisStreamClient
50+ stream string
51+ group string
52+ consumer string
53+ maxLen int64
54+ approx bool
55+ }
56+
57+ type permanentError struct {
58+ reason string
59+ }
60+
61+ func (e * permanentError ) Error () string { return e .reason }
62+
63+ // NewRedisPublisher creates a new RedisPublisher and ensures the consumer group exists.
64+ func NewRedisPublisher (config RedisPublisherConfig ) (* RedisPublisher , error ) {
65+ stream := config .Stream
66+ if stream == "" {
67+ stream = defaultRedisStream
68+ }
69+ group := config .Group
70+ if group == "" {
71+ group = defaultRedisGroup
72+ }
73+ consumer := config .Consumer
74+ if consumer == "" {
75+ host , _ := os .Hostname ()
76+ consumer = fmt .Sprintf ("%s:%d" , host , os .Getpid ())
77+ }
78+ maxLen := config .MaxLen
79+ if maxLen <= 0 {
80+ maxLen = defaultRedisMaxLen
81+ }
82+
83+ p := & RedisPublisher {
84+ client : config .Client ,
85+ stream : stream ,
86+ group : group ,
87+ consumer : consumer ,
88+ maxLen : maxLen ,
89+ approx : config .MaxLenApprox ,
90+ }
91+
92+ if err := p .ensureGroup (context .Background ()); err != nil {
93+ return nil , fmt .Errorf ("redis: ensure consumer group: %w" , err )
94+ }
95+
96+ return p , nil
97+ }
98+
99+ func (p * RedisPublisher ) ensureGroup (ctx context.Context ) error {
100+ err := p .client .XGroupCreateMkStream (ctx , p .stream , p .group , "0" ).Err ()
101+ if err != nil && ! isBusyGroupError (err ) {
102+ return err
103+ }
104+ return nil
105+ }
106+
107+ func isBusyGroupError (err error ) bool {
108+ return err != nil && len (err .Error ()) >= 9 && err .Error ()[:9 ] == "BUSYGROUP"
109+ }
110+
111+ // Publish publishes an event to the Redis Stream.
112+ func (p * RedisPublisher ) Publish (ctx context.Context , event * Event ) error {
113+ values := p .eventToValues (event )
114+
115+ timer := prometheus .NewTimer (redisPublishDuration )
116+
117+ err := p .client .XAdd (ctx , & redis.XAddArgs {
118+ Stream : p .stream ,
119+ MaxLen : p .maxLen ,
120+ Approx : p .approx ,
121+ Values : values ,
122+ }).Err ()
123+
124+ timer .ObserveDuration ()
125+
126+ if err != nil {
127+ var movedErr redis.MovedError
128+ if errors .As (err , & movedErr ) {
129+ return p .redirectXAdd (ctx , movedErr .Addr , values )
130+ }
131+ var askErr redis.AskError
132+ if errors .As (err , & askErr ) {
133+ return p .redirectXAdd (ctx , askErr .Addr , values )
134+ }
135+ return fmt .Errorf ("redis: xadd: %w" , err )
136+ }
137+
138+ return nil
139+ }
140+
141+ func (p * RedisPublisher ) redirectXAdd (ctx context.Context , addr string , values map [string ]interface {}) error {
142+ redirectClient := redis .NewClient (& redis.Options {Addr : addr })
143+ defer redirectClient .Close ()
144+
145+ timer := prometheus .NewTimer (redisPublishDuration )
146+
147+ err := redirectClient .XAdd (ctx , & redis.XAddArgs {
148+ Stream : p .stream ,
149+ MaxLen : p .maxLen ,
150+ Approx : p .approx ,
151+ Values : values ,
152+ }).Err ()
153+
154+ timer .ObserveDuration ()
155+
156+ if err != nil {
157+ return fmt .Errorf ("redis: xadd redirect to %s: %w" , addr , err )
158+ }
159+ return nil
160+ }
161+
162+ func (p * RedisPublisher ) eventToValues (event * Event ) map [string ]interface {} {
163+ values := map [string ]interface {}{
164+ "id" : event .ID .String (),
165+ "event_type" : event .EventType ,
166+ "event_data" : string (event .EventData ),
167+ "occurred_at" : event .OccurredAt .Format (time .RFC3339Nano ),
168+ "version" : event .Version ,
169+ }
170+ if event .AggregateID != nil {
171+ values ["aggregate_id" ] = * event .AggregateID
172+ }
173+ if event .AggregateType != nil {
174+ values ["aggregate_type" ] = * event .AggregateType
175+ }
176+ if event .TenantID != "" {
177+ values ["tenant_id" ] = event .TenantID
178+ }
179+ return values
180+ }
0 commit comments