Note: This document captures user requirements and needs. Technical design decisions and implementation details should be documented in an Architecture Decision Record (ADR) in
docs/adr/.
Linked Issue: #4179
These terms are used with precise meaning throughout this document.
- Confirmation-based producer: A Brighter message producer that implements
ISupportPublishConfirmationand reports publish success/failure asynchronously (after the synchronousSend/SendAsynccall has already returned) by raising theOnMessagePublishedevent. The RabbitMQ (RmqMessageProducer) and Kafka (KafkaMessageProducer) producers are confirmation-based. This is distinct from a non-confirmation producer, whose send either succeeds or throws synchronously and is observed inline via thesentboolean inOutboxProducerMediator.Dispatch(sync) andOutboxProducerMediator.DispatchAsync(async). ISupportPublishConfirmation: The Brighter interface exposing theOnMessagePublishedevent, whose signature isOnMessagePublished(bool success, string messageId).success == truemeans the broker acknowledged (acked) the message;success == falsemeans the broker rejected/failed to persist (nacked /NotPersisted/ produce error) the message.OnMessagePublishedcallback: The handler wired up byOutboxProducerMediator(inConfigureAsyncPublisherCallbackMaybe/ConfigurePublisherCallbackMaybe) onto a confirmation-based producer's event. It is a plain delegate (a function pointer): it executes synchronously on whichever thread raises the event, which is gateway-dependent — Kafka marshals the invocation throughTask.Runonto a thread-pool thread (seeKafkaMessageProducer.PublishResults), whereas RabbitMQ invokes it directly from its broker ack/nack handler. The callback itself implies no dedicated thread; it is simply not guaranteed to run on the thread that calledDispatch/DispatchAsync.- Sweeper (Outbox Sweeper): The Brighter background component that periodically scans the Outbox for messages that were written but not marked dispatched, and re-attempts delivery. A message that fails to publish is left un-dispatched precisely so the Sweeper retries it.
- Mark dispatched: The Outbox operation that records a message as successfully delivered (so the Sweeper will not retry it). This happens today only on the
success == truebranch of the callback. - Circuit breaker trip (
TripTopic): TheOutboxProducerMediatorruntime action (OutboxProducerMediator.cs:998calls it on the non-confirmation!sentpath) that marks a topic as temporarily un-publishable after a delivery failure, so Brighter stops attempting that topic until the breaker resets. Tripping is per-topic. - Standalone linked span: A new, short-lived OpenTelemetry
Activity(span) created at callback time to represent the publish-confirmation event (success or failure), carrying anActivityLinkto the original publish span'sActivityContext(when reachable; see C-7), rather than reusing the original (already-ended) producer span. On the success branch it also becomes the parent of the OutboxMarkDispatchedspan. - Wire topic (
message.Header.Topic): The routing key the message is actually being sent to. For most publications it equals the producer's configuredPublication.Topic, but for reply/RPC messages it is rewritten to a dynamic reply address (seeGetProducerLookupTopic,OutboxProducerMediator.cs:786-807). This is the topic the non-confirmation!sentpath logs and trips (OutboxProducerMediator.cs:998), and the topic FR-1/FR-3 use (see C-8). Publication.Topic: The static routing key configured on a producer'sPublication, declaredRoutingKey? Topic { get; set; }(Publication.cs:86) — nullable in type, but non-null at callback time by theProducerRegistryregistration invariant (registration throwsConfigurationExceptionon a null/empty topic).ProducerRegistrykeys producers by a compositeProducerKey(Publication.Topic, CloudEvents type)(ProducerRegistry.cs:29, 52), not by topic alone. It is not used as the breaker/log topic, because it diverges from the wire topic for reply messages (see C-8).
As an operator/SRE running a Brighter application that publishes via confirmation-based producers (RabbitMQ or Kafka), I would like publish failures that are reported asynchronously via OnMessagePublished(success: false, ...) to be logged, traced, and to trip the topic circuit breaker so that I can see and alert on failed deliveries, correlate them to the original publish, and get the same protective circuit-breaker behaviour I already get from non-confirmation producers — while the message safely remains un-dispatched in the Outbox for the Sweeper to retry.
Today the success == false case is silently dropped: nothing is logged, no telemetry is emitted, the circuit breaker is never tripped for confirmation-based producers, and for Kafka the failed message id and the underlying ProduceException reason/code are discarded. Operators have no signal that delivery failed.
When a confirmation-based producer reports the outcome of a publish via OnMessagePublished, Brighter will make that outcome observable and (on failure) protective, without changing the existing safe-retry behaviour:
- On failure (
success == false), log at Warning level (not Error — the message is not lost; it stays in the Outbox for Sweeper retry), including the message id when available. - On every confirmation (success and failure), emit a standalone, short-lived OpenTelemetry span that carries an OpenTelemetry Link to the original publish span (via that span's context/id), so the confirmation is visible in tracing tools and navigable back to the originating publish even though the original producer span has already ended. This requires the original publish span's context to be reachable at callback time (see C-7). This also corrects a pre-existing defect: today the success branch's
MarkDispatchedOutbox span is emitted orphaned (no parent) — under this change it nests beneath the confirmation span, which links to the publish. - On failure, trip the topic's circuit breaker, so confirmation-based producers behave like non-confirmation producers do on a failed send.
Additionally, for Kafka specifically, the underlying produce error (reason + code) will be logged where it is currently swallowed, and the failed message's id will be propagated through to OnMessagePublished so the log/telemetry carry it (RabbitMQ already supplies the id and only needs verification).
The message is still left un-dispatched; the Sweeper still retries it; produce exceptions are still not bubbled to the caller.
Each FR is labelled with its scope:
- [SHARED] — implemented once in
OutboxProducerMediatorcallback configuration; applies to allISupportPublishConfirmationproducers (Kafka + RabbitMQ, sync + async). - [KAFKA] — Kafka producer/publisher-side change.
- [RMQ-VERIFY] — RabbitMQ already behaves correctly; requirement is verification only, no behaviour change.
The four gaps from the issue map to FRs as follows: no log → FR-1, FR-6; id discarded → FR-5, FR-8; breaker not tripped → FR-3; no OTel signal → FR-2.
FR-1 [SHARED] — Log confirmation failure at Warning.
When OnMessagePublished fires with success == false, the mediator callback (ConfigureAsyncPublisherCallbackMaybe and ConfigurePublisherCallbackMaybe, both async and sync variants) MUST emit a log entry at Warning level. The log entry MUST include the message id (when non-empty) and the wire topic message.Header.Topic (the same topic the non-confirmation path logs; reachable at callback time per C-8). It MUST NOT be logged at Error or higher.
Example: Producer raises OnMessagePublished(false, "abc-123") for a message bound to topic orders. Result: exactly one Warning log line is emitted containing message id abc-123 and topic orders; no Error line is emitted.
FR-2 [SHARED] — Emit a standalone OTel span linked to the original publish span, on both success and failure confirmations.
When OnMessagePublished fires — whether success == true or success == false — the mediator callback MUST emit a new, standalone, short-lived OpenTelemetry Activity (S2) representing the publish-confirmation event. This Activity MUST carry an OpenTelemetry Link (ActivityLink) to the original publish span's context (the producer span S1 created during Dispatch/DispatchAsync) when that context is reachable at callback time (C-7); when it is not reachable, the Activity is emitted without the Link (graceful degradation). The Link, when present, lets tracing tools navigate from the confirmation back to the originating publish, even though S1 has already ended. The Activity MUST carry at least: message id (or an explicit "unknown" marker when empty), the wire topic message.Header.Topic (C-8), the outcome (confirmed vs failed), and an indication that this is a publish-confirmation event. Regardless of whether the Link is present, the callback MUST NOT reopen, mutate, or enrich the already-ended S1, and MUST NOT attribute the confirmation to whatever unrelated ambient Activity.Current happens to be active.
This generalises a behaviour that the failure branch lacked entirely and that the success branch did incorrectly: today the success branch's MarkDispatched/MarkDispatchedAsync Outbox call emits its DB span orphaned (parented to the empty construction-time RequestContext whose Span is null — C-6 — so it surfaces as a disconnected root span, unlinked from the publish). Under this FR the confirmation Activity S2 becomes the ambient parent for any work the callback then does, so on the success branch the MarkDispatched DB span nests under S2 (which links to S1) instead of floating free. Only the Link aspect is contingent on the C-7 mechanism; emitting S2 is unconditional. The Warning log (FR-1) and breaker trip (FR-3) remain failure-only — S2 is the only observability addition on the success branch (no Warning, no breaker trip; see AC-13).
Failure example: Original publish of abc-123 created producer span S1 (now ended); S1's ActivityContext was made reachable at callback time (C-7; mechanism per ADR 0063). Confirmation failure fires later. Result: a distinct Activity S2 is created (outcome = failed) carrying an ActivityLink to S1's ActivityContext; S1 itself is not mutated.
Success example: Confirmation success for abc-123 fires. Result: a distinct Activity S2 (outcome = confirmed) carrying an ActivityLink to S1 is created; the MarkDispatched DB span is emitted as a child of S2 (no longer an orphaned root span). The "sent" Info log and mark-dispatched still occur (AC-13).
Degraded example: if no context is reachable (e.g. tracing disabled), S2 is still emitted on either branch but carries no Link.
FR-3 [SHARED] — Trip the topic circuit breaker on failure.
When OnMessagePublished fires with success == false, the mediator MUST trip the circuit breaker for that message's wire topic message.Header.Topic — the same topic argument and TripTopic action used by the non-confirmation !sent path (OutboxProducerMediator.cs:998), giving exact parity. For reply/rewritten-topic messages, where the wire topic differs from Publication.Topic, the wire topic is the one tripped (matching the non-confirmation path; not Publication.Topic). The wire topic must be reachable at callback time (C-8). This is a runtime behaviour change.
Example: Two messages whose wire topic Header.Topic is payments both nack via OnMessagePublished(false, ...). Result: the payments topic is tripped, identical to what a synchronous !sent failure on payments would have done. Reply-topic example: a reply message whose Header.Topic was rewritten to reply-xyz (≠ its Publication.Topic) trips reply-xyz.
FR-4 [SHARED] — Preserve un-dispatched / no-bubble semantics.
On success == false, the mediator MUST NOT mark the message dispatched in the Outbox and MUST NOT throw or bubble any exception to the original caller. The message MUST remain eligible for Sweeper retry.
Example: After OnMessagePublished(false, "abc-123"), querying the Outbox shows abc-123 still un-dispatched; the calling code that invoked Post/DepositPost observed no exception.
FR-5 [SHARED] — Handle missing/empty message id gracefully.
When OnMessagePublished fires with success == false and an empty or null message id, FR-1 (Warning log), FR-2 (OTel span) and FR-3 (breaker trip — using the wire topic message.Header.Topic per C-8, not the id) MUST still occur. The log/telemetry MUST record the id as an explicit "unknown"/empty marker rather than crashing or omitting the entry.
Example: Producer raises OnMessagePublished(false, ""). Result: one Warning log emitted with id shown as empty/unknown, one OTel signal emitted, and the breaker for the associated topic is tripped; no exception is thrown.
FR-6 [KAFKA] — Log the swallowed ProduceException.
In KafkaMessagePublisher.PublishMessageAsync (KafkaMessagePublisher.cs:52-62), where a ProduceException<string, byte[]> is currently caught and a synthetic DeliveryResult with Status = NotPersisted is created with NOTHING logged, the code MUST log the produce error including the broker error reason and code. The current catch clause binds no exception variable (catch (ProduceException<string, byte[]>)); satisfying this FR requires binding it (e.g. catch (ProduceException<string, byte[]> pe)) so pe.Error.Reason/pe.Error.Code can be read. (This catch is correctly typed — distinct from the dead, wrong-typed <string, string> catches noted in OOS-2.) Level: Warning (consistent with FR-1; not Error). The synthetic DeliveryResult and the existing non-bubbling flow are otherwise preserved.
Example: Confluent client throws ProduceException with Error.Reason = "Broker: Message size too large", Error.Code = MsgSizeTooLarge. Result: a Warning log line is emitted containing both Message size too large and MsgSizeTooLarge; PublishMessageAsync still returns the synthetic NotPersisted result without rethrowing.
FR-7 [KAFKA] — Preserve synthetic NotPersisted callback flow.
The Kafka publisher MUST continue to synthesise the DeliveryResult with Status = NotPersisted and route it to the callback (so OnMessagePublished(false, ...) still fires). FR-6 adds logging only; it MUST NOT cause the produce path to start bubbling the exception or to skip invoking the callback.
Example: After the ProduceException of FR-6, KafkaMessageProducer.PublishResults still receives the synthetic report and OnMessagePublished still fires for the failed message.
FR-8 [KAFKA] — Propagate failed message id through PublishResults.
In KafkaMessageProducer.PublishResults (KafkaMessageProducer.cs:364-384), the NotPersisted branch MUST read the MESSAGE_ID header from the (synthetic) delivery report — which the publisher already populates — and fire OnMessagePublished(false, <messageId>) with that id, instead of the current OnMessagePublished(false, string.Empty). The id MUST be read from the report-level Headers collection passed into PublishResults (the same collection the Persisted branch reads via result.Headers at KafkaMessageProducer.cs:333, 366), NOT from result.Message.Headers — the publisher writes MESSAGE_ID to deliveryResult.Headers while leaving deliveryResult.Message.Headers empty (KafkaMessagePublisher.cs:58-60); reading the wrong collection would yield empty. When the header is genuinely absent, fall back to empty (handled by FR-5).
Example: Synthetic NotPersisted report carries MESSAGE_ID = "abc-123". Result: OnMessagePublished(false, "abc-123") is fired (not (false, "")); the id then flows into FR-1/FR-2.
FR-9 [RMQ-VERIFY] — RabbitMQ already supplies the id; verify only.
The RabbitMQ producer already invokes OnMessagePublished(false, messageId) with the message id on nack (RmqMessageProducer.cs:480 async; sync analogous). For the id, no RMQ producer-side change is required. Requirement: provide/confirm test coverage that the RMQ nack path carries the id into the shared mediator handling (FR-1/FR-2/FR-3/FR-4).
Example: RMQ broker nacks message def-456. Result: OnMessagePublished(false, "def-456") fires; the shared mediator path produces the Warning log (with def-456), OTel signal, and breaker trip for the RMQ topic.
Caveat (see C-7): the verify-only classification covers the id-handling path (FR-1/FR-3/FR-4) only. Whether RMQ needs a producer-side change to make FR-2's span Link reachable depends on the C-7 mechanism the ADR selects; under some mechanisms RMQ is not verify-only for FR-2. Pre-existing RMQ Debug log: the RMQ async producer already logs the nack itself at Debug via
Log.FailedToPublishMessageAsync(RmqMessageProducer.cs:480-481, 512-513). This Debug line MAY remain; the new shared Warning (FR-1) is the operator-facing signal. FR-1's "exactly one Warning" and AC-1/AC-9 count Warning-level lines specifically — a co-existing Debug line is acceptable and is not a duplicate Warning. Removing/downgrading the producer-side Debug log is out of scope.
- NFR-1 (Log level). Confirmation-failure logging MUST be at Warning, never Error/Critical. A failed-but-retained message is an expected, recoverable condition (Sweeper will retry), not data loss.
- NFR-2 (Low overhead, non-blocking — concrete bar). The callback's confirmation handling MUST: (i) perform no synchronous network I/O on the callback thread for logging/telemetry; (ii) create the standalone OTel span within the callback and dispose it deterministically before the callback returns (short-lived, never abandoned across the lifetime of the callback) — on both branches, since the span (FR-2) is now emitted on both. On the failure branch the span is started and stopped with no intervening
await. On the success branch the span remains live across the single pre-existingMarkDispatched/MarkDispatchedAsyncawait — because the DB span is parented to it (the orphan-fix, FR-2/AC-13; the parenting is explicit, see ADR 0063) — and is disposed immediately after, before the callback returns; this is a bounded hold across one known Outbox await, not an open-ended span left dangling. (iii) On the failure branch, introduce no newawaiton broker or Outbox operations beyond the existing breaker trip and log. (The success branch legitimately awaits the pre-existingMarkDispatched/MarkDispatchedAsync; FR-2 adds no further broker/Outbox await there.) No other latency bar is implied (the prior vague "measurably delay"/"cheap" wording is replaced by (i)–(iii)). Note: Kafka already isolates the broker thread by raising the callback viaTask.Run(KafkaMessageProducer.cs:373, 381). - NFR-3 (Thread safety). Confirmation callbacks may be invoked concurrently (multiple messages in flight) and on a thread other than the one that dispatched the message — which thread is gateway-dependent (Kafka marshals via
Task.Runonto a thread-pool thread; RabbitMQ invokes from its broker confirmation handler). The callback-side work (FR-1/2/3) MUST therefore be safe under concurrent invocation; access to the circuit breaker and any shared logging/telemetry state MUST be thread-safe. This requirement stands because of possible concurrency, not because the callback is inherently a "background thread" (it is a synchronous delegate running on the invoking thread). - NFR-4 (No swallowed errors in the observability code). Failure within the observability handling itself (e.g. telemetry emission) MUST NOT throw out of the callback in a way that destabilises the producer's background thread; it must degrade gracefully (the breaker trip and Warning log are the priority).
- NFR-5 (Always-on). This observability/breaker behaviour is always on; no new public configuration toggle is introduced (see Out of Scope).
- NFR-6 (Consistency with existing path). The breaker trip on confirmation failure MUST use the same circuit-breaker mechanism/
TripTopicsemantics as the existing non-confirmation!sentpath — including tripping the same per-message wire topicmessage.Header.Topic(OutboxProducerMediator.cs:998), notPublication.Topic(which diverges from the wire topic for reply/rewritten topics). Behaviour is therefore identical across producer types.
- C-1. Brighter deliberately does NOT bubble produce exceptions to the caller; failed messages are left un-dispatched for the Sweeper to retry. This spec makes failure observable and trips the breaker; it does not change retry/non-bubbling semantics.
- C-2. The
OnMessagePublishedconfirmation callback fires afterDispatch/DispatchAsynchas already ended its producer spans (broker confirmation is asynchronous relative to the send call returning), so there is typically no live producer span to enrich — this is the rationale for the standalone-span + Link decision (FR-2), independent of which thread raises the callback. - C-3. The Kafka publisher already places
MESSAGE_IDinto the syntheticNotPersistedreport's headers, so FR-8 is a read-and-forward, not a new header write. - C-4. The RabbitMQ producer already carries the message id on nack; only verification (FR-9) is needed, no RMQ producer-side code change.
- C-5. "Confirmation-based producer" = any type implementing
ISupportPublishConfirmation(currently Kafka and RabbitMQ, sync + async variants). The shared mediator fix (FR-1/2/3/4/5) is written once and applies to all of them. - C-6 (Fact, verified — corrects an earlier assumption). The confirmation callback is wired once per producer in
OutboxProducerMediator's constructor viaConfigureCallbacks(OutboxProducerMediator.cs:162), iterating_producerRegistry.Producers— it is not re-registered per outgoing message nor perDispatch/DispatchAsync. The closure captures a single construction-timeRequestContext(fromrequestContextFactory.Create()) that is empty of per-publish data: it carries neither the per-message topic nor the per-publish producer span. Therefore the topic and the original trace context for FR-1/FR-2/FR-3 cannot be taken from that capturedRequestContext. The capturedproduceryields only the staticPublication.Topic, which the wire-topic decision rejects (C-8); the per-message wire topicmessage.Header.Topicis not reachable from the captured closure state (the closure does not capture the per-messagemessage) and must be plumbed to the callback (see C-8), alongside the original trace context, which is likewise not currently available at callback time (see C-7). (Note: the capturedRequestContextis not wholly unused today — thesuccess == truebranch passes it toMarkDispatched/MarkDispatchedAsyncatOutboxProducerMediator.cs:749, 776.) - C-7 (Dependency for FR-2). Creating the span Link in FR-2 requires the original publish trace context (the producer/publish span's
ActivityContext) to be reachable at confirmation-callback time. Per C-6 this is not available today: the captured construction-timeRequestContextis empty of per-publish data, and the producer span has been ended/cleared by the time the asynchronous confirmation fires. How the context is made reachable at callback time is a design decision owned by ADR 0063; the requirement here is only that it be reachable, and that FR-2 degrade gracefully (standalone failure span, Warning log, and breaker trip still occur, but with no Link) when it cannot be. - C-8 (Topic source = per-message wire topic; reachability dependency). FR-1 (log) and FR-3 (
TripTopic: the interface overload takesRoutingKey—IAmAnOutboxCircuitBreaker.cs:44; the mediator's private overload takesRoutingKey?—OutboxProducerMediator.cs:1168) MUST use the per-message wire topicmessage.Header.Topic— the exact value the non-confirmation!sentpath trips (OutboxProducerMediator.cs:998) — notproducer.Publication.Topic. The two diverge for reply/rewritten-topic messages (GetProducerLookupTopic,OutboxProducerMediator.cs:786-807):Header.Topicmay be a dynamic reply address whilePublication.Topicis the static configured topic, so trippingPublication.Topicwould break parity and is rejected as the source. Dependency: the wire topic is NOT carried by the currentOnMessagePublisheddelegate (Action<bool, string>) nor by the captured construction-timeRequestContext(C-6), so it must be made reachable at callback time. The producer is positioned to supply it because it already receives themessagein itsSend/SendAsynccall. How the wire topic (and the C-7 trace context) reach the callback is a design decision owned by ADR 0063.
- OOS-1. Changing the Sweeper retry semantics or bubbling produce exceptions to callers (explicitly preserved, C-1).
- OOS-2. Fixing the wrong-typed
catch (ProduceException<string, string>)clauses inKafkaMessageProducer.SendWithDelay/SendWithDelayAsync. The producer isIProducer<string, byte[]>, so those catches are effectively dead code. Noted as a known low-risk issue; no behaviour change here. - OOS-3. Any new public configuration surface for toggling this observability/breaker behaviour. Behaviour is always on (NFR-5).
- OOS-4. Changing the
success == true(acked) path's delivery semantics is out of scope: the existing "sent" Info log and Outbox mark-dispatched are preserved exactly, and the success branch gains no Warning log and no breaker trip. The success branch does gain the FR-2 confirmation span and the correlation/orphan fix (the previously root-parentedMarkDispatchedspan now nests under it) — that observability change is in scope per FR-2; only the delivery behaviour is frozen.
Given/When/Then assertions. Each FR maps to at least one AC.
AC-1 (FR-1). Given a confirmation-based producer for topic orders, when it raises OnMessagePublished(false, "abc-123"), then exactly one log entry is emitted at Warning level containing message id abc-123 and topic orders, and no entry at Error or higher is emitted.
AC-2 (FR-2, C-2, C-7). Given the original publish span S1 for abc-123 has already ended and its ActivityContext was made reachable at callback time (per the C-7 mechanism the ADR adopts), when OnMessagePublished(false, "abc-123") fires, then a new standalone Activity is emitted carrying an ActivityLink whose ActivityContext equals S1's context, the original producer span is not mutated, and no failure attributes are written to an unrelated ambient span.
AC-2b (FR-2 degradation). Given no publish span context is reachable at callback time (e.g. tracing disabled, no listener), when OnMessagePublished(false, "abc-123") fires, then a standalone failure Activity is still emitted (carrying message id and topic) with no ActivityLink, and FR-1/FR-3 still occur.
AC-3 (FR-3, C-8, NFR-6). Given the circuit breaker is closed and a message whose wire topic Header.Topic is payments, when OnMessagePublished(false, ...) fires for it, then TripTopic is invoked with the RoutingKey equal to message.Header.Topic (payments) — the identical argument the non-confirmation !sent path passes (OutboxProducerMediator.cs:998).
AC-3b (FR-3 reply-topic parity, C-8). Given a reply message whose Header.Topic was rewritten to a dynamic address reply-xyz distinct from its producer's Publication.Topic, when OnMessagePublished(false, ...) fires for it, then the breaker trips reply-xyz (the wire topic), not Publication.Topic — matching what the non-confirmation path would trip.
AC-4 (FR-4, C-1). Given a deposited message abc-123 that fails to publish, when OnMessagePublished(false, "abc-123") fires, then the Outbox still shows abc-123 as un-dispatched, no exception propagates to the original caller, and the message remains eligible for Sweeper retry.
AC-5 (FR-5). Given a confirmation failure with an empty message id, when OnMessagePublished(false, "") fires, then a single Warning log (id shown as empty/unknown) and an OTel signal are emitted, the breaker is tripped for the associated topic, and no exception is thrown.
AC-6 (FR-6, FR-7). Given the Confluent client throws ProduceException with reason Message size too large and code MsgSizeTooLarge inside KafkaMessagePublisher.PublishMessageAsync, when the publish is attempted, then a Warning log containing both the reason text and the code is emitted, the method returns the synthetic NotPersisted DeliveryResult without rethrowing, and the callback still fires for the failed message.
AC-7 (FR-8). Given a synthetic NotPersisted Kafka report whose headers contain MESSAGE_ID = "abc-123", when KafkaMessageProducer.PublishResults processes it, then OnMessagePublished(false, "abc-123") is fired (not OnMessagePublished(false, string.Empty)).
AC-8 (FR-8 boundary). Given a synthetic NotPersisted Kafka report with no MESSAGE_ID header, when PublishResults processes it, then OnMessagePublished(false, string.Empty) is fired and the downstream shared path behaves per AC-5 (empty-id handling).
AC-9 (FR-9, RMQ). Given a RabbitMQ producer, when the broker nacks message def-456 and OnMessagePublished(false, "def-456") fires, then the shared mediator path emits a Warning log containing def-456, emits an OTel signal, trips the breaker for the RMQ topic, and leaves the message un-dispatched — with no RMQ producer-side code change required.
AC-10 (FR-3 repeated failures / NFR-3). Given multiple messages on the same topic failing via concurrent OnMessagePublished(false, ...) calls (on gateway-dependent threads), when the callbacks run concurrently, then the breaker trip and Warning logs occur without race-induced exceptions or lost log/telemetry entries, and the topic ends up tripped.
AC-11 (NFR-1 negative). Given any success == false confirmation, when logged, then the entry is asserted to be Warning level specifically; a test fails if it is emitted at Error or Critical.
AC-12 (NFR-2 (i)+(ii) non-blocking). Given any confirmation (success or failure), when the callback runs, then no synchronous network I/O is performed on the callback thread for telemetry, and the standalone OTel span is started and stopped within the callback (short-lived).
AC-12b (NFR-2 (iii) no new broker/Outbox await). Given a success == false confirmation, when the failure path runs, then it performs no MarkDispatched/MarkDispatchedAsync or other Outbox call and adds no awaited broker round-trip — the only side effects are the Warning log (FR-1), the standalone span (FR-2), and the breaker trip (FR-3). (Contrast: the success == true path does await MarkDispatched.)
AC-13 (FR-2 success branch + OOS-4 delivery guard). Given a success == true confirmation, when OnMessagePublished(true, "abc-123") fires, then: (a) the existing delivery behaviour is preserved — an informational "sent" log and Outbox mark-dispatched occur; (b) no Warning log and no breaker trip are produced (those stay failure-only); and (c) a standalone confirmation Activity S2 (outcome = confirmed) is emitted carrying an ActivityLink to S1, and the MarkDispatched DB span is parented under S2 (i.e. it is not an orphaned root span). Degraded: when no publish context is reachable, S2 is still emitted without a Link and mark-dispatched still occurs.
AC-14 (NFR-4 error isolation). Given the telemetry/span emission (or other observability handling) throws inside the callback, when OnMessagePublished(false, ...) fires, then the exception does not propagate out of the callback (the producer's invoking thread is not destabilised), the breaker is still tripped (FR-3), and the Warning log is still emitted (FR-1).
-
Source chain (ground truth):
KafkaMessagePublisher.PublishMessageAsync(KafkaMessagePublisher.cs:52-62) catchesProduceException, builds a syntheticDeliveryResultwithStatus = NotPersisted, invokes the callback, and logs nothing → reason/code lost (gap → FR-6).- Because the exception is swallowed,
SendAsyncreturns normally →sent == trueinOutboxProducerMediator.DispatchAsync, soif(!sent) TripTopic(...)(OutboxProducerMediator.cs:998) never runs for confirmation producers → breaker never trips (gap → FR-3). KafkaMessageProducer.PublishResults(KafkaMessageProducer.cs:364-384) readsMESSAGE_IDonly on thePersistedbranch; onNotPersistedit firesOnMessagePublished(false, string.Empty), discarding the id even though the synthetic report carries it (gap → FR-8).OutboxProducerMediator.ConfigureAsyncPublisherCallbackMaybe/ConfigurePublisherCallbackMaybe(OutboxProducerMediator.cs:737-784) handle onlysuccess == true;falseis silently dropped (gaps → FR-1, FR-2, FR-3).- On
success == true, the callback callsMarkDispatched/MarkDispatchedAsyncwith the empty construction-timeRequestContext(C-6) whose.Spanis null; the Outbox creates theMarkDispatchedDB span with that null parent (RelationDatabaseOutbox.csCreateDbSpan(..., requestContext?.Span, ...)), and the async callback thread'sActivity.Currentis also unset — so the span surfaces as a disconnected root span, unlinked from the publish (defect addressed by FR-2's success-branch confirmation span). - Contrast:
RmqMessageProducer.cs:480(async; sync analogous) already invokesOnMessagePublished(false, messageId)with the id on nack → RMQ needs verification only (FR-9).
-
Design decisions folded in: standalone OTel span carrying an
ActivityLinkto the original publish span on both the success and failure confirmation branches (FR-2, rationale C-2, dependency C-7) — symmetric because the success branch'sMarkDispatchedOutbox span is currently emitted orphaned (null parent, C-6); the confirmation span fixes that by becoming its parent, so the scope is framed as cleaning up how the confirmation callback reports overall, not a success-path feature add (the Warning log and breaker trip stay failure-only). Circuit breaker trip in scope (FR-3); breaker/log topic = per-message wire topicmessage.Header.Topicfor exact parity with the non-confirmation path including reply/rewritten topics (C-8, NFR-6). Scope = shared mediator fix (FR-1/3/4/5) + Kafka publisher logging (FR-6/7) + Kafka id propagation (FR-8) + RMQ id verification (FR-9) + making the wire topic and original trace context reachable at callback time. The mechanism for that reachability — and any resulting producer-side change beyond verify-only for RMQ — is owned by ADR 0063.
src/Paramore.Brighter/OutboxProducerMediator.cs— shared callback handling:ConfigureAsyncPublisherCallbackMaybe/ConfigurePublisherCallbackMaybe(~737-784),TripTopic(~998)src/Paramore.Brighter.MessagingGateway.Kafka/KafkaMessagePublisher.cs—PublishMessageAsync(~52-62): log swallowedProduceExceptionsrc/Paramore.Brighter.MessagingGateway.Kafka/KafkaMessageProducer.cs—PublishResults(~364-384): propagate failed message idsrc/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.csandsrc/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs— nack path (~480): verification onlysrc/Paramore.Brighter/ISupportPublishConfirmation.cs— theOnMessagePublishedcontract shared by all confirmation-based producers; the channel by which the wire topic and trace context reach the callback is an ADR decision (C-7/C-8)src/Paramore.Brighter/CircuitBreaker/IAmAnOutboxCircuitBreaker.cs—TripTopic(RoutingKey)(FR-3 target)