Skip to content

Commit 2f0f1bc

Browse files
JamyDevclaude
andcommitted
fix(pipeline): make DLQ required and simplify ConsumerGroup convention
Address review feedback: - ConsumerGroup is now documented as service-wide (e.g. "orchestrator"), not per-stage (e.g. "orchestrator-start"). - DLQ handler is now required on every stage — the queue infrastructure always creates a DLQ topic, so not processing it silently accumulates messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7acfd38 commit 2f0f1bc

2 files changed

Lines changed: 96 additions & 60 deletions

File tree

platform/pipeline/pipeline.go

Lines changed: 32 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ type Stage[D any] struct {
4545
// Used as the default when no TopicNames override is provided.
4646
Name string
4747

48-
// ConsumerGroup is the consumer group suffix for this stage's subscription
49-
// (e.g. "orchestrator-start").
48+
// ConsumerGroup is the consumer group for this stage's subscription
49+
// (e.g. "orchestrator").
5050
ConsumerGroup string
5151

5252
// New builds the stage's controller from the service's Deps and engine-
@@ -55,10 +55,11 @@ type Stage[D any] struct {
5555
// name on it, never mid-delivery.
5656
New func(D, StageContext) (consumer.Controller, error)
5757

58-
// DLQ, when non-nil, declares "this stage dead-letters". The engine then
59-
// derives the paired DLQ topic (<topic>_dlq, retry budget, DLQ-of-DLQ
60-
// disabled) AND registers this reconciler on the DLQ consumer. Declaring
61-
// one without getting the other is impossible — that's the invariant.
58+
// DLQ builds the stage's dead-letter reconciler. Every stage must declare
59+
// one: the queue infrastructure always creates a DLQ topic, so not
60+
// processing it silently accumulates messages. The engine derives the
61+
// paired DLQ topic (<topic>_dlq, retry budget, DLQ-of-DLQ disabled) and
62+
// registers this reconciler on the DLQ consumer automatically.
6263
DLQ func(D, StageContext) (consumer.Controller, error)
6364
}
6465

@@ -179,10 +180,12 @@ func Construct[D any](
179180
// Create the DLQ consumer with always-retryable processor.
180181
dlq := consumer.New(logger, scope, registry, errs.AlwaysRetryableProcessor)
181182

182-
hasDLQ := false
183-
184183
// Eagerly construct and register all controllers.
185184
for _, s := range stages {
185+
if s.DLQ == nil {
186+
return nil, fmt.Errorf("pipeline: stage %s: DLQ handler is required (every topic gets a DLQ)", s.Key)
187+
}
188+
186189
sc := StageContext{
187190
Registry: registry,
188191
TopicKey: s.Key,
@@ -197,30 +200,25 @@ func Construct[D any](
197200
return nil, fmt.Errorf("pipeline: stage %s: failed to register controller: %w", s.Key, err)
198201
}
199202

200-
if s.DLQ != nil {
201-
dlqSC := StageContext{
202-
Registry: registry,
203-
TopicKey: dlqTopicKey(s.Key),
204-
ConsumerGroup: s.ConsumerGroup + "-dlq",
205-
}
206-
rec, err := s.DLQ(deps, dlqSC)
207-
if err != nil {
208-
return nil, fmt.Errorf("pipeline: stage %s dlq: failed to create controller: %w", s.Key, err)
209-
}
210-
if err := dlq.Register(rec); err != nil {
211-
return nil, fmt.Errorf("pipeline: stage %s dlq: failed to register controller: %w", s.Key, err)
212-
}
213-
hasDLQ = true
203+
dlqSC := StageContext{
204+
Registry: registry,
205+
TopicKey: dlqTopicKey(s.Key),
206+
ConsumerGroup: s.ConsumerGroup + "-dlq",
207+
}
208+
rec, err := s.DLQ(deps, dlqSC)
209+
if err != nil {
210+
return nil, fmt.Errorf("pipeline: stage %s dlq: failed to create controller: %w", s.Key, err)
211+
}
212+
if err := dlq.Register(rec); err != nil {
213+
return nil, fmt.Errorf("pipeline: stage %s dlq: failed to register controller: %w", s.Key, err)
214214
}
215215
}
216216

217217
// Compose the lifecycle group.
218218
members := make([]lifecycle.Component, 0, len(o.extraComponents)+2)
219219
members = append(members, o.extraComponents...)
220220
members = append(members, &consumerComponent{name: "primary", c: primary})
221-
if hasDLQ {
222-
members = append(members, &consumerComponent{name: "dlq", c: dlq})
223-
}
221+
members = append(members, &consumerComponent{name: "dlq", c: dlq})
224222

225223
return lifecycle.NewGroup(members...), nil
226224
}
@@ -232,7 +230,7 @@ func buildTopicConfigs[D any](
232230
stages []Stage[D],
233231
o *options,
234232
) ([]consumer.TopicConfig, error) {
235-
// Pre-size: each stage gets a primary config + optional DLQ config,
233+
// Pre-size: each stage gets a primary config + DLQ config,
236234
// plus publish-only topics.
237235
configs := make([]consumer.TopicConfig, 0, 2*len(stages)+len(o.publishOnly))
238236

@@ -248,16 +246,14 @@ func buildTopicConfigs[D any](
248246
),
249247
})
250248

251-
if s.DLQ != nil {
252-
configs = append(configs, consumer.TopicConfig{
253-
Key: dlqTopicKey(s.Key),
254-
Name: topicName + dlqTopicSuffix,
255-
Queue: queue,
256-
Subscription: extqueue.DLQSubscriptionConfig(
257-
subscriberName, s.ConsumerGroup+"-dlq",
258-
),
259-
})
260-
}
249+
configs = append(configs, consumer.TopicConfig{
250+
Key: dlqTopicKey(s.Key),
251+
Name: topicName + dlqTopicSuffix,
252+
Queue: queue,
253+
Subscription: extqueue.DLQSubscriptionConfig(
254+
subscriberName, s.ConsumerGroup+"-dlq",
255+
),
256+
})
261257
}
262258

263259
for _, p := range o.publishOnly {

platform/pipeline/pipeline_test.go

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func newTestLogger() *zap.SugaredLogger {
5050
return l.Sugar()
5151
}
5252

53-
func TestConstruct_SingleStage_NoDLQ(t *testing.T) {
53+
func TestConstruct_SingleStage(t *testing.T) {
5454
ctrl := gomock.NewController(t)
5555
q := mqmock.NewMockQueue(ctrl)
5656
q.EXPECT().Subscriber().Return(mqmock.NewMockSubscriber(ctrl)).AnyTimes()
@@ -61,10 +61,13 @@ func TestConstruct_SingleStage_NoDLQ(t *testing.T) {
6161
{
6262
Key: "start",
6363
Name: "start",
64-
ConsumerGroup: "orchestrator-start",
64+
ConsumerGroup: "orchestrator",
6565
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
6666
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
6767
},
68+
DLQ: func(d testDeps, sc StageContext) (consumer.Controller, error) {
69+
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
70+
},
6871
},
6972
}
7073

@@ -84,7 +87,7 @@ func TestConstruct_WithDLQ(t *testing.T) {
8487
{
8588
Key: "start",
8689
Name: "start",
87-
ConsumerGroup: "orchestrator-start",
90+
ConsumerGroup: "orchestrator",
8891
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
8992
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
9093
},
@@ -99,6 +102,30 @@ func TestConstruct_WithDLQ(t *testing.T) {
99102
assert.NotNil(t, comp)
100103
}
101104

105+
func TestConstruct_NilDLQ_Error(t *testing.T) {
106+
ctrl := gomock.NewController(t)
107+
q := mqmock.NewMockQueue(ctrl)
108+
q.EXPECT().Subscriber().Return(mqmock.NewMockSubscriber(ctrl)).AnyTimes()
109+
q.EXPECT().Publisher().Return(mqmock.NewMockPublisher(ctrl)).AnyTimes()
110+
111+
deps := testDeps{logger: newTestLogger()}
112+
stages := []Stage[testDeps]{
113+
{
114+
Key: "start",
115+
Name: "start",
116+
ConsumerGroup: "orchestrator",
117+
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
118+
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
119+
},
120+
// DLQ intentionally omitted.
121+
},
122+
}
123+
124+
_, err := Construct(deps.logger, tally.NoopScope, q, "test-sub", deps, stages)
125+
require.Error(t, err)
126+
assert.Contains(t, err.Error(), "DLQ handler is required")
127+
}
128+
102129
func TestConstruct_MultipleStages(t *testing.T) {
103130
ctrl := gomock.NewController(t)
104131
q := mqmock.NewMockQueue(ctrl)
@@ -110,15 +137,18 @@ func TestConstruct_MultipleStages(t *testing.T) {
110137
{
111138
Key: "start",
112139
Name: "start",
113-
ConsumerGroup: "orchestrator-start",
140+
ConsumerGroup: "orchestrator",
114141
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
115142
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
116143
},
144+
DLQ: func(d testDeps, sc StageContext) (consumer.Controller, error) {
145+
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
146+
},
117147
},
118148
{
119149
Key: "validate",
120150
Name: "validate",
121-
ConsumerGroup: "orchestrator-validate",
151+
ConsumerGroup: "orchestrator",
122152
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
123153
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
124154
},
@@ -154,10 +184,13 @@ func TestConstruct_ControllerCreationFailure(t *testing.T) {
154184
{
155185
Key: "start",
156186
Name: "start",
157-
ConsumerGroup: "orchestrator-start",
187+
ConsumerGroup: "orchestrator",
158188
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
159189
return nil, fmt.Errorf("missing dependency")
160190
},
191+
DLQ: func(d testDeps, sc StageContext) (consumer.Controller, error) {
192+
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
193+
},
161194
},
162195
}
163196

@@ -178,7 +211,7 @@ func TestConstruct_DLQControllerCreationFailure(t *testing.T) {
178211
{
179212
Key: "start",
180213
Name: "start",
181-
ConsumerGroup: "orchestrator-start",
214+
ConsumerGroup: "orchestrator",
182215
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
183216
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
184217
},
@@ -205,10 +238,13 @@ func TestConstruct_WithPublishOnly(t *testing.T) {
205238
{
206239
Key: "start",
207240
Name: "start",
208-
ConsumerGroup: "orchestrator-start",
241+
ConsumerGroup: "orchestrator",
209242
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
210243
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
211244
},
245+
DLQ: func(d testDeps, sc StageContext) (consumer.Controller, error) {
246+
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
247+
},
212248
},
213249
}
214250

@@ -233,10 +269,13 @@ func TestConstruct_WithTopicNameOverrides(t *testing.T) {
233269
{
234270
Key: "start",
235271
Name: "start",
236-
ConsumerGroup: "orchestrator-start",
272+
ConsumerGroup: "orchestrator",
237273
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
238274
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
239275
},
276+
DLQ: func(d testDeps, sc StageContext) (consumer.Controller, error) {
277+
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
278+
},
240279
},
241280
}
242281

@@ -262,7 +301,7 @@ func TestConstruct_StageContext_Populated(t *testing.T) {
262301
{
263302
Key: "start",
264303
Name: "start",
265-
ConsumerGroup: "orchestrator-start",
304+
ConsumerGroup: "orchestrator",
266305
New: func(d testDeps, sc StageContext) (consumer.Controller, error) {
267306
primarySC = sc
268307
return &fakeController{key: sc.TopicKey, group: sc.ConsumerGroup}, nil
@@ -279,11 +318,11 @@ func TestConstruct_StageContext_Populated(t *testing.T) {
279318

280319
// Primary StageContext should have the stage's own key and group.
281320
assert.Equal(t, consumer.TopicKey("start"), primarySC.TopicKey)
282-
assert.Equal(t, "orchestrator-start", primarySC.ConsumerGroup)
321+
assert.Equal(t, "orchestrator", primarySC.ConsumerGroup)
283322

284323
// DLQ StageContext should have the derived DLQ key and group.
285324
assert.Equal(t, consumer.TopicKey("start_dlq"), dlqSC.TopicKey)
286-
assert.Equal(t, "orchestrator-start-dlq", dlqSC.ConsumerGroup)
325+
assert.Equal(t, "orchestrator-dlq", dlqSC.ConsumerGroup)
287326

288327
// Both should share the same registry.
289328
assert.Equal(t, primarySC.Registry, dlqSC.Registry)
@@ -347,16 +386,16 @@ func TestBuildTopicConfigs(t *testing.T) {
347386
{
348387
Key: "start",
349388
Name: "start",
350-
ConsumerGroup: "orchestrator-start",
389+
ConsumerGroup: "orchestrator",
351390
New: func(d testDeps, sc StageContext) (consumer.Controller, error) { return nil, nil },
352391
DLQ: func(d testDeps, sc StageContext) (consumer.Controller, error) { return nil, nil },
353392
},
354393
{
355394
Key: "validate",
356395
Name: "validate",
357-
ConsumerGroup: "orchestrator-validate",
396+
ConsumerGroup: "orchestrator",
358397
New: func(d testDeps, sc StageContext) (consumer.Controller, error) { return nil, nil },
359-
// No DLQ for this stage.
398+
DLQ: func(d testDeps, sc StageContext) (consumer.Controller, error) { return nil, nil },
360399
},
361400
}
362401

@@ -369,29 +408,30 @@ func TestBuildTopicConfigs(t *testing.T) {
369408
configs, err := buildTopicConfigs(q, "test-sub", stages, o)
370409
require.NoError(t, err)
371410

372-
// Expected: start (primary + DLQ) + validate (primary only) + log (publish-only) = 4
373-
assert.Len(t, configs, 4)
411+
// Expected: start (primary + DLQ) + validate (primary + DLQ) + log (publish-only) = 5
412+
assert.Len(t, configs, 5)
374413

375414
// Verify primary stage config.
376415
assert.Equal(t, consumer.TopicKey("start"), configs[0].Key)
377416
assert.Equal(t, "start", configs[0].Name)
378-
assert.Equal(t, "orchestrator-start", configs[0].Subscription.ConsumerGroup)
417+
assert.Equal(t, "orchestrator", configs[0].Subscription.ConsumerGroup)
379418

380419
// Verify DLQ config derived from primary.
381420
assert.Equal(t, consumer.TopicKey("start_dlq"), configs[1].Key)
382421
assert.Equal(t, "start_dlq", configs[1].Name)
383-
assert.Equal(t, "orchestrator-start-dlq", configs[1].Subscription.ConsumerGroup)
422+
assert.Equal(t, "orchestrator-dlq", configs[1].Subscription.ConsumerGroup)
384423

385424
// Verify DLQ subscription has DLQ disabled (no cascade).
386-
expected := extqueue.DLQSubscriptionConfig("test-sub", "orchestrator-start-dlq")
425+
expected := extqueue.DLQSubscriptionConfig("test-sub", "orchestrator-dlq")
387426
assert.Equal(t, expected.DLQ.Enabled, configs[1].Subscription.DLQ.Enabled)
388427
assert.Equal(t, expected.Retry.MaxAttempts, configs[1].Subscription.Retry.MaxAttempts)
389428

390-
// Verify validate stage (no DLQ).
429+
// Verify validate stage (primary + DLQ).
391430
assert.Equal(t, consumer.TopicKey("validate"), configs[2].Key)
431+
assert.Equal(t, consumer.TopicKey("validate_dlq"), configs[3].Key)
392432

393433
// Verify publish-only topic.
394-
assert.Equal(t, consumer.TopicKey("log"), configs[3].Key)
395-
assert.Equal(t, "log", configs[3].Name)
396-
assert.Equal(t, "", configs[3].Subscription.ConsumerGroup)
434+
assert.Equal(t, consumer.TopicKey("log"), configs[4].Key)
435+
assert.Equal(t, "log", configs[4].Name)
436+
assert.Equal(t, "", configs[4].Subscription.ConsumerGroup)
397437
}

0 commit comments

Comments
 (0)