Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/rfc/submitqueue/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ Every consumed stage in `pipeline.go` is paired with a `{topic}_dlq` subscriptio

DLQ handlers do not resume the failed request's normal workflow. They reconcile terminal state and its outputs, preserving authoritative terminal winners. The generic request reconciler also preserves valid downstream batch ownership. The [DLQ package guide](../../../submitqueue/orchestrator/controller/dlq/README.md) defines the reconcile-only boundary, handler-specific dispositions, and named limits; the [stage table](../../../submitqueue/orchestrator/pipeline.go) defines the bindings. Unlimited final retries protect reconciliation, not another business retry budget.

The start DLQ reports a failed submission through an Error request log with version 0 when no Request was saved. Existing rows retain their ownership, terminal-outcome, and conditional-write protections.

The narrow speculate nudge concerns other live batches after culprit failure; it never reopens the failed batch. Hook DLQ is report-and-stop, with side-effect recovery left to a person. Neither authorizes replay of the failed business command.

The orchestrator publishes terminal status logs and the gateway alone materializes them; replaying a log into gateway read models is not orchestration restart. Runway owns failure-result publication for dead letters on its inbound merge queues, without rerunning the merge.
Expand Down
4 changes: 4 additions & 0 deletions submitqueue/orchestrator/controller/dlq/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ go_library(
"log.go",
"request.go",
"speculate.go",
"start.go",
],
importpath = "github.com/uber/submitqueue/submitqueue/orchestrator/controller/dlq",
visibility = ["//visibility:public"],
Expand Down Expand Up @@ -44,12 +45,15 @@ go_test(
"publisher_test.go",
"request_test.go",
"speculate_test.go",
"start_test.go",
],
embed = [":go_default_library"],
deps = [
"//api/runway/messagequeue:go_default_library",
"//api/runway/messagequeue/protopb:go_default_library",
"//platform/base/change:go_default_library",
"//platform/base/failure:go_default_library",
"//platform/base/mergestrategy:go_default_library",
"//platform/base/messagequeue:go_default_library",
"//platform/consumer:go_default_library",
"//platform/consumer/mock:go_default_library",
Expand Down
4 changes: 3 additions & 1 deletion submitqueue/orchestrator/controller/dlq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Dead letters have exhausted the primary retry budget or returned a non-retryable

Disposition remains handler-specific: the generic request/batch reconcilers acknowledge missing entities; buildsignal also acknowledges a missing build or empty batch ID. Decode errors returned by these handlers remain retryable and may need operator removal. Payload/tenant mismatches are acknowledged, while invalid envelope tenant metadata is rejected before controller error processing. The shared hook handler reports and acknowledges even undecodable events. Final retry policy therefore does not promise retention of every poison message or recreation of every missing entity.

When the Request is absent, Start reports the failed submission through an Error request log with version 0, because there is no saved Request version. No normal work is published. Reconciliation validates the request identity and queue; existing rows retain authoritative-identity, batch-ownership, terminal-winner, and conditional-write guards.

## Terminal outputs and ownership

- An already-Error request skips the state CAS but republishes its terminal status log. A different terminal request outcome is left unchanged. This repairs status delivery, not the original diagnostic content or timestamp.
Expand All @@ -29,7 +31,7 @@ Request logs are published to `log`; the gateway alone materializes them into it

The [stage table](../../pipeline.go) is the source of truth rather than a second topology table here:

- `start`, `cancel`, `validate`, and `batch` use generic request reconciliation. Their decoders read LandRequest, CancelRequest, or the topic's request-ID payload.
- `start` uses dedicated request reconciliation; `cancel`, `validate`, and `batch` use generic request reconciliation. Their decoders read LandRequest, CancelRequest, or the topic's request-ID payload.
- `dependency-analysis`, `build`, `submitqueue-land`, and `conclude` use generic batch reconciliation.
- `buildsignal` carries a build-ID payload, not a full Build. Its handler loads the build to find the batch and leaves build status untouched. The two Runway signal handlers read a request or batch correlation ID from `MergeResult`.
- **Speculation queue-isolation nudge:** after failing attributed culprit work (or the labelled payload fallback), a delivery that transitioned a batch may nudge `speculate` with another live batch. The primary re-derives current queue state; the failed batch is not reopened. This narrow protection for other work does not authorize replay of the dead-lettered command or guarantee a nudge after every partial reconciliation failure.
Expand Down
46 changes: 19 additions & 27 deletions submitqueue/orchestrator/controller/dlq/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ func DecodeRequestID(primary consumer.TopicKey) RequestIDDecoder {
}

// requestController is the DLQ reconciler for request-scoped pipeline stages.
// It is registered once per primary request-scoped topic (start, cancel,
// validate, batch) with the matching decoder. On each delivery it decodes the
// It is registered once per primary request-scoped topic (cancel, validate,
// batch) with the matching decoder. On each delivery it decodes the
// request ID and transitions the request to RequestStateError, unless a live
// batch already owns the request's outcome.
type requestController struct {
Expand Down Expand Up @@ -164,7 +164,7 @@ func (c *requestController) Process(ctx context.Context, delivery consumer.Deliv
}
if owner != "" {
metrics.NamedCounter(c.metricsScope, opName, "skipped_owned_by_batch", 1, metrics.TagsFromContext(ctx)...)
c.logger.Infow("dlq reconcile: request is carried by a live batch, leaving the outcome to conclude",
c.logger.Infow("dlq reconcile: batch owns the request outcome, leaving completion to conclude",
"request_id", rid.ID,
"batch_id", owner,
)
Expand All @@ -179,53 +179,45 @@ func (c *requestController) Process(ctx context.Context, delivery consumer.Deliv
return nil
}

// owningBatch returns a batch that owns the request's outcome, or the empty
// string if none does.
//
// A non-terminal batch past Creating owns the request outright: conclude writes
// its outcome when the batch finishes. Creating is the ambiguous case, because
// the association is written before the claim that enrols the request, so it
// also survives an abandoned attempt — one whose claim lost to cancel, or found
// the request already halted. Such a batch is inert: nothing promotes it and no
// conclude will ever run for it, so letting it answer "owned" would suppress
// the reconcile forever and strand the request.
//
// The request's own state separates the two, because the claim is the only
// writer of Batched: a Creating batch alongside a Batched request is
// mid-promotion and owns it, and any other request state means the claim never
// landed. A request that has since disappeared is left to failRequest, which
// reports the missing row.
// owningBatch returns a batch that is still running or whose result has not been recorded on the request yet.
// Creating batches must have claimed the request; failed batches do not stop it from being marked Error.
func owningBatch(ctx context.Context, store orchstorage.Storage, requestID string) (string, error) {
batches, _, err := corebatch.FindByRequestID(ctx, store, requestID)
if err != nil {
return "", err
}

creating := ""
owner := ""
needsClaim := false
for _, batch := range batches {
switch {
case batch.State.IsTerminal():
case batch.State == entity.BatchStateFailed:
case batch.State == entity.BatchStateSucceeded || batch.State == entity.BatchStateCancelled:
owner, needsClaim = batch.ID, false
case batch.State != entity.BatchStateCreating:
return batch.ID, nil
case creating == "":
creating = batch.ID
case owner == "":
owner, needsClaim = batch.ID, true
}
}
if creating == "" {
if owner == "" {
return "", nil
}

request, err := store.GetRequestStore().Get(ctx, requestID)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
if errors.Is(err, storage.ErrNotFound) && needsClaim {
return "", nil
}
if errors.Is(err, storage.ErrNotFound) {
return owner, nil
}
return "", err
}
if request.State != entity.RequestStateBatched {
if entity.IsRequestStateTerminal(request.State) || needsClaim && request.State != entity.RequestStateBatched {
return "", nil
}
return creating, nil
return owner, nil
}

// Name returns the controller name for logging and metrics.
Expand Down
5 changes: 2 additions & 3 deletions submitqueue/orchestrator/controller/dlq/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,8 @@ func TestDLQRequestController_Process_SkipsRequestOwnedByLiveBatch(t *testing.T)
}
}

// A batch that already concluded does not own anything conclude has not written
// yet, so the request is still the dead letter's to fail.
func TestDLQRequestController_Process_FailsWhenEveryBatchIsTerminal(t *testing.T) {
// A failed batch permits the same Error outcome to be reconciled.
func TestDLQRequestController_Process_FailsWhenAssociatedBatchFailed(t *testing.T) {
ctrl := gomock.NewController(t)

request := entity.Request{ID: "1", Queue: "q", Version: 1, State: entity.RequestStateBatched}
Expand Down
134 changes: 134 additions & 0 deletions submitqueue/orchestrator/controller/dlq/start.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package dlq

import (
"context"
"errors"
"fmt"

"github.com/uber-go/tally"
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
"github.com/uber/submitqueue/platform/consumer"
"github.com/uber/submitqueue/platform/metrics"
"github.com/uber/submitqueue/submitqueue/entity"
"github.com/uber/submitqueue/submitqueue/extension/storage"
corerequest "github.com/uber/submitqueue/submitqueue/orchestrator/core/request"
orchstorage "github.com/uber/submitqueue/submitqueue/orchestrator/extension/storage"
"go.uber.org/zap"
)

type startController struct {
logger *zap.SugaredLogger
metricsScope tally.Scope
stores orchstorage.Factory
registry consumer.TopicRegistry
topicKey consumer.TopicKey
consumerGroup string
}

// NewDLQStartController reconciles failed submissions without restarting processing.
func NewDLQStartController(logger *zap.SugaredLogger, scope tally.Scope, stores orchstorage.Factory, registry consumer.TopicRegistry, topicKey consumer.TopicKey, consumerGroup string) consumer.Controller {
return &startController{
logger: logger.Named("start_dlq"),
metricsScope: scope.SubScope("start_dlq"),
stores: stores,
registry: registry,
topicKey: topicKey,
consumerGroup: consumerGroup,
}
}

func (c *startController) Process(ctx context.Context, delivery consumer.Delivery) (err error) {
defer func() {
if err != nil {
metrics.NamedCounter(c.metricsScope, "process", "reconcile_errors", 1, metrics.TagsFromContext(ctx)...)
}
}()

msg := delivery.Message()
rid, err := DecodeLandRequestID(msg.Payload)
if err != nil {
return fmt.Errorf("failed to decode start dlq payload: %w", err)
}
if err := entityqueue.ValidatePayloadQueue(msg, rid.Queue); err != nil {
metrics.NamedCounter(c.metricsScope, "process", "queue_identity_errors", 1, metrics.TagsFromContext(ctx)...)
c.logger.Warnw("invalid start dlq queue identity", "error", err)
return nil
}
if rid.ID == "" {
return fmt.Errorf("dlq payload decoded to empty request id")
}

store, err := c.stores.For(orchstorage.Config{QueueName: msg.Tenant})
if err != nil {
return fmt.Errorf("failed to resolve storage for queue %q: %w", rid.Queue, err)
}
lastError, metadata := failureContext(delivery)
c.logger.Warnw("reconciling failed start", "request_id", rid.ID, "queue", rid.Queue, "attempt", delivery.Attempt())
request, err := store.GetRequestStore().Get(ctx, rid.ID)
switch {
case errors.Is(err, storage.ErrNotFound):
err = c.publishStartFailure(ctx, rid.ID, msg.Tenant, 0, lastError, metadata)
case err == nil:
err = c.failExistingStartRequest(ctx, store, request, rid, lastError, metadata)
}
return err
}

func (c *startController) failExistingStartRequest(ctx context.Context, store orchstorage.Storage, request entity.Request, rid entity.RequestID, lastError string, metadata map[string]string) error {
if request.ID != rid.ID || request.Queue != rid.Queue {
return fmt.Errorf("conflicting request identity for failed start request %s", rid.ID)
}
// A Start retry can fail after an earlier delivery published validation and the request joined a batch.
// Let that batch finish instead of changing its request to Error.
owner, err := owningBatch(ctx, store, request.ID)
Comment thread
sbalabanov marked this conversation as resolved.
Resolved
if err != nil || owner != "" {
return err
}
switch request.State {
case entity.RequestStateError:
_, err := corerequest.TerminateRequest(ctx, store, c.registry, request.ID, entity.RequestStateError, lastError, metadata)
return err
case entity.RequestStateLanded, entity.RequestStateCancelled:
return nil
}
updated := request
updated.State = entity.RequestStateError
newVersion := request.Version + 1
// The observed version guards against a batch claim or terminal winner after the owner lookup.
if err := store.GetRequestStore().Update(ctx, updated, request.Version, newVersion); err != nil {
return fmt.Errorf("failed to fail start request: %w", err)
}
updated.Version = newVersion
return c.publishStartFailure(ctx, updated.ID, updated.Queue, updated.Version, lastError, metadata)
}

func (c *startController) publishStartFailure(ctx context.Context, requestID, queue string, version int32, lastError string, metadata map[string]string) error {
entry := entity.NewRequestStatusLog(queue, requestID, entity.RequestStatusError, version, lastError, metadata)
return corerequest.PublishLog(ctx, c.registry, entry, requestID, "")
}

func (c *startController) Name() string {
return string(c.topicKey)
}

func (c *startController) TopicKey() consumer.TopicKey {
return c.topicKey
}

func (c *startController) ConsumerGroup() string {
return c.consumerGroup
}
Loading
Loading