Skip to content

Commit 9ec8c42

Browse files
committed
fix(queue): ISS-013 keep DLQ reconciliation retrying
Summary: Intent: - Keep final-DLQ reconciliation durable during dependency outages longer than any finite attempt budget. - Preserve finite retry budgets for primary subscriptions. Changes: - Define MaxAttempts zero as unlimited in both direct Nack and visibility-expiry poll paths. - Configure the shared DLQ subscription for unlimited retries with second-level dead-lettering disabled. - Verify the orchestrator pipeline and Runway wiring inherit the shared behavior and update the operational docs. Reproduction: - A signal or storage dependency remains unavailable for more than 1000 DLQ reconciliation attempts. - Previously the finite cap was exhausted; because the reconciliation subscription had its own DLQ disabled, MySQL acknowledged the row and advanced past it, losing the reconciliation message. - The row now remains retryable until reconciliation succeeds or an operator removes it. --- <sub>Generated by the 🪄 [pr-create](https://sg.uberinternal.com/code.uber.internal/uber-code/devexp-agent-marketplace/-/blob/claude-code/plugins/dev/uber-dev/skills/pr-create/SKILL.md) skill in devexp-agent-marketplace</sub>
1 parent 980e921 commit 9ec8c42

13 files changed

Lines changed: 171 additions & 30 deletions

File tree

‎platform/errs/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ One operational consequence worth knowing before relying on any of this: **retry
8585
### Choosing a processor
8686

8787
- **Primary pipeline consumer** → `NewClassifierProcessor(...)`. Controllers' explicit `NewUserError` / `NewDependencyError` wraps must survive so user errors don't get retried, and unclassified backend errors must be inspected by the registered classifiers.
88-
- **DLQ reconciliation consumer** → `AlwaysRetryableProcessor`. The DLQ is the last stop; any unprocessable message must come back for another attempt rather than silently drop. The DLQ subscription itself runs with a very high `Retry.MaxAttempts` and with its own DLQ disabled, so "always retryable + bounded-but-effectively-infinite attempts" is the convergence guarantee.
88+
- **DLQ reconciliation consumer** → `AlwaysRetryableProcessor`. The DLQ is the last stop; any unprocessable message must come back for another attempt rather than silently drop. The DLQ subscription itself runs with unlimited attempts (`Retry.MaxAttempts = 0`) and with its own DLQ disabled, so every returned error remains retryable until reconciliation succeeds or an operator removes the message.
8989

9090
## Adding a Backend-Specific Classifier
9191

‎platform/extension/messagequeue/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ type Delivery interface {
5353
- **Reject** — poison pill, move to DLQ (or ack if DLQ disabled)
5454
- **ExtendVisibilityTimeout** — extend processing window for long-running work
5555

56-
**`Postpone` vs `Nack` vs `ExtendVisibilityTimeout`:** `Nack` is a failure — the message is immediately eligible again, the redelivery counts toward `Retry.MaxAttempts` and eventually trips the DLQ, and later offsets in the partition keep flowing past the nacked message (a failed message must not halt its partition). `Postpone` is a deliberate wait — the redelivery happens after the chosen delay, resets the failure streak (it restarts at attempt 1), and blocks the partition behind it until it redelivers, in order. `ExtendVisibilityTimeout` is neither: the delivery is still being processed and stays in flight.
56+
**`Postpone` vs `Nack` vs `ExtendVisibilityTimeout`:** `Nack` is a failure — the message is immediately eligible again, the redelivery counts toward `Retry.MaxAttempts` and eventually trips the DLQ when the limit is finite, and later offsets in the partition keep flowing past the nacked message (a failed message must not halt its partition). `Postpone` is a deliberate wait — the redelivery happens after the chosen delay, resets the failure streak (it restarts at attempt 1), and blocks the partition behind it until it redelivers, in order. `ExtendVisibilityTimeout` is neither: the delivery is still being processed and stays in flight.
5757

5858
### SubscriptionConfig
5959

@@ -70,6 +70,8 @@ cfg.DLQ.Enabled = true
7070

7171
See `subscription_config.go` for all fields and defaults.
7272

73+
`Retry.MaxAttempts` uses zero to mean unlimited attempts. `DLQSubscriptionConfig` selects this mode and disables a second-level DLQ so reconciliation messages remain retryable until they converge or an operator removes them.
74+
7375
## Usage
7476

7577
```go

‎platform/extension/messagequeue/mysql/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ subConfig.DLQ.TopicSuffix = "_dlq" // DLQ topic suffix
7474
| `VisibilityTimeoutMs` | How long messages are invisible after fetch. Must exceed max processing time for `BatchSize=1` |
7575
| `LeaseRenewalIntervalMs` | How often to renew partition leases |
7676
| `LeaseDurationMs` | How long leases remain valid without renewal |
77-
| `Retry.MaxAttempts` | Maximum processing attempts before DLQ |
77+
| `Retry.MaxAttempts` | Maximum processing attempts before DLQ; zero retries indefinitely |
7878
| `DLQ.TopicSuffix` | Suffix appended to topic name for DLQ (e.g., `"orders"` → `"orders_dlq"`) |
7979

8080
## Package Layout

‎platform/extension/messagequeue/mysql/subscriber.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ func (d *sqlDelivery) Nack(ctx context.Context, f failure.Failure) error {
341341
return &ErrAlreadyAcknowledged{DeliveryID: d.deliveryID}
342342
}
343343

344-
if d.retry.MaxAttempts > 0 && d.attempt >= d.retry.MaxAttempts {
344+
if retryBudgetExhausted(d.retry.MaxAttempts, d.attempt) {
345345
d.subscriber.logger.Warnw("message exhausted retry budget, dead-lettering",
346346
"topic", d.topic,
347347
"partition_key", d.partitionKey,
@@ -1188,8 +1188,7 @@ func (w *partitionWorker) pollAndDeliver(ctx context.Context) (retErr error) {
11881188
return fmt.Errorf("mark delivered offset=%d: %w", row.Offset, err)
11891189
}
11901190

1191-
// Check if message has exceeded retry limit
1192-
if retryCount >= cfg.Retry.MaxAttempts {
1191+
if retryBudgetExhausted(cfg.Retry.MaxAttempts, retryCount) {
11931192
s.logger.Warnw("message exceeded retry limit",
11941193
"topic", sub.topic,
11951194
"consumer_group", cfg.ConsumerGroup,
@@ -1610,6 +1609,10 @@ func retryBackoffMs(retry extqueue.RetryConfig, attempt int) int64 {
16101609
return int64(backoff)
16111610
}
16121611

1612+
func retryBudgetExhausted(maxAttempts, attempts int) bool {
1613+
return maxAttempts > 0 && attempts >= maxAttempts
1614+
}
1615+
16131616
func validateRetryConfig(retry extqueue.RetryConfig) error {
16141617
if retry.MaxAttempts < 0 {
16151618
return fmt.Errorf("retry MaxAttempts must be non-negative, got %d", retry.MaxAttempts)

‎platform/extension/messagequeue/mysql/subscriber_test.go‎

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,6 @@ func TestSQLDelivery_NackDeadLettersWhenBudgetSpent(t *testing.T) {
594594
retry: extqueue.RetryConfig{MaxAttempts: 1},
595595
wantDLQ: true,
596596
},
597-
// A zero budget is not "dead-letter immediately" — it is unconfigured,
598-
// and the poll loop still governs.
599-
{name: "unset budget never dead-letters here", attempt: 9},
600597
}
601598

602599
for _, tt := range tests {
@@ -642,6 +639,97 @@ func TestSQLDelivery_NackDeadLettersWhenBudgetSpent(t *testing.T) {
642639
}
643640
}
644641

642+
func TestPartitionWorker_PollRetryLimit(t *testing.T) {
643+
tests := []struct {
644+
name string
645+
maxAttempts int
646+
retryCount int
647+
expectAck bool
648+
expectDelivery bool
649+
expectedAttempt int
650+
}{
651+
{
652+
name: "finite subscription acknowledges after visibility expiry exhausts retries",
653+
maxAttempts: 3,
654+
retryCount: 3,
655+
expectAck: true,
656+
},
657+
{
658+
name: "unlimited subscription redelivers after visibility expiry",
659+
maxAttempts: 0,
660+
retryCount: 1001,
661+
expectDelivery: true,
662+
expectedAttempt: 1002,
663+
},
664+
}
665+
666+
for _, tt := range tests {
667+
t.Run(tt.name, func(t *testing.T) {
668+
ctrl := gomock.NewController(t)
669+
mockMessageStore := NewMockmessageStore(ctrl)
670+
mockOffsetStore := NewMockoffsetStore(ctrl)
671+
mockDeliveryState := NewMockdeliveryStateStore(ctrl)
672+
673+
s := NewSubscriber(
674+
zaptest.NewLogger(t).Sugar(),
675+
tally.NoopScope,
676+
mockMessageStore,
677+
mockOffsetStore,
678+
NewMockpartitionLeaseStore(ctrl),
679+
newTestHeartbeatStore(ctrl),
680+
mockDeliveryState,
681+
)
682+
683+
cfg := testSubscriptionConfig()
684+
cfg.Retry.MaxAttempts = tt.maxAttempts
685+
cfg.DLQ.Enabled = false
686+
deliveryCh := make(chan extqueue.Delivery, 1)
687+
sub := &subscription{
688+
topic: "test_topic",
689+
config: cfg,
690+
deliveryCh: deliveryCh,
691+
workers: make(map[string]*partitionWorker),
692+
}
693+
worker := &partitionWorker{
694+
partitionKey: "part-1",
695+
sub: sub,
696+
subscriber: s,
697+
done: make(chan struct{}),
698+
}
699+
row := messageRow{
700+
ID: "msg-1",
701+
Offset: 1,
702+
PartitionKey: "part-1",
703+
Payload: []byte("payload"),
704+
PublishedAt: time.Now().UnixMilli(),
705+
}
706+
707+
mockOffsetStore.EXPECT().Initialize(gomock.Any(), "test_topic", "part-1", cfg.ConsumerGroup).Return(nil)
708+
mockOffsetStore.EXPECT().GetAckedOffset(gomock.Any(), "test_topic", "part-1", cfg.ConsumerGroup).Return(int64(0), nil).Times(2)
709+
mockMessageStore.EXPECT().FetchByOffset(gomock.Any(), "test_topic", "part-1", int64(0), cfg.BatchSize).Return([]messageRow{row}, nil)
710+
mockDeliveryState.EXPECT().GetDeliveryState(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(1)).
711+
Return(DeliveryState{InvisibleUntil: time.Now().Add(-time.Second).UnixMilli(), RetryCount: tt.retryCount}, true, nil)
712+
mockDeliveryState.EXPECT().MarkDelivered(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(1), cfg.VisibilityTimeoutMs).
713+
Return(tt.retryCount, nil)
714+
if tt.expectAck {
715+
mockDeliveryState.EXPECT().MarkAcked(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(1)).Return(nil)
716+
}
717+
mockMessageStore.EXPECT().GetOffsetsAbove(gomock.Any(), "test_topic", "part-1", int64(0), watermarkAdvancementLimit).Return([]int64{1}, nil)
718+
mockDeliveryState.EXPECT().AdvanceWatermark(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(0), []int64{1}).Return(int64(0), nil)
719+
720+
require.NoError(t, worker.pollAndDeliver(context.Background()))
721+
722+
select {
723+
case delivery := <-deliveryCh:
724+
require.True(t, tt.expectDelivery)
725+
assert.Equal(t, tt.expectedAttempt, delivery.Attempt())
726+
default:
727+
assert.False(t, tt.expectDelivery)
728+
}
729+
})
730+
}
731+
}
732+
645733
// A message arriving from its original topic has no failure to report, which is
646734
// how a DLQ consumer tells "nothing recorded" apart from a recorded failure
647735
// that named nothing.

‎platform/extension/messagequeue/subscription_config.go‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ type SubscriptionConfig struct {
6464
type RetryConfig struct {
6565
// MaxAttempts is the maximum number of processing attempts.
6666
// After this many attempts, the message is moved to DLQ (if enabled).
67+
// Zero means unlimited attempts.
6768
MaxAttempts int
6869

6970
// InitialBackoffMs is the delay after the first failed attempt (in milliseconds).
@@ -90,20 +91,14 @@ type DLQConfig struct {
9091
TopicSuffix string
9192
}
9293

93-
// DLQSubscriptionConfig returns a SubscriptionConfig for consuming a dead-letter
94-
// topic (DLQ reconciliation). It starts from DefaultSubscriptionConfig and applies
95-
// the two overrides every DLQ consumer needs:
96-
//
97-
// - DLQ.Enabled is false, so a reconciliation failure retries in place instead of
98-
// cascading to a second-level "_dlq_dlq" topic that nobody consumes.
99-
// - Retry.MaxAttempts is a very high backstop so the per-message retry budget
100-
// effectively never runs out. This pairs with errs.AlwaysRetryableProcessor
101-
// wired into the DLQ consumer: reconciliation converges eventually instead of
102-
// being silently dropped after the default retry count.
94+
// DLQSubscriptionConfig returns a final-DLQ reconciliation subscription.
95+
// It disables a second-level DLQ and sets MaxAttempts to zero (unlimited).
96+
// Paired with errs.AlwaysRetryableProcessor, errors redeliver until the
97+
// reconciliation converges or an operator removes the message.
10398
func DLQSubscriptionConfig(subscriberName, consumerGroup string) SubscriptionConfig {
10499
config := DefaultSubscriptionConfig(subscriberName, consumerGroup)
105100
config.DLQ.Enabled = false
106-
config.Retry.MaxAttempts = 1000
101+
config.Retry.MaxAttempts = 0
107102
return config
108103
}
109104

‎platform/extension/messagequeue/subscription_config_test.go‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,9 @@ func TestDLQSubscriptionConfig(t *testing.T) {
7878

7979
assert.Equal(t, "worker-1", config.SubscriberName)
8080
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.
8481
assert.False(t, config.DLQ.Enabled)
85-
assert.Greater(t, config.Retry.MaxAttempts, DefaultSubscriptionConfig("worker-1", "consumer-1").Retry.MaxAttempts)
82+
assert.Zero(t, config.Retry.MaxAttempts)
83+
assert.Positive(t, DefaultSubscriptionConfig("worker-1", "consumer-1").Retry.MaxAttempts)
8684
}
8785

8886
func TestSubscriptionConfig_DifferentConsumerGroups(t *testing.T) {

‎platform/pipeline/pipeline_test.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ func TestBuildTopicConfigs(t *testing.T) {
415415
assert.Equal(t, consumer.TopicKey("start"), configs[0].Key)
416416
assert.Equal(t, "start", configs[0].Name)
417417
assert.Equal(t, "orchestrator", configs[0].Subscription.ConsumerGroup)
418+
assert.Positive(t, configs[0].Subscription.Retry.MaxAttempts)
418419

419420
// Verify DLQ config derived from primary.
420421
assert.Equal(t, consumer.TopicKey("start_dlq"), configs[1].Key)
@@ -425,6 +426,7 @@ func TestBuildTopicConfigs(t *testing.T) {
425426
expected := extqueue.DLQSubscriptionConfig("test-sub", "orchestrator-dlq")
426427
assert.Equal(t, expected.DLQ.Enabled, configs[1].Subscription.DLQ.Enabled)
427428
assert.Equal(t, expected.Retry.MaxAttempts, configs[1].Subscription.Retry.MaxAttempts)
429+
assert.Zero(t, configs[1].Subscription.Retry.MaxAttempts)
428430

429431
// Verify validate stage (primary + DLQ).
430432
assert.Equal(t, consumer.TopicKey("validate"), configs[2].Key)

‎service/runway/server/BUILD.bazel‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ go_test(
100100
},
101101
deps = [
102102
"//api/base/mergestrategy/protopb:go_default_library",
103+
"//api/runway/messagequeue:go_default_library",
103104
"//platform/base/failure:go_default_library",
104105
"//platform/base/messagequeue:go_default_library",
105106
"//platform/consumer:go_default_library",
@@ -108,6 +109,7 @@ go_test(
108109
"//platform/extension/messagequeue/mock:go_default_library",
109110
"//platform/git/exec:go_default_library",
110111
"//platform/git/exectest:go_default_library",
112+
"//runway/controller/dlq:go_default_library",
111113
"//runway/extension/merger/git:go_default_library",
112114
"@com_github_stretchr_testify//assert:go_default_library",
113115
"@com_github_stretchr_testify//require:go_default_library",

‎service/runway/server/main.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,7 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
699699
// DLQ topics: the reconciler consumes these and republishes a FAILED
700700
// result to the corresponding signal topic. Names match the primary
701701
// topic name plus the "_dlq" suffix the subscriber uses when
702-
// dead-lettering (see dlq.TopicKey / DefaultSubscriptionConfig).
702+
// dead-lettering (see dlq.TopicKey / DLQSubscriptionConfig).
703703
{
704704
Key: dlq.TopicKey(runwaymq.TopicKeyMergeConflictCheck),
705705
Name: "merge-conflict-check_dlq",

0 commit comments

Comments
 (0)