|
| 1 | +# Conclude Request Failures |
| 2 | + |
| 3 | +Broaden the existing `conclude` stage so controllers and DLQs can send it structured request failures. Do not add a separate `request-failure` topic. |
| 4 | + |
| 5 | +## Current versus proposed |
| 6 | + |
| 7 | +Current `conclude` consumes a `BatchID`, loads the terminal batch, updates each member request, and publishes its terminal request log. |
| 8 | + |
| 9 | +The proposal keeps that path and adds `RequestFailureID` as a second tagged input: |
| 10 | + |
| 11 | +| | Current | Proposed | |
| 12 | +|---|---|---| |
| 13 | +| Input | `BatchID` | `BatchID` or `RequestFailureID` | |
| 14 | +| Source | Terminal batch | Terminal batch, business rejection, or DLQ | |
| 15 | +| Context | Batch state and ID | Structured stage, code, message, and metadata | |
| 16 | +| Failure ownership | Controllers and DLQs may terminalize directly | They delegate terminalization and logging to `conclude` | |
| 17 | + |
| 18 | +The finalization algorithm is not new. The change is a broader input contract and durable failure context. |
| 19 | + |
| 20 | +``` |
| 21 | +terminal batch ────────────────────────────────> conclude { batch_id } |
| 22 | +
|
| 23 | +known business rejection ─> RequestFailure ────> conclude { failure_id } |
| 24 | +
|
| 25 | +permanent controller error ─> DLQ ─> RequestFailure ─> conclude { failure_id } |
| 26 | +``` |
| 27 | + |
| 28 | +Both business-decision controllers and DLQs publish to `conclude`. A controller publishes only after reaching a known terminal decision. Execution and infrastructure errors continue to return as errors while recovery remains possible; the DLQ publishes only after they become permanent. |
| 29 | + |
| 30 | +## Contracts |
| 31 | + |
| 32 | +`ConcludeMessage` is an internal protobuf with a tagged `oneof`: |
| 33 | + |
| 34 | +```proto |
| 35 | +message ConcludeMessage { |
| 36 | + oneof target { |
| 37 | + string batch_id = 1; |
| 38 | + string request_failure_id = 2; |
| 39 | + } |
| 40 | +} |
| 41 | +``` |
| 42 | + |
| 43 | +This avoids mixing bare `BatchID` and `RequestFailureID` payloads on one topic. |
| 44 | + |
| 45 | +`RequestFailure` is an immutable orchestrator entity containing a stable ID, request ID, stage, kind (`business_rejection` or `controller_failure`), stable code, message, metadata, and creation time. Producers create it idempotently; the queue carries only its ID. |
| 46 | + |
| 47 | +## Controller change |
| 48 | + |
| 49 | +Today a controller such as `mergeconflictsignal` terminalizes a business rejection directly: |
| 50 | + |
| 51 | +```go |
| 52 | +if result.Outcome != runwaypb.Outcome_SUCCEEDED { |
| 53 | + return c.failRequest(ctx, request, result.Reason) |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +Under this proposal it persists the decision and delegates finalization: |
| 58 | + |
| 59 | +```go |
| 60 | +if result.Outcome != runwaypb.Outcome_SUCCEEDED { |
| 61 | + failure := entity.NewRequestFailure( |
| 62 | + request.ID, |
| 63 | + "mergeconflictsignal", |
| 64 | + entity.FailureKindBusinessRejection, |
| 65 | + "mergeability_check_failed", |
| 66 | + result.Reason, |
| 67 | + metadata, |
| 68 | + ) |
| 69 | + if err := failureStore.Create(ctx, failure); err != nil { |
| 70 | + return fmt.Errorf("create request failure: %w", err) |
| 71 | + } |
| 72 | + return publishConcludeFailure(ctx, failure.ID, request.Queue) |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +The controller no longer changes `Request.State` or publishes a terminal request log. |
| 77 | + |
| 78 | +## DLQ change |
| 79 | + |
| 80 | +Today a request-scoped DLQ changes the request to `RequestStateError` and publishes a log using `dlq.last_error`. |
| 81 | + |
| 82 | +Under this proposal it creates a generic durable failure and publishes it to `conclude`: |
| 83 | + |
| 84 | +```go |
| 85 | +failure := entity.NewRequestFailure( |
| 86 | + requestID, |
| 87 | + originalTopic, |
| 88 | + entity.FailureKindControllerFailure, |
| 89 | + "controller_failure", |
| 90 | + delivery.Metadata()["dlq.last_error"], |
| 91 | + nil, |
| 92 | +) |
| 93 | +if err := failureStore.Create(ctx, failure); err != nil { |
| 94 | + return err |
| 95 | +} |
| 96 | +return publishConcludeFailure(ctx, failure.ID, partitionKey) |
| 97 | +``` |
| 98 | + |
| 99 | +A previously persisted business failure wins over a later generic DLQ failure for the same logical transition. A batch-scoped DLQ still marks the batch `BatchStateFailed`, then publishes one failure conclusion per member to preserve the original error context. |
| 100 | + |
| 101 | +## Broadened conclude |
| 102 | + |
| 103 | +`conclude` dispatches on the tagged reference: |
| 104 | + |
| 105 | +```go |
| 106 | +switch target := msg.Target.(type) { |
| 107 | +case *ConcludeMessage_BatchId: |
| 108 | + return c.concludeBatch(ctx, target.BatchId) |
| 109 | +case *ConcludeMessage_RequestFailureId: |
| 110 | + return c.concludeFailure(ctx, target.RequestFailureId) |
| 111 | +default: |
| 112 | + return errors.New("conclude message has no target") |
| 113 | +} |
| 114 | +``` |
| 115 | + |
| 116 | +- `concludeBatch` retains the current batch fan-out behavior. |
| 117 | +- `concludeFailure` loads the failure and request, conditionally changes the request to `RequestStateError`, and publishes `RequestStatusError` with the structured context. |
| 118 | +- Both paths use the same request reconciliation and log-repair helper. |
| 119 | +- Gateway remains the sole writer of the request-log database. |
| 120 | + |
| 121 | +## Replay and rollout |
| 122 | + |
| 123 | +- Failure creation and conclusion publication use stable identities. |
| 124 | +- If state update succeeds but log publication fails, redelivery republishes the log. |
| 125 | +- Version conflicts return errors so redelivery reloads current state. |
| 126 | +- A different terminal request outcome is left unchanged. |
| 127 | +- `conclude_dlq` replays a referenced failure instead of creating another one. |
| 128 | + |
| 129 | +To preserve in-flight messages, first deploy a decoder that accepts both legacy bare `BatchID` payloads and `ConcludeMessage`. Then migrate batch producers, add failure producers, and remove legacy decoding after the topic retention window. |
0 commit comments