| id | 0077-delivery-count-contract | ||||
|---|---|---|---|---|---|
| title | Delivery Count Contract | ||||
| status | Accepted | ||||
| author |
|
||||
| created | 2026-09-24 | ||||
| summary | SQS, Pub/Sub and RocketMQ consumers read the broker's own delivery counter on receive and subtract 1 so a first delivery presents 0, keeping the stamped handled count on a Brighter-routed rejection copy (one carrying the rejectionReason metadata key); the pump stays the only budget enforcer, budget findings are three lower-rung ISpecification<Subscription> rules plus a channel-creation log, and GCP and RocketMQ keep pre-registered conditional branches. | ||||
| tags |
|
Date: 2026-09-24
Accepted
Parent Requirement: specs/0037-delivery-count-and-rejection-routing/requirements.md
Scope: This ADR decides the cross-transport delivery-count contract — Groups A–C and F–H (R-1 to R-15, R-22 to R-28) and the NFRs they touch — and how AWSSQS and AWSSQS.V4 (kept in lockstep, 0038-aws-sqs-dlq-direct-send), GcpPubSub (pull and stream consumers) and RocketMQ are bound to it. GCP rejection routing and DLQ channel creation without project IAM admin (R-16 to R-21, Groups D–E) belong to **ADR 0078, "GCP rejection routing and DLQ channel creation" ** (0078-gcp-rejection-routing-and-dlq-channel-creation, Proposed). R-15 is shared: this ADR relies only on its outcome (GcpPubSubSubscription exposes a dead-letter routing key); 0078 decides how.
The problem in one line. The pump enforces RequeueCount by UpdateHandledCount() then HandledCountReached(RequeueCount) (Reactor.cs:494-507, Proactor.cs:500-513), which only runs down if a redelivered message presents a larger HandledCount. SQS, Pub/Sub and RocketMQ re-serve their own stored copy, which Brighter never rewrites, so on 13 configurations the budget is inert.
Settled inputs taken as given: one spec (C-2); republish-on-requeue excluded (NFR-3, C-12); 0038-aws-sqs-dlq-direct-send's DLQ strategy stands (C-1); RocketMQ is conditional (R-14, C-5); budget findings go through ValidatePipelines (R-25/R-26); the GCP bar is the Pub/Sub emulator (R-21, C-4), A-1 verified, A-2/A-3 not.
On receive, each consumer reads the broker's own delivery counter and normalises it so that a first delivery presents 0 (R-2) — except when the received message is a Brighter-routed rejection copy, in which case the consumer keeps the handled count stamped in the message (R-28). A message is a routed copy when its Header.Bag carries the rejectionReason metadata key. A message put back on its source with that metadata still attached is therefore treated as a routed copy and rejected again. Stripping the metadata is the replaying tool's responsibility (edge case 2). No broker call is added, no state is kept, and the pump is unchanged: it remains the only budget enforcer.
broker receive ──► transport creator/parser (per-transport seam)
│ knows: raw broker counter, header-carried count, Header.Bag
▼
DeliveryCount.Resolve(headerCount, brokerCount, bag) ◄── core, decides the source
▼
Message.Header.HandledCount ──► pump: UpdateHandledCount(); HandledCountReached(R) ──► Reject(DeliveryError)
(enforces; unchanged)
Why the budget fires on delivery R exactly (R-4). Broker counter on delivery n is b(n) ≥ n, with b(1) = 1 as the origin. The consumer presents c(n) = b(n) − 1; the pump increments to c(n) + 1 = b(n) and tests b(n) ≥ R.
| delivery n | broker b (exact) |
presented c |
after UpdateHandledCount |
≥ R for R = 3? |
|---|---|---|---|---|
| 1 | 1 | 0 | 1 | no → requeue |
| 2 | 2 | 1 | 2 | no → requeue |
| 3 | 3 | 2 | 3 | yes → Reject(DeliveryError) |
Exact counter: rejection on delivery R, after R − 1 requeues. Approximate (b(n) ≥ n): on or before delivery R — R-4's "at most R". R = 1: 0 → 1 ≥ 1, rejected on first delivery. R = 0 or R < −1: 1 ≥ R true, first deferral rejects (R-7). R = −1: budget never consulted (MessagePump.cs:171, R-6).
What the dead-letter copy carries (R-5, R-28). Brighter sends the in-memory header, whose count is c(R) + 1 = b(R) ≥ R; the senders serialise it (SQS SqsMessageSender.cs:130 / SnsMessagePublisher.cs:110, RocketMQ RocketMqMessagePublisher.cs:103 — kept only once its bag loop skips keys already written, below — GCP Parser.cs:307). The copy also carries rejectionReason (SqsMessageConsumer.cs:508, RocketMessageConsumer.cs:256), so on a DLQ read the discriminator keeps the stamped count (≥ R; exactly R on an exact counter), not the DLQ's own counter, which would normalise to 0.
Roles (Responsibility-Driven Design):
| Role | Object | Knowing | Doing | Deciding |
|---|---|---|---|---|
| Counter reader (per transport) | SqsMessageCreator / SqsInlineMessageCreator (both packages), GcpPubSub/Parser, RocketMessageConsumer.CreateMessage |
raw broker counter, header-carried count, bag | extracts the inputs without allocating | nothing — delegates |
| Count resolver | new Paramore.Brighter.DeliveryCount (static, core) |
the normalisation rule and the discriminator | Normalise, Resolve |
which count source wins |
| Metadata vocabulary | new Paramore.Brighter.RejectionMetadataKeyNames (static, core) |
the five rejection-metadata key names ("rejectionReason", "originalTopic", …) |
— | — |
| Budget capability | new core role interface IAmADeliveryCountingSubscription, implemented by SqsSubscription ×2, GcpPubSubSubscription, RocketSubscription |
NativeRedriveLimit (M), DeliveryBudgetUnenforceableReason (R-11's predicate) |
— | whether this subscription can advance its count |
| Budget rules | three ISpecification<Subscription> rules in ConsumerValidationRules |
— | report Warnings via ValidatePipelines |
whether R-7 / R-10 / R-11 fires |
| Channel-creation diagnostic | new core DeliveryBudgetDiagnostics.WarnIfUnenforceable(Subscription), called by each in-scope channel factory |
— | logs R-11 once per channel | — |
| Budget enforcer | Reactor / Proactor |
— | UpdateHandledCount, HandledCountReached, RejectMessage |
unchanged |
Why DeliveryCount is a shared core helper, not four copies. The rule must be identical across four transports and two AWS packages (NFR-6); a copy in AWSSQS and another in AWSSQS.V4 is exactly the divergence NFR-6 calls a defect. Every in-scope assembly already references Paramore.Brighter, and the helper is pure arithmetic plus one dictionary lookup. The per-transport seams still decide where the raw value comes from — broker knowledge core must not have.
DeliveryCount contract:
public static class DeliveryCount
{
// R-2/R-3/AC-33: 1→0, 2→1, 3→2; null, 0 or negative → 0; never negative.
public static int Normalise(int? brokerCount) => brokerCount is > 1 ? brokerCount.Value - 1 : 0;
// R-28: routed rejection copy → stamped count; broker counter unavailable → header count
// (today's behaviour); otherwise → normalised broker counter.
public static int Resolve(int headerCount, int? brokerCount, IDictionary<string, object> bag)
=> bag.ContainsKey(RejectionMetadataKeyNames.RejectionReason) || brokerCount is null or < 1
? headerCount
: Normalise(brokerCount);
}No copy of the bag is made (MessageHeader.Bag is a Dictionary<string, object>, MessageHeader.cs:162).
Error conditions (none logs per message — NFR-2 forbids the allocation, NFR-4 the per-message noise):
| Condition | Presented count | Logged |
|---|---|---|
SQS ApproximateReceiveCount absent (Attributes null on V4, or key missing) or unparseable |
header count (today's behaviour) | nothing |
Pub/Sub DeliveryAttempt == 0 / GetDeliveryAttempt == null (no DeadLetterPolicy, A-1) |
header count (normally 0) |
R-11 Warning once at channel creation (R-26) |
RocketMQ DeliveryAttempt <= 0 |
header count | R-11 at channel creation only on the AC-25 branch |
| Routed rejection copy | stamped header count | nothing |
| Transport | Raw counter | Seam | Note |
|---|---|---|---|
AWSSQS |
sqsMessage.Attributes["ApproximateReceiveCount"], already on the wire (MessageSystemAttributeNames = ["All"], SqsMessageConsumer.cs:188-194) |
SqsMessageCreator.ReadHandledCount (:323, called :80, bag read :70); SqsInlineMessageCreator.ReadHandledCount (:350, called :60) |
The inline creator reads the handled count before the bag (:60 vs :76); the call order is swapped so Resolve can see it. |
AWSSQS.V4 |
same attribute; Attributes may be null in SDK v4 |
SqsMessageCreator.ReadHandledCount (:330, called :77); SqsInlineMessageCreator.ReadHandledCount (:317, called :60) |
lockstep twin; same call-order swap |
GcpPubSub pull |
ReceivedMessage.DeliveryAttempt (int, 0 without a policy) |
Parser.ToBrighterMessage(ReceivedMessage) (~:84; ReadHandleCount :167) |
Resolve runs after the bag is filled from attributes |
GcpPubSub stream |
PubsubExtensions.GetDeliveryAttempt(PubsubMessage) (int?) on GcpStreamMessage.Message (GcpStreamConsumer.cs:93) |
Parser.ToBrighterMessage(GcpStreamMessage) (:33) |
The library carries the value as attribute googclient_deliveryattempt. Decided here: add that key to Parser.s_ignoreHeaders so it never enters Header.Bag — otherwise it would be re-published on a routed copy (0078) and read back stale. |
RocketMQ |
MessageView.DeliveryAttempt |
RocketMessageConsumer.ReadHandledCount (:422, called :292) |
implemented only on R-14's AC-24 branch (below) |
Why the discriminator is "rejectionReason". "rejectionReason" is the key every Brighter-managed route stamps, spelled identically (SqsMessageConsumer.cs:508, V4 :501, RocketMessageConsumer.cs:256, and the Redis, Postgres, MsSql and MQTT consumers); bag keys survive the wire verbatim and case-sensitive (JsonSerialisationOptions.cs:11-14; DictionaryStringObjectJsonConverter.cs:22). It is deliberately not the pump's Message.RejectionReasonHeaderName = "RejectionReason" (Message.cs:51, stamped at Reactor.cs:426), which is written only on pump-driven rejections — a direct channel.Reject(...), as conformance FR-4/5/6/8/17 use, never carries it. RejectionMetadataKeyNames gives the literals one name; the in-scope RefreshMetadata methods adopt the constants in a separate structural commit (Tidy First).
A null-reason Reject stamps rejectionReason = "None". Today RefreshMetadata returns before stamping rejectionReason when reason == null (SqsMessageConsumer.cs:506, RocketMessageConsumer.cs:254), so a channel.Reject(message, null) copy carries no discriminator and a DLQ read would present the DLQ's own count. Decided: on the in-scope transports (AWSSQS, AWSSQS.V4, RocketMQ, and GCP via 0078), RefreshMetadata stamps rejectionReason = RejectionReason.None.ToString() when reason is null — consistent with the route DetermineRejectionRoute already selects for a null reason (reason?.RejectionReason ?? RejectionReason.None, SqsMessageConsumer.cs:275). rejectionMessage stays absent. This is an additive refinement of 0047-message-rejection-routing-strategy's metadata on the in-scope transports only; the other Brighter-managed transports are not changed here.
| Transport | How R-28 is satisfied | Discriminator |
|---|---|---|
AWSSQS |
The DLQ copy carries rejectionReason and handled-count (SqsMessageSender.cs:130); Resolve keeps the stamped count; the DLQ's own ApproximateReceiveCount is ignored. |
rejectionReason present in Header.Bag |
AWSSQS.V4 |
identical | same |
GcpPubSub |
same rule; the routed copy's metadata and HandledCount attribute (Parser.cs:307) are produced by 0078's routing, which must stamp RejectionMetadataKeyNames (constraint on 0078). |
same |
RocketMQ |
DLQ copy carries rejectionReason (:256) and HandledCount (RocketMqMessagePublisher.cs:103), provided the publisher's bag loop (:54-59) does not overwrite it with the received count the consumer copied into the bag (RocketMessageConsumer.cs:328-331) — the AC-24 change below. On the AC-25 branch the receive path is unchanged, so the header count is what is read already. |
same (AC-24); not needed (AC-25) |
Native dead-lettering (R-9) carries no rejectionReason, so the redrive target presents its own normalised count; R-28 does not bind that case and AC-9 asserts no count.
Discriminator edge cases:
- Null-reason
Reject— covered by stamping"None"(above). - Put back on its source with metadata intact (a DLQ redrive, a replay tool, a manual move — all outside Brighter's control). The message still carries
rejectionReason, so it is treated as a routed copy: its stamped countSis kept, and sinceS ≥ Rfor the budget that dead-lettered it, the pump rejects it again on its first delivery (wheneverR ≤ S + 1). This is the intended behaviour, not a defect: putting a message back for another attempt means resetting it, and that is the responsibility of the tool that puts it back — it must remove the rejection metadata (rejectionReasonand the otherRejectionMetadataKeyNames) and may resetHandledCount. Brighter documents this (dead-letter documentation and release notes). If the source's budget is larger than the one that dead-lettered it (R > S + 1), the count stays atSand only a native limit bounds the message; also documented. - User-supplied
rejectionReasonbag key on an ordinary message — treated as a routed copy, as in case 2. The keys are Brighter-reserved (0047-message-rejection-routing-strategy); release notes say so. Accepted. - R-17's no-destination case — nothing is routed; not applicable.
- Header-carried count on source channels — where a broker counter is available, the header count is now ignored on source-channel deliveries: a producer that sends
HandledCount = Nsees0presented on first delivery. Follows from R-2; recorded as a negative consequence.
R-2 protects the identity assertion on a first delivery only. The FR-2, FR-15, FR-16 and FR-22 templates also assert identity on the redelivered message (_messageAssertion.Assert(message, redelivered) — e.g. Templates/MessagingGateway/Reactor/When_requeuing_a_failed_message_should_be_redelivered.cs.liquid:77, …_with_zero_delay…:86, …_with_delay…:85, When_nacking_a_message_it_should_be_redelivered.cs.liquid:81, and the Proactor twins), and the assertions compare HandledCount for equality (AwsMessageAssertion.cs:58 in both AWS test projects, RocketMqMessageAssertion.cs:74, DefaultMessageAssertion.cs.liquid:59). R-1 requires that redelivered count to be ≥ 1, so without a change today's AWS Pass cells (and RocketMQ FR-16/FR-22 on AC-24) would fail, breaking R-23.
Decided: in those four behaviours' redelivery arms only, both pump variants, the generated test asserts redelivered.Header.HandledCount >= message.Header.HandledCount, then sets redelivered.Header.HandledCount = message.Header.HandledCount before calling the transport's message assertion. The transport assertion classes are untouched; these templates compare the redelivered message with the sent one only, so R-2 still bites through the other behaviours that assert a first receive; the change holds on all 24 configurations (republishing transports present 0 ≥ 0). It is a change to the conformance oracle under 0067-conformance-rollout-and-deferral-governance, made in the templates and regenerated, never hand-edited. The requirements record this in R-23 and C-7.
| Transport | Classification | Evidence |
|---|---|---|
AWSSQS |
approximate | attribute is ApproximateReceiveCount; AWS documents it approximate (A-4) |
AWSSQS.V4 |
approximate | same attribute |
GcpPubSub |
approximate | Google.Cloud.PubSub.V1 3.36.0 XML doc, ReceivedMessage.DeliveryAttempt: "calculated at best effort and is approximate … If a DeadLetterPolicy is not set … this will be 0" (A-1, verified) |
RocketMQ |
approximate (conservative) | no vendor statement of exactness found for MessageView.DeliveryAttempt in RocketMQ.Client 5.2.1; absent evidence, approximate. AC-23 may reclassify by amendment. |
Consequence: no transport is exact, so AC-34's second clause and AC-41's "exactly 3" clause bind none; AC-13 compares v3/v4 bounds (≤ R), not equality.
Normalise maps the documented origin 1 to 0. If a broker reports > 1 on what the test regards as a first delivery, no mechanism within NFR-1 to NFR-3 can reach 0 — an SQS receive whose response was lost is a real, counted receive; a Pub/Sub over-count is indistinguishable from a real redelivery.
Residual risk against C-7: an occasional, non-systematic first delivery presenting ≥ 1 (needs a lost response or a best-effort over-count). Exposed cells: every in-scope configuration whose template calls _messageAssertion.Assert on a first delivery — Pass behaviours on the 8 AWS/AWS.V4 configurations, and on RocketMQ under AC-24, that assert identity on a first receive (not FR-2/15/16/22, whose templates compare the redelivered message with the sent one only). It surfaces as a flaky, not systematic, failure; the mitigation is to report, not mask: observed failures are recorded against this ADR.
The three document clauses pass; the full review is in the spec README's Status Checklist. Two clarifications follow; neither changes a decision.
- Why GCP is not among the exposed cells. 6.7 selected AC-19, and the R-13 branch rule below says the first-delivery residual risk "applies as on SQS". That holds for any GCP subscription with a native
DeadLetterPolicy, which is the only kind whereDeliveryAttemptis non-zero. It exposes no conformance cell, though. The GCP conformance providers set a native policy only when adeadLetterRoutingKeyis given (GcpPullMessageGatewayProvider.cs:142-157, and the same shape in the other three providers). The templates that pass one (the five reject templates andWhen_requeuing_a_message_too_many_times…) never call_messageAssertion.Assert. Every other GCP template reads aDeliveryAttemptof0, soResolvekeeps the header count. The exposed-cell list above is therefore complete as written. 7.1 selected AC-24, so its RocketMQ clause is live. - Line citations that have drifted with the implementation (the decisions they support are unchanged):
Parser.cs:307(HandledCountattribute) →:323;Parser.cs:352(!headers.ContainsKey) →:368;Parser.ToBrighterMessage(GcpStreamMessage):33→:34; pullToBrighterMessage~:84→:93;ReadHandleCount:167→:183;RocketMqMessagePublisher.cs:103(stampedHandledCount) →:119; the publisher bag loop:54-59→:55-59;RocketMessageConsumer.cs:292/:422(ReadHandledCountcall/definition) →:296/:433; its bag copy:328-331→:332-334;ReceiptHandle:333→:337;Resolve(formerly "after the bag loop,:328") →:344.rejectionReasonis still stamped atRocketMessageConsumer.cs:256/:260; on SQS it is now stamped atSqsMessageConsumer.cs:508/:512.
None observed. The final regression ran at 5e7d78ef8 (code as of 30828b1c7). It ran every exposed cell's project whole, net10.0, both variants:
- AWS and AWS.V4 on Floci: 286 / 0 / 2 each.
- RocketMQ on a clean store: 67 / 0 / 6.
No first-delivery identity assertion failed. No test anywhere failed with a first delivery presenting HandledCount >= 1. The only generated test that failed in any of the 8.7 runs was a Kafka activity-context test, on a cold broker (Error finding topic). It passed on re-run, and Kafka is not an approximate counter. One clean pass does not retire a residual risk that is flaky by nature, so it stays as recorded above. The run record is in the conformance ledger, under "Final run record — 2026-10-02 (spec 0037 task 8.7)".
Mechanism: the broker counter above. Input: AC-39's measurement (the selector is this ADR's amended conclusion under the rule below), run on the emulator once 0078/R-20 make DLQ-backed channels creatable; this ADR is then amended with a dated Measurement outcome entry (AC-39(b)/(c)).
- A-2 holds (populated, strictly increasing, first value
1): the mechanism satisfies R-1 to R-5 within NFR-1 to NFR-3. AC-19 claimed; AC-40 not applicable. The first-delivery residual risk applies as on SQS. - A-2 refuted, or populated but not strictly increasing, or first value
> 1: thedelivery_attempt-independent search below has been run and found nothing. AC-40 claimed; AC-19 not applicable. GCP is bound but unimplemented;GcpPubSubSubscription.DeliveryBudgetUnenforceableReasonbecomes non-null for every subscription; the fourGCP / *FR-23 cells stayDeferred, re-pointed at the emulator limitation.
The delivery_attempt-independent search (R-13, A-2), run in advance:
| Candidate | Verdict |
|---|---|
Header-carried HandledCount |
never rewritten on ModifyAckDeadline/Nack — cannot advance (the defect itself) |
| Republish on requeue | excluded (C-12, NFR-3) |
GetSubscription / any per-message lookup |
excluded (NFR-1) |
| Consumer-side in-memory map | rejected (Alternatives) |
Data encoded in AckId |
opaque, undocumented — not a contract |
| Conclusion | none fits; a refuted A-2 selects AC-40 |
Measured on a clean local Pub/Sub emulator (docker-compose -f docker-compose-gcp.yaml down -v; up -d)
with the Skip-marked fixture
tests/Paramore.Brighter.Gcp.Tests/MessagingGateway/Pull/GcpDeliveryAttemptMeasurementTests.cs (unskip
locally to reproduce; both facts stay [Fact(Skip = "measurement — AC-39")] in the committed tree). Each
fact provisions its subscription through Brighter's own channel factory with a native DeadLetterPolicy
(MaxDeliveryAttempts = 5, creatable on the emulator since 0078/R-20, task 5.3), confirms the policy
with a raw GetSubscription before publishing, publishes one message, and defers it on the first two
deliveries, acknowledging the third. The counter is read with raw Google clients, because Brighter's
Parser does not surface it until 6.10/6.11:
| Consumer | Counter read | Deferral | Observed on deliveries 1, 2, 3 |
|---|---|---|---|
| Pull | ReceivedMessage.DeliveryAttempt from SubscriberServiceApiClient.Pull |
ModifyAckDeadline(…, 0) — the call GcpPullMessageConsumer.Requeue makes |
1, 2, 3 |
| Stream | PubsubExtensions.GetDeliveryAttempt() in a SubscriberClient callback |
SubscriberClient.Reply.Nack |
1, 2, 3 |
Both were run twice (the first on the freshly reset emulator), with identical sequences each time.
- (b) A-2 held. The counter is populated, strictly increasing and starts at
1on both consumers. Nodelivery_attempt-independent mechanism is needed; the pre-recorded search above stands as run, but is not selected. - (c) The broker-counter mechanism satisfies R-1 to R-5 within NFR-1 to NFR-3.
Normalisemaps the observed1, 2, 3to0, 1, 2, so R-2 (first delivery0) and R-1 (strictly greater on each redelivery) hold. R-3 then holds under the approximate classification above. R-4/R-5 follow from R-1 through the pump's existingHandledCountReachedcheck and Phase 5's routing. The value is a field already on the receive response (pull) or an attribute already on the message (stream), so no extra round trip per delivery (NFR-1) and none per requeue (NFR-3). NFR-2 is left to AC-37's allocation measurement, as on every transport. - (d) AC-19 claimed; AC-40 not applicable, on the strength of (c) and this measurement. Tasks
6.10–6.16 are taken, and 6.20–6.21 are marked
[!] not taken — AC-39 selected AC-19.
Caveat, not a refutation: the stream deferral here is SubscriberClient's own Nack, not Brighter's
stream Requeue through a pump. A Brighter pump that requeues on a GCP Stream channel currently hangs
(#4479; related
#4449). That is a defect in the consumer's
settle path, not in the counter this branch rule measures. It may still block the AC-19 stream tasks
that drive redelivery through a deferring pump (6.11, and 6.16's GCP / Stream* rows) until it is
fixed. 6.14 redelivers by lease lapse with no pump and no Requeue, so it is not on that path.
Both GCP / Stream and GCP / StreamOrdering, emulator, both variants (NFR-8), through GcpPubSubStreamMessageConsumer.
- Configuration:
DeadLetterPolicy { MaxDeliveryAttempts = 5 }; subscriptionAckDeadlineSeconds = 10;bufferSize: 2,noOfPerformers: 1(the factory derives the flow-control cap fromBufferSize × NoOfPerformers,GcpPubSubConsumerFactory.cs:123, so a second slot exists while the first delivery is held);StreamingConfiguration = b => b.Settings = new SubscriberClient.Settings { MaxTotalAckExtension = TimeSpan.FromSeconds(10) }(survives because the hook runs beforebuilder.Settings ??= …,GcpPubSubConsumerFactory.cs:126-147). - Sequence: (1) publish with
HandledCount = 0; (2)Receive/ReceiveAsyncreturns m1 — record its count, do not ack, nack or requeue; (3) pollReceiveevery 500 ms for up to 45 s (lease extension stops at 10 s, lapses ~20 s); (4) assert the redelivery m2 presents a count strictly greater than m1's; (5) only thenAcknowledgem2 and m1, so theWaitForProcessingshutdown (GcpStreamConsumer.cs:49) completes. - Alternative test for
StreamOrderingif same-key serialisation blocks the primary (unverified, R-13): same procedure on the same ordering-enabled subscription with a message published without an ordering key. Ordered delivery withholds a same-key redelivery while its predecessor is outstanding by design, so "hold, then observe the lapse" is not drivable with a key; a keyless message still exercises the stream consumer's lease-lapse path on that configuration. The switch, if taken, is recorded in the ledger. - On the AC-40 branch this obligation lapses (R-13).
Measured on the local Pub/Sub emulator with the configuration above (bufferSize: 2, noOfPerformers: 1,
subscription AckDeadlineSeconds = 10, DeadLetterPolicy M = 5, MaxTotalAckExtension = 10 s), holding m1
unsettled and polling Receive every 500 ms. The fixture is committed Skip-marked at
tests/Paramore.Brighter.Gcp.Tests/MessagingGateway/Stream/GcpStreamLeaseLapseBufferMeasurementTests.cs; each
case also ran with the client's own SubscriberClient.Settings.AckDeadline set to 10 s. The default-deadline
cases were run twice, with the same result.
| Case | Client AckDeadline default (60 s) |
Client AckDeadline = 10 s |
|---|---|---|
Stream |
redelivered at 60.0 s, count 0 → 1 | redelivered at 15.0 s, count 0 → 1 |
StreamOrdering, no ordering key |
redelivered at 60.0 s, count 0 → 1 | redelivered at 15.0 s, count 0 → 1 |
StreamOrdering, with an ordering key |
no redelivery in 90 s | no redelivery in 90 s |
bufferSize: 2admits the redelivery while the first delivery is held, onStreamand on a keylessStreamOrderingmessage. The redelivery presents a strictly greater count (6.11's parser change).- The procedure's timing assumption was wrong.
MaxTotalAckExtensionstops further extensions, but the client leases each message for its own streamAckDeadline(default 60 s), whatever the subscription'sAckDeadlineSeconds. The lapse therefore lands at ~60 s, outside the 45 s poll. The procedure'sStreamingConfigurationmust also setSettings.AckDeadline = TimeSpan.FromSeconds(10). With both set, the lapse lands at ~15 s. - A subscription's
StreamingConfigurationreplaces the connection's (GcpPubSubConsumerFactorypassessub.StreamingConfiguration ?? _connection.StreamConfiguration), so the hook must repeat the connection'sEmulatorDetection. Otherwise the client goes to production Pub/Sub and failsUnauthenticated. (Fixed by #4516, bugfix 0049: the connection's configuration now runs first, then the subscription's, so the repetition is no longer needed.) - Ordering blocks the primary procedure on
StreamOrdering. A keyed message's redelivery is withheld while m1 is outstanding, so 6.14 takes the keyless alternative onStreamOrdering, and records the switch in the ledger. - Incidental observation, not in scope here: with an ordering key and the default client deadline, disposing
the channel afterwards threw
TaskCanceledExceptionfromSubscriberClient.StopAsync(GcpStreamConsumer.cs:55).
Measurement (AC-23): from a clean store (docker-compose -f docker-compose-rocketmq.yaml down -v; up -d), requeueCount: 3, a deferring handler, Reactor variant. The test reads the raw MessageView.DeliveryAttempt from message.Header.Bag["ReceiptHandle"], which is the MessageView (set at RocketMessageConsumer.cs:333) — no production change needed to observe it. Three deliveries across 10 s invisibility lapses, no ChangeInvisibleDuration call (commented out, :186-187). Dispose the consumer and create a fresh one between deliveries 2 and 3: the 5.2.1 assembly contains a client-side IncrementAndGetDeliveryAttempt, and a value that survives a fresh client proves the increment is broker-supplied, as R-14's condition requires.
- Condition holds (AC-24):
HandledCountis resolved after the bag loop (RocketMessageConsumer.cs:328), becauseReadHandledCount(:422) runs before the header exists:header.HandledCount = DeliveryCount.Resolve(header.HandledCount, view.DeliveryAttempt, header.Bag), as on GCP; the publisher's bag loop (RocketMqMessagePublisher.cs:54-59) skips any keyAddHeaderPropertiesalready wrote (GCP's!headers.ContainsKeyshape,Parser.cs:352), so the stampedHandledCount(:103) reaches the DLQ copy instead of the stale bag entry;Requeuestays a broker no-op;DeliveryBudgetUnenforceableReasonis null; the FR-23 cell moves toFixed. - Condition fails (AC-25): receive path unchanged;
RocketSubscription.DeliveryBudgetUnenforceableReasonreturns fixed text naming the upstreamChangeInvisibleDurationblocker; the cell staysDeferred, re-pointed; #4353 and the ledger record the three values. AC-38 protects the nineFixedcells on both branches.
Measured with the Skip-marked fixture
tests/Paramore.Brighter.RocketMQ.Tests/MessagingGateway/Reactor/RocketMqDeliveryAttemptMeasurementTests.cs
(unskip locally to reproduce; both facts stay Skip-marked in the committed tree). Broker apache/rocketmq:5.5.0,
RocketMQ.Client 5.2.1, requeueCount: 3, Reactor, a 10 s lease, and no ChangeInvisibleDuration call. The consumer
was disposed and recreated in the same group before delivery 3. Two runs, the second from a down -v store:
DeliveryAttempt 1, 2, 3 both times.
- The condition holds. AC-24 is claimed; AC-25 is not applicable. Tasks 7.10–7.13 are taken, and 7.20–7.21
are marked
[!] not taken — AC-23 selected AC-24. The "Condition holds" design above stands as written. - Classification unchanged: approximate. The Apache RocketMQ consumption-retry documentation describes the
retry mechanism (lease lapse,
ChangeInvisibleDuration, a per-group maximum) but makes no exactness claim for the attempt count. AC-34's second clause and AC-41's "exactly 3" are therefore not activated; 7.10 and 7.12 assert the approximate form. - Finding outside the branch rule:
SimpleConsumer.ChangeInvisibleDuration(view, TimeSpan.Zero)works on 5.2.1. It redelivered in 2–3 s instead of after the 10 s lease, andDeliveryAttemptstill advanced. The "upstream blocker" this ADR and R-14 name for the AC-25 branch is therefore gone. That branch is not taken, so nothing here changes. MakingRequeue/Nackact on the broker (as PR #4263 does forNack) is a follow-up outside this spec. On this measurement it would not disturb the counter AC-24 relies on.
The follow-up above is done. Requeue / RequeueAsync now set the invisible duration to the requeue delay. A
negative delay counts as zero, and a delay above the broker's 12 h maximum is held at 12 h. Nack / NackAsync
set it to zero. A failed call is logged and falls back to the receive lease. "Requeue stays a broker no-op" in
the AC-24 branch above no longer holds.
The counter AC-24 relies on is unaffected. DeliveryAttempt advanced 1 → 2 across every
ChangeInvisibleDuration redelivery measured (1 s, 5 s, 25 s and 0). The broker counter is still read on
receive, by DeliveryCount.Resolve. The RocketMQ FR-2 and FR-15 cells are Fixed (#4353). Evidence is in
bugfixes/0025-rocketmq-requeue-nack-broker-noop/bugfix.md.
All three rules take the lower rung — ISpecification<Subscription>, registered alongside the existing four in RegisterConsumerValidationSpecs (ServiceActivator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs:199-215) and harvested at BrighterPipelineValidationExtensions.cs:79. No dependency on #4282.
public interface IAmADeliveryCountingSubscription
{
int? NativeRedriveLimit { get; } // M; null when none configured or not visible to Brighter
string? DeliveryBudgetUnenforceableReason { get; } // null when R-1 holds for this subscription
}- R-7:
RequeueCount == 0 || RequeueCount < -1→ Warning naming the subscription, the value, and the likely intents-1and1. - R-10:
R != -1 && NativeRedriveLimit is int m && R >= m→ Warning namingR,M, and that the effective limit isM. - R-11:
R != -1 && DeliveryBudgetUnenforceableReason is not null→ Warning naming the subscription,R, and the reason. - Subscriptions not implementing the interface pass R-10/R-11 vacuously — including the nine republishing configurations, so R-22 is untouched. Severity
ValidationSeverity.Warning, per theRequestTypeSubtypeprecedent (ConsumerValidationRules.cs:114-123).
| Subscription | NativeRedriveLimit |
Shape that trips R-11 (AC-11) |
|---|---|---|
SqsSubscription (both packages) |
QueueAttributes.RedrivePolicy?.MaxReceiveCount (SqsSubscription.cs:72, SqsAttributes.cs:110, RedrivePolicy.cs:34) |
none — ApproximateReceiveCount is always returned. A redrive policy configured outside Brighter is invisible, so R-10 cannot fire for it. |
GcpPubSubSubscription |
DeadLetter?.MaxDeliveryAttempts (GcpPubSubSubscription.cs:75, DeadLetterPolicy.cs:47) |
DeadLetter == null with R != -1 (A-1); on the AC-40 branch every subscription with R != -1 |
RocketSubscription (RocketMqSubscription.cs:10) |
null — the broker's max-retry setting lives server-side on the consumer group, invisible to the client |
none on AC-24; every R != -1 on AC-25 |
AC-11's first branch applies, since GCP has a tripping shape on both R-13 branches.
R-26: DeliveryBudgetDiagnostics.WarnIfUnenforceable(subscription) is called once on each non-delegating channel-creation path of the in-scope channel factories — the method every public entry reaches, never a method that only delegates to another (CreateAsyncChannel → CreateAsyncChannelAsync on GCP :54-55 and AWS :82-83, which would log twice): AWSSQS/ChannelFactory.cs CreateSyncChannelAsync (:204) and CreateAsyncChannelAsync (:92) and the V4 twins (uniform shape; a no-op for SQS), GcpPubSubChannelFactory CreateSyncChannel (:26) and CreateAsyncChannelAsync (:65), RocketMqChannelFactory.cs:13, :28, :43. The channel factory is the site both the dispatcher (ConsumerFactory.cs:100, :124) and direct users (conformance providers; AC-11/AC-25/AC-40) pass through; it logs once per channel, never on the receive path (NFR-4, AC-29).
No code. Brighter does not assume it is the one that dead-letters. Queues and subscriptions are often provisioned outside Brighter, so a native redrive policy (SQS RedrivePolicy.maxReceiveCount, Pub/Sub DeadLetterPolicy.MaxDeliveryAttempts) may move a message before the budget is spent, and that is a legitimate outcome. min(R, M) emerges because whichever threshold is reached first acts; Brighter neither suppresses nor stamps a native redrive (R-9), so a natively redriven message arrives without rejection metadata and presents the destination's own count. R-10 is the only Brighter-side expression of R-8, and it can fire only where M is visible to Brighter (a policy Brighter created); a policy configured outside Brighter is invisible and is not warned about.
Structural and behavioural commits kept apart (Tidy First, C-10):
- Structural: add
RejectionMetadataKeyNames; swap the inline creators' call order; in-scopeRefreshMetadataadopt the constants. - Core:
DeliveryCount(+ AC-33 unit tests, including the discriminator);IAmADeliveryCountingSubscription;DeliveryBudgetDiagnostics; the three rules (AC-7, AC-10, AC-11, AC-32 inParamore.Brighter.Core.Tests). - Conformance oracle: the redelivery-arm change in the FR-2/15/16/22 templates, regenerated — lands before or with step 4 so no
Passcell goes red. - SQS, both packages in one commit (NFR-6): creators; null-reason
"None"stamping inRefreshMetadata; interface onSqsSubscription; factory calls. - GCP: parser (pull + stream,
googclient_deliveryattemptignore entry); interface; factory calls. FR-23 gated on AC-39, after 0078/R-20. - RocketMQ: AC-23 measurement, then the AC-24 or AC-25 branch (null-reason stamping and the publisher bag-loop skip land on either branch).
- Harness (R-27):
R = 3, M = 5on the 12 providers (GCP's native-policy shape: 0078 Implementation step 5); GCP IAM members; dispatch-count and recording consumer inConformanceDeferredPump.cs.liquid; the<= RequeueCountassertion in both FR-23 templates.
Testing follows .agent_instructions/generated_tests.md — templates edited, tests regenerated, never hand-edited.
| Transport | Environment | Tests |
|---|---|---|
| AWS | LocalStack | FR-23 generated, both variants (AC-12); AC-13 v3/v4 comparison |
| GCP | Pub/Sub emulator (docker-compose-gcp.yaml) |
AC-39 → AC-19 or AC-40; AC-42 incl. the stream procedure |
| RocketMQ | local compose | AC-23 → AC-24 or AC-25; AC-38 |
Bespoke tests (constructed subscriptions, not provider-supplied): AC-1, AC-5, AC-6, AC-35, AC-8, AC-9, AC-41, AC-42. AC-37: allocated-bytes, median of 5 × 1,000 receives, post <= base; the new reads are value-typed (int?, TryGetValue on existing dictionaries, int.TryParse on an existing string, one ordinal string comparison). AC-27: compile-only samples in the five projects R-24 names.
Method. A console harness, kept in the session scratchpad and not in the repo, was compiled twice from identical source: once against the merge base f906efc0b (a separate worktree) and once against this branch at 904655ae1. The build was net10.0 Release with workstation GC. Every message was a fixed-shape MT_COMMAND: a 256-byte body and one bag entry. Two measurements were taken for each transport:
- End-to-end. Brighter's own consumer (
…ConsumerFactory.Create,bufferSize: 1, so every measured call returns exactly one message), withGC.GetAllocatedBytesForCurrentThread()around eachReceive. The ack is outside the window, and calls that returned nothing are not counted. Each run was a 100-message warm-up and then 5 rounds of 1,000 receives, reporting the median round. Two passes, run base, post, base, post. - Reader only. 1,000 raw broker messages were captured once from a real receive with the native SDK (SQS
ReceiveMessagewith all attributes; Pub/SubPull; RocketMQSimpleConsumer.Receive). The transport's reader was then run over them, bound by reflection to a delegate:SqsMessageCreator/SqsInlineMessageCreator.CreateMessage,Parser.ToBrighterMessage(pull, and stream viaGcpStreamMessagewith thegoogclient_deliveryattemptattributeSubscriberClientadds), andRocketMessageConsumer.CreateMessage. This was one warm-up pass then 5 measured rounds, reporting the median. This is exactly the code this branch changed, with no network in the window. - Environment. Floci for AWS; the Pub/Sub emulator with subscriptions created by raw admin clients carrying a native
DeadLetterPolicy(sodelivery_attemptis populated on both sides); a clean RocketMQ 5.5.0 store with a fresh topic per run. Every first delivery presentedHandledCount = 0on both sides.
| Transport (reader) | Reader only, bytes per 1,000 (base → post) | End-to-end median Δ post − base, pass 1 / pass 2 | End-to-end spread between rounds of one run |
|---|---|---|---|
AWSSQS (SqsMessageCreator) |
5,168,600 → 5,168,600 | +3,928 / +2,248 (of ~68.16 MB) | ~14–18 KB |
AWSSQS (SqsInlineMessageCreator, SNS non-raw) |
15,457,200 → 15,457,200 | not run end-to-end | — |
AWSSQS.V4 (SqsMessageCreator) |
5,168,600 → 5,168,600 | +864 / −640 (of ~48.53 MB) | up to ~99 KB |
AWSSQS.V4 (SqsInlineMessageCreator, SNS non-raw) |
15,457,800 → 15,457,800 | not run end-to-end | — |
GcpPubSub pull (Parser) |
2,104,000 → 2,104,000 | −648 / +328 (of ~9.44 MB) | ~34 KB |
GcpPubSub stream (Parser) |
2,104,000 → 2,104,000 | +1,544 / −3,440 (of ~5.65 MB) | ~40–70 KB |
RocketMQ (CreateMessage) |
2,248,000 → 2,248,000 | +390,608 / +195,680 (of ~35 MB) | ~1.35 MB, rising with run order |
- Reader only:
post = baseon every transport and both SQS creators. Each round was identical apart from a single 600-byte step that appears on both sides. The new reads (TryGetValueon the existing attribute dictionaries,int.TryParseon an existing string, theint?counter,DeliveryCount.Resolve) allocate nothing. Keepinggoogclient_deliveryattemptout of the stream message's bag (the news_ignoreHeadersentry) made no measurable difference either. Base puts that entry in the bag, and its stream figure is still identical to the branch's. I did not investigate why. - End-to-end: noise-bound, so it cannot decide a zero-tolerance gate. The base/post difference is smaller than the spread between rounds of a single run, and its sign flips between passes on V4, GCP pull and GCP stream. An earlier single-pass AWSSQS smoke run went the other way (post 68,139,864 ≤ base 68,143,768). On RocketMQ the medians rise in run order whatever the revision (34.4 → 34.8 → 35.7 → 35.9 MB for base-p1, post-p1, base-p2, post-p2), which is drift in the broker or client, not a code difference. By a literal median comparison, post exceeded base in 7 of the 10 end-to-end pairs, by 0.002–1.1 %. None is attributable to the reader, as the reader-only figures show.
- Verdict: AC-37 / NFR-2 met, on the reader-only measurement (Ian Cooper, 2026-10-02). It measures exactly the code task 8.3 names as the place an increase would be a defect, and there
post = base. The end-to-end figures are recorded as context only. The harness is not committed; the method above is enough to rebuild it.
AC-28 broker-call enumeration — no call added or removed:
| Transport | Per delivery (before = after) | Per requeue (before = after) |
|---|---|---|
| AWSSQS / V4 | ReceiveMessage (system attribute already requested) |
ChangeMessageVisibility |
| GcpPubSub pull | Pull |
ModifyAckDeadline(…, 0) |
| GcpPubSub stream | streaming pull (no per-message RPC) | local Nack on the stream |
| RocketMQ | SimpleConsumer.Receive |
none |
The table above is confirmed, unchanged. I enumerated it from git diff f906efc0b..3c9e8c010 (merge base with master → the branch after 8.1) over src/Paramore.Brighter.MessagingGateway.{AWSSQS,AWSSQS.V4,GcpPubSub,RocketMQ}. No broker call was added to or removed from any receive or requeue path.
| Transport | Receive path in the diff | Requeue path in the diff |
|---|---|---|
| AWSSQS / V4 | SqsMessageConsumer.Receive/ReceiveAsync: no hunk. The creators' ReadHandledCount (v3 SqsMessageCreator.cs:323, SqsInlineMessageCreator.cs:350; V4 :330, :317) now reads sqsMessage.Attributes["ApproximateReceiveCount"] from the ReceiveMessage response already in hand and calls DeliveryCount.Resolve. No client call. |
Requeue/RequeueAsync: no hunk. Still one ChangeMessageVisibility. |
| GcpPubSub pull | GcpPullMessageConsumer.Receive: no hunk. Parser.ToBrighterMessage(ReceivedMessage) (:146) reads ReceivedMessage.DeliveryAttempt from the Pull response. |
Requeue/RequeueAsync (:397-449): the ModifyAckDeadline(…, 0) call and its client lookup (GetOrCreateSubscriberServiceApiClient / CreateSubscriberServiceApiClientAsync) moved verbatim into ReleaseByHandle/ReleaseByHandleAsync (:484-503). Still one ModifyAckDeadline. |
| GcpPubSub stream | GcpPubSubStreamMessageConsumer.Receive: no hunk. Parser.ToBrighterMessage(GcpStreamMessage) (:86) reads PubsubExtensions.GetDeliveryAttempt from the message's own attributes. |
Requeue (:287): gcpStreamMessage.Reject() became Nack(gcpStreamMessage), a one-line helper (:333) that calls the same gcpStreamMessage.Reject(). Still one local Nack on the stream. |
| RocketMQ | RocketMessageConsumer.Receive: no hunk. CreateMessage (:344) reads MessageView.DeliveryAttempt from the view SimpleConsumer.Receive returned. |
Requeue: no hunk. Still no broker call. |
Per channel, not per message: each factory now calls DeliveryBudgetDiagnostics.WarnIfUnenforceable once at channel creation. It reads DeliveryBudgetUnenforceableReason/NativeRedriveLimit, which are pure properties of the subscription on all four transports. The GCP consumer factory passes the routing keys to a GcpRejectionRouter built per consumer. Neither issues a broker call.
Broker-call changes the diff makes outside these two paths. They are recorded so the review sees them, and none is part of the delivery-count mechanism:
- GCP Reject, pull and stream (ADR 0078, R-16 to R-19): one routing
PublishtodeadLetterRoutingKey/invalidMessageRoutingKeywhen a destination is configured. When that publish fails, the original is released (pullModifyAckDeadline(…, 0); streamReject()) instead of acknowledged, so the release replaces the ack rather than adding to it. SQS and RocketMQRejectchanged only inRefreshMetadata(no call). - GCP stream
Nack(GcpPubSubStreamMessageConsumer.cs:84,a576dc0f3, bugfix 0024 / #4449): it was a no-op and now callsgcpStreamMessage.Reject(), the same local Nack the requeue path makes. This is the pump'sDontAckActionpath, notRequeue. Before the fix, the base revision never settled such a message, so the client kept extending its lease. The change fixes that defect; it is not part of R-1's mechanism. - GCP stream shutdown (
GcpStreamConsumer.cs:55,a010129ea, bugfix 0023 / #4479):StopAsyncusesShutdownMode.NackImmediatelyinstead ofWaitForProcessing. This is once per consumer stop, not per message.
requeueCountenforces on all 8 AWS/AWS.V4 configurations, and conditionally on GCP and RocketMQ, with the existing pump code as the only enforcer.- One rule in one place; v3/v4 lockstep holds by construction.
- No broker call, no state (NFR-1..NFR-3); restarts and competing consumers do not reset the count, because the broker keeps it.
- R-28 holds without a new wire key; DLQ inspection keeps its evidence, including for null-reason rejections.
- R-11 is never silent: validation Warning plus channel-creation log.
- The header-carried count is ignored on source channels where a broker counter exists — a producer-set non-zero
HandledCountno longer carries prior budget spend. - All counters are approximate: rejection may come before delivery
R, and v3/v4 may reject on different deliveries. - GCP needs a
DeadLetterPolicyto have a counter at all (A-1); without one the budget is unenforceable and only warned about. - The conformance oracle weakens from equality to
>=on theHandledCountof four behaviours' redelivery arms. - A null-reason rejection now carries
rejectionReason = "None"on the in-scope transports — a visible change to what a DLQ consumer sees, and a divergence from the out-of-scope Brighter-managed transports until they follow. - A message put back on its source with rejection metadata attached is rejected again; resetting it is left to the replaying tool, which Brighter can only document (edge case 2).
- On RocketMQ, header-owned properties now take precedence over same-named
Header.Bagentries on every publish (the bag-loop skip): a user who forwards a received message, or sets such a key in the bag to override a header, sees the header win. - RocketMQ and GCP may still end bound but unimplemented (C-12's recorded price).
| Risk | Mitigation |
|---|---|
| A-2 refuted on the emulator | pre-registered branch rule (AC-40); independent search already recorded |
| First-delivery over-count (C-7) | recorded residual risk; observed failures logged against this ADR |
StreamOrdering lapse blocked by ordering |
confirmed 2026-09-30 (6.13): the keyless alternative defined above is required |
| RocketMQ counter is client-local, not broker-supplied | fresh-client step in AC-23 |
Stale googclient_deliveryattempt travels on a routed GCP copy |
excluded from the bag here; 0078 must not reintroduce it |
| A replay tool puts messages back without stripping metadata, and they bounce straight back to the DLQ | documented as the tool's responsibility; the bounce is immediate and visible (a DeliveryError rejection on first delivery), not a silent loop |
Unverified library behaviours: whether SubscriberClient injects/overwrites googclient_deliveryattempt; whether bufferSize: 2 admits the stream redelivery while the first is held |
SubscriberClient half confirmed 2026-09-28, see amendment below; bufferSize: 2 confirmed 2026-09-30 (6.13, amendment under the stream lease-lapse procedure), provided the client AckDeadline is also 10 s |
Measured on the local Pub/Sub emulator against a DLQ-backed stream subscription (DeadLetterPolicy
set, per A-1), using a Skip-marked fixture committed at
tests/Paramore.Brighter.Gcp.Tests/MessagingGateway/Stream/GcpStreamDeliveryAttemptMeasurementTests.cs
(unskip locally to reproduce; both facts stay [Fact(Skip = ...)] in the committed tree).
- (i)
SubscriberClientdoes inject the attribute. A message received on first delivery carriedgoogclient_deliveryattempt = "1"inHeader.Bag(Brighter'sParsercopies every attribute not ins_ignoreHeadersinto the bag verbatim, so this is exactly what the rawPubsubMessage.Attributesheld). The value parses cleanly as1, consistent withPubsubExtensions.GetDeliveryAttempt()and with assumption A-1 ("delivery_attemptis ... 1 on first delivery"). - (ii) The emulator accepts a publish carrying a stale
googclient_deliveryattemptattribute, andSubscriberClientoverwrites it. A message published withgoogclient_deliveryattempt = "999"already set (simulating a routed copy that had not had the attribute stripped) was accepted by the emulator without error. The value a receiver saw was"1"— the fresh delivery-attempt count — not the stale"999", soSubscriberClientoverwrites rather than preserves a pre-existing value.
Consequence for 5.5d: (ii) shows a stale value would not survive to a reader once the message
passes back through SubscriberClient — so this is not flagged for 6.11 per the task's instruction.
The residual concern the ignore-header entry (5.5d) still needs to cover is a pull consumer, which
has no SubscriberClient in its path to perform this overwrite; and any reader that inspects the raw
published attributes without going through a fresh SubscriberClient receive. 5.5d's Given is chosen
under branch (a) of its Given list: since (ii) shows the emulator accepts an explicitly published
googclient_deliveryattempt attribute, that publish can be used directly to test the stream and pull
parser clauses.
- Consumer-side in-memory tracking keyed by message id (incremented on
Requeue, read on receive). For: no broker support needed, would work on RocketMQ today, exact, independent of A-1/A-2. Against: resets on restart and deploy — exactly when poison messages bite; invisible to competing consumers (N performers or pods →N × Rdeliveries); needs eviction for NFR-2, which makes the count forgettable, and leaks without it; does not advance on an expiry redelivery that never passes throughRequeue(AC-42); invents state the broker already keeps. Rejected. - Republish on requeue (the nine conforming transports' mechanism). Excluded by requirement (NFR-3, C-12): a net broker call per requeue; bypasses the user's visibility timeout / ack deadline; SQS FIFO content-based dedup would silently discard the copy, since
MessageDeduplicationIdis set only when the bag carries one (SqsMessageSender.cs:100-102). Listed so it is not re-opened. max(header count, normalised broker count)everywhere, no discriminator. Gives replayed messages a working budget, but lets a DLQ's own count override the stamped count on repeated DLQ redeliveries — violating R-28's "never that destination's own count". Rejected.- A new wire key (e.g.
x-brighter-routed) as discriminator. Every transport must write and read a new key, whilerejectionReasonis already stamped by every Brighter-managed route. Rejected (user's call). - Detect a return to the source and use the broker count there — either
Header.Topic != originalTopic, ororiginalTopiccompared with the reading channel's own identity (queue URL, subscribed topic ARN). Would give a message put back with metadata a working budget. Rejected: putting a message back is outside Brighter's control and resetting it is the replaying tool's job; the message-Topicvariant also fails on SQS's own redrive (StartMessageMoveTaskkeeps attributes, soTopicstays the DLQ URL,SqsMessageSender.cs:115), and the channel-identity variant would thread consumer identity into every creator/parser for an edge case Brighter should only document. - Leave null-reason rejections unstamped. No change to
0047-message-rejection-routing-strategy's metadata, but a DLQ read of such a copy shows the DLQ's own count. Rejected (user's call). - Upper rung (
IAmAPipelineValidatorper transport) for R-10/R-11. The inputs are an integer and a predicate on a subscription, which a core role interface carries; the upper rung adds a dependency on #4282 for nothing. Rejected, per0074-lifetime-validation-evaluation-site's test. - Log R-11 in
ServiceActivator.ConsumerFactoryonly. One site, but misses channels created directly through a channel factory — where AC-11/AC-25/AC-40 create them. Rejected in favour of the channel factories calling a shared core helper. - Implement the SQS budget as native redrive (
RedrivePolicy.maxReceiveCount = requeueCounton queues Brighter creates). Rejected as the mechanism: the dead-lettered message loses the rejection metadata,Unacceptablecannot reach the invalid-message channel, it reverses0038-aws-sqs-dlq-direct-send, and it does nothing on queues Brighter does not create (makeChannels: Assume/Validate). Native redrive remains a legitimate co-existing route (R-8/R-9, above).
- Requirements: specs/0037-delivery-count-and-rejection-routing/requirements.md
- Related ADRs:
0006-blocking-and-non-blocking-retries— defer, requeue and the DLQ threshold this makes reachable.0038-aws-sqs-dlq-direct-send— the SQS direct-send route (C-1) and v3/v4 lockstep.0042-rocketmq-dlq-brighter-managed— RocketMQReject→ DLQ producer + Ack.0045-provide-dlq-where-missing,0046-kafka-dlq-producer-for-requeue.0047-message-rejection-routing-strategy— routing byRejectionReasonand the rejection metadata the discriminator reads; refined here for null reasons on the in-scope transports.0053-pipeline-validation-at-startup— theValidatePipelinesseam.0061-reject_mapping_errors.0066-conformance-test-provider-and-ungating,0067-conformance-rollout-and-deferral-governance— the FR-23 ledger cells this moves and the redelivery-arm oracle change.0074-lifetime-validation-evaluation-site(PR #4282, unmerged) — the two-rung ladder; lower rung taken, so no dependency.0078-gcp-rejection-routing-and-dlq-channel-creation(Proposed) — GCP rejection routing and DLQ channel creation.- Supersedes none.
- External references: Google.Cloud.PubSub.V1 3.36.0 (
ReceivedMessage.DeliveryAttempt,PubsubExtensions.GetDeliveryAttempt); AWS SQSApproximateReceiveCount; RocketMQ.Client 5.2.1 (MessageView.DeliveryAttempt).
Not decided here: R-16 to R-21 (GCP routing; Reject composition per consumer — R-19's ADR MUSTs; evidencing the failed-ack and failed-release outcomes); the makeChannels of the GCP dead-letter producer (AC-18/AC-43); NFR-5's Resource Manager exception type (AC-21); R-20's IAM tolerance.
Constraints this ADR places on 0078: the routed GCP copy stamps RejectionMetadataKeyNames (rejectionReason — "None" for a null reason) and carries HandledCount; it must not re-publish googclient_deliveryattempt.