Skip to content

Commit 78d3d91

Browse files
gsturgesclaude
andcommitted
refactor(stovepipe): address process-DLQ review feedback
Review feedback on the process DLQ PR: the DLQ subscription overrides (DLQ.Enabled=false, MaxAttempts=1000) were duplicated across wiring files, and two doc comments in the DLQ reconciler claimed behavior the code does not have (release-first ordering prevents double release; decode errors are non-retryable under AlwaysRetryableProcessor). - platform/extension/messagequeue/subscription_config.go: add DLQSubscriptionConfig helper owning the DLQ-consumer subscription policy - platform/extension/messagequeue/subscription_config_test.go: test the helper's contract - service/stovepipe/server/main.go: use the helper - service/submitqueue/orchestrator/server/main.go: use the helper - stovepipe/controller/dlq/dlq.go: correct failRequest comment to state the real crash trade-off (double release vs leaked slot) - stovepipe/controller/dlq/request.go: correct decode-error comment — retried deliberately to heal deployment skew, bounded by MaxAttempts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 029d8af commit 78d3d91

6 files changed

Lines changed: 57 additions & 32 deletions

File tree

platform/extension/messagequeue/subscription_config.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,23 @@ type DLQConfig struct {
7878
TopicSuffix string
7979
}
8080

81+
// DLQSubscriptionConfig returns a SubscriptionConfig for consuming a dead-letter
82+
// topic (DLQ reconciliation). It starts from DefaultSubscriptionConfig and applies
83+
// the two overrides every DLQ consumer needs:
84+
//
85+
// - DLQ.Enabled is false, so a reconciliation failure retries in place instead of
86+
// cascading to a second-level "_dlq_dlq" topic that nobody consumes.
87+
// - Retry.MaxAttempts is a very high backstop so the per-message retry budget
88+
// effectively never runs out. This pairs with errs.AlwaysRetryableProcessor
89+
// wired into the DLQ consumer: reconciliation converges eventually instead of
90+
// being silently dropped after the default retry count.
91+
func DLQSubscriptionConfig(subscriberName, consumerGroup string) SubscriptionConfig {
92+
config := DefaultSubscriptionConfig(subscriberName, consumerGroup)
93+
config.DLQ.Enabled = false
94+
config.Retry.MaxAttempts = 1000
95+
return config
96+
}
97+
8198
// DefaultSubscriptionConfig returns a SubscriptionConfig with sensible defaults.
8299
func DefaultSubscriptionConfig(subscriberName, consumerGroup string) SubscriptionConfig {
83100
return SubscriptionConfig{

platform/extension/messagequeue/subscription_config_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ func TestSubscriptionConfig_CustomValues(t *testing.T) {
7373
assert.Equal(t, "_dead", config.DLQ.TopicSuffix)
7474
}
7575

76+
func TestDLQSubscriptionConfig(t *testing.T) {
77+
config := DLQSubscriptionConfig("worker-1", "consumer-1-dlq")
78+
79+
assert.Equal(t, "worker-1", config.SubscriberName)
80+
assert.Equal(t, "consumer-1-dlq", config.ConsumerGroup)
81+
82+
// The DLQ consumer must not dead-letter its own failures (no "_dlq_dlq"
83+
// cascade) and needs a far larger retry budget than a primary consumer.
84+
assert.False(t, config.DLQ.Enabled)
85+
assert.Greater(t, config.Retry.MaxAttempts, DefaultSubscriptionConfig("worker-1", "consumer-1").Retry.MaxAttempts)
86+
}
87+
7688
func TestSubscriptionConfig_DifferentConsumerGroups(t *testing.T) {
7789
// Test that different consumer groups get independent configs
7890
tests := []struct {

service/stovepipe/server/main.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -341,14 +341,8 @@ func run() error {
341341
// newTopicRegistry builds the TopicRegistry for Stovepipe's internal pipeline queues. ingest
342342
// publishes to process; process publishes admitted requests to the publish-only build topic.
343343
// The process_dlq topic is the dead-letter destination the queue backend routes to (per
344-
// DefaultSubscriptionConfig's DLQ.TopicSuffix) when the process controller exhausts retries;
345-
// DLQ.Enabled is false on its own subscription so a reconciliation failure retries in place
346-
// rather than cascading to a further DLQ.
344+
// DefaultSubscriptionConfig's DLQ.TopicSuffix) when the process controller exhausts retries.
347345
func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRegistry, error) {
348-
dlqSub := extqueue.DefaultSubscriptionConfig(subscriberName, "stovepipe-process-dlq")
349-
dlqSub.DLQ.Enabled = false
350-
dlqSub.Retry.MaxAttempts = 1000
351-
352346
return consumer.NewTopicRegistry([]consumer.TopicConfig{
353347
{
354348
Key: stovepipemq.TopicKeyProcess,
@@ -367,7 +361,7 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
367361
Key: dlq.TopicKey(stovepipemq.TopicKeyProcess),
368362
Name: "process_dlq",
369363
Queue: q,
370-
Subscription: dlqSub,
364+
Subscription: extqueue.DLQSubscriptionConfig(subscriberName, "stovepipe-process-dlq"),
371365
},
372366
})
373367
}

service/submitqueue/orchestrator/server/main.go

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -403,27 +403,15 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
403403
subscriberName, t.groupSuffix,
404404
),
405405
})
406-
// DLQ subscription for the same primary stage. DLQ is disabled here
407-
// to avoid a "_dlq_dlq" cascade: if DLQ reconciliation itself fails,
408-
// the consumer retries forever and the failure is surfaced via logs
409-
// and metrics rather than being moved to a second-level dead-letter
410-
// topic that nobody consumes.
411-
//
412-
// MaxAttempts is bumped to a very high value so the per-message
413-
// retry budget effectively never runs out — this pairs with the
414-
// AlwaysRetryableProcessor wired into the DLQ consumer to guarantee
415-
// reconciliation eventually converges instead of being silently
416-
// dropped after the default retry count.
417-
dlqSub := extqueue.DefaultSubscriptionConfig(
418-
subscriberName, t.groupSuffix+"-dlq",
419-
)
420-
dlqSub.DLQ.Enabled = false
421-
dlqSub.Retry.MaxAttempts = 1000
406+
// DLQ subscription for the same primary stage. DLQSubscriptionConfig
407+
// disables the subscription's own DLQ (no "_dlq_dlq" cascade) and sets
408+
// an effectively unlimited retry budget to pair with the
409+
// AlwaysRetryableProcessor wired into the DLQ consumer.
422410
configs = append(configs, consumer.TopicConfig{
423411
Key: dlq.TopicKey(t.key),
424412
Name: t.name + "_dlq",
425413
Queue: q,
426-
Subscription: dlqSub,
414+
Subscription: extqueue.DLQSubscriptionConfig(subscriberName, t.groupSuffix+"-dlq"),
427415
})
428416
}
429417

