Skip to content

Commit 9c0ccf9

Browse files
feat: add Redis Streams publisher for outbox with consumer groups and MAXLEN cap (#420) (#594)
Co-authored-by: oyeyemi01 <oyeyemi01@users.noreply.github.com> Co-authored-by: thlpkee20-wq <thlpkee20@gmail.com>
1 parent 159e46f commit 9c0ccf9

2 files changed

Lines changed: 613 additions & 0 deletions

File tree

internal/outbox/redis_publisher.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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

Comments
 (0)