Skip to content

Commit ee84d5f

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 c9ee7a8 commit ee84d5f

13 files changed

Lines changed: 195 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
@@ -325,7 +325,7 @@ func (d *sqlDelivery) Nack(ctx context.Context, f failure.Failure) error {
325325
return &ErrAlreadyAcknowledged{DeliveryID: d.deliveryID}
326326
}
327327

328-
if d.retry.MaxAttempts > 0 && d.attempt >= d.retry.MaxAttempts {
328+
if retryBudgetExhausted(d.retry.MaxAttempts, d.attempt) {
329329
d.subscriber.logger.Warnw("message exhausted retry budget, dead-lettering",
330330
"topic", d.topic,
331331
"partition_key", d.partitionKey,
@@ -1120,8 +1120,7 @@ func (w *partitionWorker) pollAndDeliver(ctx context.Context) (retErr error) {
11201120
return fmt.Errorf("mark delivered offset=%d: %w", row.Offset, err)
11211121
}
11221122

1123-
// Check if message has exceeded retry limit
1124-
if retryCount >= cfg.Retry.MaxAttempts {
1123+
if retryBudgetExhausted(cfg.Retry.MaxAttempts, retryCount) {
11251124
s.logger.Warnw("message exceeded retry limit",
11261125
"topic", sub.topic,
11271126
"consumer_group", cfg.ConsumerGroup,
@@ -1544,6 +1543,10 @@ func retryBackoffMs(retry extqueue.RetryConfig, attempt int) int64 {
15441543
return int64(backoff)
15451544
}
15461545

1546+
func retryBudgetExhausted(maxAttempts, attempts int) bool {
1547+
return maxAttempts > 0 && attempts >= maxAttempts
1548+
}
1549+
15471550
func validateRetryConfig(retry extqueue.RetryConfig) error {
15481551
if retry.MaxAttempts < 0 {
15491552
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
@@ -536,9 +536,6 @@ func TestSQLDelivery_NackDeadLettersWhenBudgetSpent(t *testing.T) {
536536
retry: extqueue.RetryConfig{MaxAttempts: 1},
537537
wantDLQ: true,
538538
},
539-
// A zero budget is not "dead-letter immediately" — it is unconfigured,
540-
// and the poll loop still governs.
541-
{name: "unset budget never dead-letters here", attempt: 9},
542539
}
543540

544541
for _, tt := range tests {
@@ -583,6 +580,97 @@ func TestSQLDelivery_NackDeadLettersWhenBudgetSpent(t *testing.T) {
583580
}
584581
}
585582

583+
func TestPartitionWorker_PollRetryLimit(t *testing.T) {
584+
tests := []struct {
585+
name string
586+
maxAttempts int
587+
retryCount int
588+
expectAck bool
589+
expectDelivery bool
590+
expectedAttempt int
591+
}{
592+
{
593+
name: "finite subscription acknowledges after visibility expiry exhausts retries",
594+
maxAttempts: 3,
595+
retryCount: 3,
596+
expectAck: true,
597+
},
598+
{
599+
name: "unlimited subscription redelivers after visibility expiry",
600+
maxAttempts: 0,
601+
retryCount: 1001,
602+
expectDelivery: true,
603+
expectedAttempt: 1002,
604+
},
605+
}
606+
607+
for _, tt := range tests {
608+
t.Run(tt.name, func(t *testing.T) {
609+
ctrl := gomock.NewController(t)
610+
mockMessageStore := NewMockmessageStore(ctrl)
611+
mockOffsetStore := NewMockoffsetStore(ctrl)
612+
mockDeliveryState := NewMockdeliveryStateStore(ctrl)
613+
614+
s := NewSubscriber(
615+
zaptest.NewLogger(t).Sugar(),
616+
tally.NoopScope,
617+
mockMessageStore,
618+
mockOffsetStore,
619+
NewMockpartitionLeaseStore(ctrl),
620+
newTestHeartbeatStore(ctrl),
621+
mockDeliveryState,
622+
)
623+
624+
cfg := testSubscriptionConfig()
625+
cfg.Retry.MaxAttempts = tt.maxAttempts
626+
cfg.DLQ.Enabled = false
627+
deliveryCh := make(chan extqueue.Delivery, 1)
628+
sub := &subscription{
629+
topic: "test_topic",
630+
config: cfg,
631+
deliveryCh: deliveryCh,
632+
workers: make(map[string]*partitionWorker),
633+
}
634+
worker := &partitionWorker{
635+
partitionKey: "part-1",
636+
sub: sub,
637+
subscriber: s,
638+
done: make(chan struct{}),
639+
}
640+
row := messageRow{
641+
ID: "msg-1",
642+
Offset: 1,
643+
PartitionKey: "part-1",
644+
Payload: []byte("payload"),
645+
PublishedAt: time.Now().UnixMilli(),
646+
}
647+
648+
mockOffsetStore.EXPECT().Initialize(gomock.Any(), "test_topic", "part-1", cfg.ConsumerGroup).Return(nil)
649+
mockOffsetStore.EXPECT().GetAckedOffset(gomock.Any(), "test_topic", "part-1", cfg.ConsumerGroup).Return(int64(0), nil).Times(2)
650+
mockMessageStore.EXPECT().FetchByOffset(gomock.Any(), "test_topic", "part-1", int64(0), cfg.BatchSize).Return([]messageRow{row}, nil)
651+
mockDeliveryState.EXPECT().GetDeliveryState(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(1)).
652+
Return(DeliveryState{InvisibleUntil: time.Now().Add(-time.Second).UnixMilli(), RetryCount: tt.retryCount}, true, nil)
653+
mockDeliveryState.EXPECT().MarkDelivered(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(1), cfg.VisibilityTimeoutMs).
654+
Return(tt.retryCount, nil)
655+
if tt.expectAck {
656+
mockDeliveryState.EXPECT().MarkAcked(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(1)).Return(nil)
657+
}
658+
mockMessageStore.EXPECT().GetOffsetsAbove(gomock.Any(), "test_topic", "part-1", int64(0), watermarkAdvancementLimit).Return([]int64{1}, nil)
659+
mockDeliveryState.EXPECT().AdvanceWatermark(gomock.Any(), cfg.ConsumerGroup, "test_topic", "part-1", int64(0), []int64{1}).Return(int64(0), nil)
660+
661+
require.NoError(t, worker.pollAndDeliver(context.Background()))
662+
663+
select {
664+
case delivery := <-deliveryCh:
665+
require.True(t, tt.expectDelivery)
666+
assert.Equal(t, tt.expectedAttempt, delivery.Attempt())
667+
default:
668+
assert.False(t, tt.expectDelivery)
669+
}
670+
})
671+
}
672+
}
673+
586674
// A message arriving from its original topic has no failure to report, which is
587675
// how a DLQ consumer tells "nothing recorded" apart from a recorded failure
588676
// 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ go_test(
7979
srcs = [
8080
"checkout_test.go",
8181
"config_test.go",
82+
"main_test.go",
8283
],
8384
# Checkout provisioning runs real git, so the test uses the same pinned
8485
# runtime the merger does rather than whatever git the host happens to have.
@@ -97,7 +98,10 @@ go_test(
9798
},
9899
deps = [
99100
"//api/base/mergestrategy/protopb:go_default_library",
101+
"//api/runway/messagequeue:go_default_library",
102+
"//platform/consumer:go_default_library",
100103
"//platform/git/exectest:go_default_library",
104+
"//runway/controller/dlq:go_default_library",
101105
"//runway/extension/merger/git:go_default_library",
102106
"@com_github_stretchr_testify//assert:go_default_library",
103107
"@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
@@ -643,7 +643,7 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
643643
// DLQ topics: the reconciler consumes these and republishes a FAILED
644644
// result to the corresponding signal topic. Names match the primary
645645
// topic name plus the "_dlq" suffix the subscriber uses when
646-
// dead-lettering (see dlq.TopicKey / DefaultSubscriptionConfig).
646+
// dead-lettering (see dlq.TopicKey / DLQSubscriptionConfig).
647647
{
648648
Key: dlq.TopicKey(runwaymq.TopicKeyMergeConflictCheck),
649649
Name: "merge-conflict-check_dlq",

0 commit comments

Comments
 (0)