stovepipe/controller/dlq/dlq.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@
2727
// name). The DLQ controller decodes that payload to recover the affected request, then
2828
// transitions it to RequestStateRecordedNotGreen — the conservative not-green verdict
2929
// for gating (see entity.RequestState) — with an idempotent optimistic-locking write so
30-
// concurrent
31-
// activity (a late successful pipeline transition) wins cleanly. If the request had
30+
// concurrent activity (a late successful pipeline transition) wins cleanly. If the request had
3231
// already been admitted (processing) and was holding a concurrency slot, the
3332
// reconciler also releases it by CAS-decrementing the queue's in_flight_count, per
3433
// doc/rfc/stovepipe/steps/process.md#in_flight_count-integrity.
@@ -61,8 +60,16 @@ func TopicKey(main consumer.TopicKey) consumer.TopicKey {
6160
// failRequest transitions request to RequestStateRecordedNotGreen if it is not already
6261
// in a terminal state. If the request had reached RequestStateProcessing — meaning process's
6362
// admit step already CAS-incremented the queue's in_flight_count for it — the queue's
64-
// slot is released first, so a crash between the two writes leaves the count still
65-
// bound to a non-terminal request rather than double-released.
63+
// slot is released first. Queue and Request are separate entities with no cross-entity
64+
// transaction, so the two writes cannot be atomic and the ordering picks which crash
65+
// failure mode we accept: a crash between the writes leaves the request non-terminal,
66+
// redelivery re-runs reconciliation, and releaseSlot (which tracks no per-request slot
67+
// ownership) decrements again — transiently over-admitting by one slot until the
68+
// under-count re-converges at releaseSlot's zero clamp. The reverse order would leak
69+
// the slot instead: redelivery skips terminal requests, permanently shrinking the
70+
// queue's capacity toward a wedge. Over-admission is the failure mode we prefer. See
71+
// doc/rfc/stovepipe/steps/process.md#in_flight_count-integrity for the broader
72+
// counter-drift story.
6673
func failRequest(ctx context.Context, store storage.Storage, logger *zap.SugaredLogger, requestID string) error {
6774
request, err := store.GetRequestStore().Get(ctx, requestID)
6875
if err != nil {
@@ -93,7 +100,7 @@ func failRequest(ctx context.Context, store storage.Storage, logger *zap.Sugared
93100
updated.State = entity.RequestStateRecordedNotGreen
94101
newVersion := request.Version + 1
95102
if err := store.GetRequestStore().Update(ctx, updated, request.Version, newVersion); err != nil {
96-
return fmt.Errorf("failed to update request %s state to failed: %w", requestID, err)
103+
return fmt.Errorf("failed to update request %s state to recorded_not_green: %w", requestID, err)
97104
}
98105
logger.Infow("dlq reconcile: request forced terminal not-green",
99106
"request_id", requestID,

stovepipe/controller/dlq/request.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,15 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r
7575
pr := &stovepipemq.ProcessRequest{}
7676
if err := stovepipemq.Unmarshal(msg.Payload, pr); err != nil {
7777
metrics.NamedCounter(c.metricsScope, _opName, "deserialize_errors", 1)
78-
// Malformed DLQ payload is non-retryable: a re-delivery will decode the same
79-
// bytes and fail the same way.
78+
// Decoding the same bytes normally fails deterministically, but this error is
79+
// still retried: the DLQ consumer's AlwaysRetryableProcessor (see Process doc)
80+
// classifies every error as retryable. That is deliberate — the recoverable
81+
// cause is deployment skew, where a newer producer's payload shape reaches a
82+
// not-yet-upgraded consumer and decodes fine once the rollout completes. A
83+
// genuinely malformed payload exhausts the DLQ subscription's MaxAttempts
84+
// backstop and is dropped by the subscriber with a warning log; acking it here
85+
// instead would skip reconciliation silently and leave the referenced request
86+
// non-terminal.
8087
return fmt.Errorf("failed to decode dlq payload: %w", err)
8188
}
8289
if pr.Id == "" {

0 commit comments

Comments
 (0)