Replay Outgoing Messages When Inbox Receives Duplicate#4067
Conversation
Introduces the design for CausationId tracking and OnceOnlyAction.Replay, enabling automatic replay of downstream outbox messages when the inbox detects a duplicate command. Accepted after 7 rounds of adversarial review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cate Create and approve implementation task list (23 tasks) for the replay outbox messages feature. Tasks cover structural prerequisites, core behavior (CausationId propagation, UseInboxHandler replay logic, pipeline validation), telemetry, DI registration, and all 18 persistent store implementations. Minor ADR clarification for ReplayCausation semantics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review — PR #4067: Replay Outgoing Messages When Inbox Receives DuplicateThis PR contains design and specification documents only — no implementation code yet. The changes are: 🔴 Critical:
|
| Area | Status |
|---|---|
PROMPT.md in repo root |
❌ Should be removed |
| ADR quality and completeness | ✅ Well-written |
| Interface design | SupportsCausationTracking async variant worth revisiting |
| Tidy-first discipline | ✅ Correctly scoped |
| Task list TDD discipline | ✅ Follows project /test-first conventions |
| Scope creep (Task 16) | |
| Persistent store migration dependency |
The core design is sound. The main ask is removing PROMPT.md before merge; the interface and scope observations are lower priority suggestions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review — PR #4067: Replay Outgoing Messages When Inbox Receives DuplicateOverviewThis PR is a specification and design phase PR only — it contains requirements, ADR 0057, and task definitions for the Replay-on-Inbox-Duplicate feature. No production code has been written yet (the PROMPT.md explicitly states "Implementation — not started"). The 1606 additions are almost entirely documentation and spec artefacts. Issues🔴 PROMPT.md Should Not Be Merged
Recommendation: Remove 🟡 README.md for Spec 0027 Shows Tasks as Incomplete
...but Recommendation: Update the README checkbox to 🟡 Inconsistent
|
| Area | Status |
|---|---|
PROMPT.md in commit history |
❌ Remove before merge |
specs/0027/README.md tasks checkbox |
[x] |
| ADR 0057 design quality | ✅ Solid |
SupportsCausationTracking() startup I/O cost |
|
| Requirements completeness | ✅ Good — minor typos only |
| Non-breaking / opt-in design | ✅ Correct approach |
The design is sound and the specification artefacts are high quality. The main blocker before merging is removing PROMPT.md from the branch history.
Review generated by Claude Code
Code Review — PR #4067: Replay Outgoing Messages When Inbox Receives Duplicate
What's in this PR
Issues
|
| Area | Status |
|---|---|
PROMPT.md in repo |
Must fix — remove from git tracking |
| ADR structure & completeness | Good — minor gaps noted |
DescribePipelines() gap |
Low priority — fix in Task 3 |
SupportsCausationTracking() startup cost |
Needs documentation |
| Race condition clarity | Needs a note on downstream idempotency requirement |
| Requirements typos | Trivial — worth fixing |
| Task 14 scenario structure | Clarify [Theory] expectation in task |
| Task 23 test scope | Enumerate specific suites |
| Schema migration PR dependency | Track explicitly |
The design itself is sound — the role interface approach (IAmACausationTrackingInbox/Outbox) is non-breaking and consistent with Brighter's existing patterns. The decision to use the outbox sweeper for re-dispatch rather than building a new dispatch path is the right call. The CausationId concept is well-scoped and distinct from CorrelationId/JobId.
# Conflicts: # .gitignore # specs/.current-spec # specs/.current_spec
Review — PR #4067: Replay Outgoing Messages When Inbox Receives DuplicateThanks for the very thorough design work — ADR 0057 is well structured (clear Forces, Alternatives Considered, Consequences, Risks table), the role-interface approach ( Most of my feedback is about scope/hygiene of this PR, since it ships only design+spec docs — no production code or tests yet. 🔴 Blocking / should-resolve-before-merge
🟡 Design observations (for the implementation PR)These don't block a docs PR but are worth tracking against the tasks:
🟢 Nits
Test coverageN/A for this PR (docs only). The task list correctly front-loads in-memory Summary: The design is strong and review-ready. Before merge I'd (1) fix 🤖 Generated with Claude Code |
PR Review — #4067 Replay Outgoing Messages When Inbox Receives DuplicateReviewed against the repo's Scope of the changeThis PR touches 0 files under
That's consistent with the spec workflow (Requirements → ADR → Tasks, all approved), but the PR is framed as the feature itself. See the first blocking point. 🔴 Blocking / should-fix
🟡 Minor
Design assessment (ADR 0057)The ADR is genuinely strong — 7 review rounds show. I spot-checked its claims against the current code and they hold up:
The opt-in / non-breaking strategy, role interfaces ( Two things to watch during implementation:
Summary: The design is approved-quality and the task breakdown is thorough. Before merge, please address the four blocking items — most importantly, decide whether this is the spec-only PR (then don't 🤖 Generated with Claude Code |
…relational store tasks
Fold the inbox/outbox CausationId schema change into this spec via
BoxProvisioning instead of a deferred separate PR:
- requirements.md: schema evolution is in-scope via BoxProvisioning
(data backfill stays out of scope); AC9 + Performance NFR now assert
the column, the outbox CausationId index, and SupportsCausationTracking().
- ADR 0057: new "Schema Evolution via BoxProvisioning" section covering the
three store classes (catalog-based relational, Spanner provisioner, NoSQL
schemaless); per-backend catalog versions (outbox V8; inbox V3 for
MsSql/MySql/Sqlite, V2 for the V1-only Postgres inbox); Spanner
VLatestOutbox/VLatestInbox constant bumps + cross-backend drift test;
Spanner SupportsCausationTracking() as a live column probe.
- tasks.md: split Task 19 -> 19a (atomic structural schema) + 19b-19f
(per-backend IAmACausationTrackingInbox, test-first); Task 21 -> 21a
(atomic structural schema + index) + 21b (Liquid generator) + 21c-21g
(per-backend IAmACausationTrackingOutbox, test-first). The schema layer is
atomic because SpannerVLatestDriftAgainstRelationalCatalogTests pins
VLatest* to every relational catalog count.
Includes the 2026-06-17 focused re-review findings
(review-{requirements,design,tasks}.md); all threshold findings addressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…strumentationOptions Task 1 (structural/tidy-first): - Part A: UseInboxHandler/UseInboxHandlerAsync use the pipeline's shared IRequestContext (this.Context) instead of a private InitRequestContext(), so RequestContext.Bag is shared across the pipeline (prereq for CausationId). - Part B: expose instrumentationOptions as a protected InstrumentationOptions property on RequestHandler<T>/RequestHandlerAsync<T> so derived handlers can gate telemetry on InstrumentationOptions.Brighter. - Tidy: remove duplicate base.InitializeFromAttributeParams() call in async handler. Behaviour-preserving: OnceOnly tests 10/10 green (net9.0 + net10.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 2 (structural/tidy-first): non-positional RequestHandlerAttribute? Attribute init-property on PipelineStepDescription, populated at both Describe() construction sites, so validation rules can inspect attribute properties (e.g. OnceOnlyAction). Behaviour-preserving: Validation tests 90/90 green (net9.0 + net10.0). Also ticks Task 1 in tasks.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arity with Build() Task 3 (structural/tidy-first): - PipelineBuilder.Describe() now injects the global inbox UseInbox(Async)Attribute for handlers, using the same guards as Build()'s AddGlobalInboxAttributes (HasNoInboxAttributesInPipeline / HasExistingUseInboxAttributesInPipeline) via a reflection-only TryCreateGlobalInboxAttribute helper, so descriptions don't drift. - BrighterPipelineValidationExtensions resolves InboxConfiguration from IAmConsumerOptions and passes it to the validate/describe PipelineBuilder (null when no consumer options, matching the runtime). Behaviour-preserving: Validation + Pipeline tests 131/131 green (net9.0 + net10.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 4 (structural/tidy-first): add read-only IAmAnOutbox? Outbox to the mediator interface and implement it in OutboxProducerMediator as the sync or async outbox, so pipeline validation can inspect the outbox (e.g. for causation-tracking support). Behaviour-preserving: core + core-tests build clean (net9.0 + net10.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> # Conflicts: # src/Paramore.Brighter/OutboxProducerMediator.cs
Code Review — PR #4067: Replay outbox messages on inbox duplicate detectionReviewed the core library changes (the bulk of the 183 files are generated tests and per-backend query/migration additions that follow a single consistent pattern). This is a well-structured, spec-driven change (ADR 0057, role interfaces, opt-in per backend) with genuinely excellent test coverage. Feedback below, ordered by importance. 🟠 Potential behavioral change —
|
…guard non-RequestContext (PR #4067 review) MongoDb and Firestore outbox bulk Add overloads dropped the CausationId, so messages deposited via DepositPost(IEnumerable<>) landed with a null causation and OnceOnlyAction.Replay silently resent nothing for them. Read the causation id once and set it on every message, mirroring the single- message paths. Covered by the existing CausationTrackingOutboxBaseTests bulk cases the generated Mongo/Firestore tests inherit. UseInboxHandler(Async) replaced InitRequestContext() with `Context as RequestContext`, which is null when a custom IAmARequestContextFactory yields a non-RequestContext — silently changing the Throw/Warn/Add inbox paths to pass null instead of a context carrying Activity.Current. Fall back to a fresh RequestContext { Span = Activity.Current } so those paths keep their pre- feature behaviour, while the normal path still uses the shared context so the causation id flows to the outbox. Also align the sync Warn duplicate log level with the async handler (Warning, was Debug). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…red DI registration (PR #4067 review) Document the deliberate non-synchronization of the memoized causation-probe fields (_causationColumnExists ×2, _causationIndexExists ×2): volatile is not applicable to a nullable value type, and a stale-null read only triggers one extra idempotent probe that resolves to the same value. Document why the deferred AddProducers overload cannot register IAmACausationTrackingOutbox conditionally (the outbox type is unknown at registration time), so its factory returns null for a non-tracking outbox and GetRequiredService returns null rather than throwing — consumers use optional GetService. Comment-only; no behavioural change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review: Replay Outgoing Messages When Inbox Receives Duplicate (#4067)Reviewed the core implementation against ADR 0057. This is a large but well-structured PR — the design is documented thoroughly, follows Brighter's existing conventions (role interfaces ✅ What's solid
🟡 Test coverage gap (worth addressing before merge)The whole feature hinges on the causation id flowing inbox So there is no test proving the end-to-end link: a real Suggestions:
🟡 Scope creep in
|
…4067 review) Closes the propagation test gap the PR #4067 review flagged: no test proved the causation id flows inbox Add -> handler -> outbox Add within a real Send, nor that a duplicate replays the original outgoing message end-to-end. Routes everything over the real InternalBus with nothing injected through a testing seam. A command is posted through the command processor, a Reactor pump (hosted on a background thread via Performer) consumes it, the handler records it in the inbox and forwards a downstream event back onto the bus, and the inbox/outbox record receipt and the outgoing message naturally. Re-posting the same command exercises the duplicate path: the handler does not re-run and the outbox replays the original outgoing message. - ProcessAndForwardHandler: [UseInbox(Replay)] handler that forwards a downstream event via Post threading the pipeline RequestContext (the propagation link), and signals the test thread through a .NET Channel. - Replay re-dispatch uses ClearOutbox (the primitive Post is built on), not a background sweeper, so the test stays deterministic. - The shared causation id is proven by the replay itself: ReplayCausation is keyed on the inbox's causation id, so the forwarded message only returns to outstanding if the outbox stored it under that same id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review: Replay Outgoing Messages When Inbox Receives Duplicate (#4067)Thanks for this — it's a large, carefully-structured change. The ADR ( Potential issues1. (Medium) Adding a member to the public 2. (Low–Medium) Order-dependent double-read of the memoized causation-column flag in the relational outbox. 3. (Low) 4. (Low) Silent no-op when a custom 5. (Low) Scope concern6. Notes (no action required)
Overall this is high-quality work with sound architecture and thorough tests. The items above are mostly low-severity hardening; #1 (interface break) and #6 (gitignore scope) are the two worth resolving before merge. 🤖 Generated with Claude Code |
…ingle-read causation gate, replay span label)
- .gitignore: restore *.db-journal entry (kept the PROMPT.md de-dup)
- PipelineBuilder.Describe: materialize GetOtherHandlersInPipeline() once,
filter for before/after instead of two reflection passes
- RelationDatabaseOutbox: thread an explicit includeCausation bool from the
Add[Async] probe into InitAddDbCommand so the SQL choice and parameter list
derive from a single read of the memoized causation-column flag (mirrors the
bulk path) instead of an order-dependent double-read
- Observability: add BoxDbOperation.Replay ("replay.causation") and label the
ReplayCausation spans with it across all stores (relational, DynamoDB v3/v4,
Mongo, Firestore, InMemory) instead of mislabelling them MarkDispatched
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review — addressed in Fixed
Accepted, not changed
Verification: core + affected outbox projects build clean; |
…rce-breaking change (PR #4067 review #1) Adds a "Replay Outbox Messages on Inbox Duplicate" section to release_notes.md under Master, with a source-breaking-change note for the new required IRequestContext.InstrumentationOptions interface member — it affects any external implementor of IRequestContext, not just Replay adopters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-up on #1: added the breaking-change call-out in |
Code Review: Replay Outgoing Messages When Inbox Receives Duplicate (#4067)Reviewed the core of this large change (188 files): the causation-tracking design, A few observations, mostly minor, ordered by importance. 1.
|
|
Code Review — PR 4067: Replay Outbox Messages on Inbox Duplicate Detection Thanks for this — a large, carefully-structured change (ADR + 7 review rounds show). The Findings from a review of the core handlers/validation/interfaces plus a per-backend sweep of the persistent stores: 🔴 Bugs worth fixing 1. Spanner bulk outbox insert throws on a null 2. DynamoDB replay aborts the whole causation if one message was concurrently deleted — 🟡 Minor 3. MongoDB sync 4. Public interface addition is a binary/source break — 5. DynamoDB replay does N sequential round-trips — one 6. Replay silently swallows a duplicate when causation tracking is unavailable — in 7. Non-atomic check-then-set on the Bag — 8. Cosmetic — double spaces in some generated causation SQL ( Checked and liked
Overall a solid, well-tested change. Items 1 and 2 are the two I would want addressed before merge; the rest are minor/telemetry/docs. 🤖 Generated with Claude Code |
# Conflicts: # .agent_instructions/generated_tests.md # tools/Paramore.Brighter.Test.Generator/Generators/OutboxGenerator.cs # tools/Paramore.Brighter.Test.Generator/Paramore.Brighter.Test.Generator.csproj
There was a problem hiding this comment.
Code Health Improved
(5 files improve in Code Health)
Gates Failed
Prevent hotspot decline
(1 hotspot with Complex Method)
Enforce critical code health rules
(2 files with Bumpy Road Ahead, Deep, Nested Complexity)
Enforce advisory code health rules
(12 files with Complex Method, Complex Conditional, Code Duplication, Constructor Over-Injection, Large Method)
Our agent can fix these. Install it.
Gates Passed
1 Quality Gates Passed
Reason for failure
| Prevent hotspot decline | Violations | Code Health Impact | |
|---|---|---|---|
| ServiceCollectionExtensions.cs | 1 rule in this hotspot | 7.62 → 7.60 | Suppress |
| Enforce critical code health rules | Violations | Code Health Impact | |
|---|---|---|---|
| UseInboxHandler.cs | 2 critical rules | 10.00 → 8.35 | Suppress |
| UseInboxHandlerAsync.cs | 2 critical rules | 10.00 → 8.35 | Suppress |
| Enforce advisory code health rules | Violations | Code Health Impact | |
|---|---|---|---|
| UseInboxHandler.cs | 2 advisory rules | 10.00 → 8.35 | Suppress |
| UseInboxHandlerAsync.cs | 2 advisory rules | 10.00 → 8.35 | Suppress |
| MySqlOutboxMigrationCatalog.cs | 2 advisory rules | 10.00 → 9.28 | Suppress |
| MsSqlOutboxMigrationCatalog.cs | 2 advisory rules | 10.00 → 9.29 | Suppress |
| PostgreSqlOutboxMigrationCatalog.cs | 2 advisory rules | 10.00 → 9.29 | Suppress |
| InboxMessage.cs | 1 advisory rule | 10.00 → 9.69 | Suppress |
| SqliteOutboxMigrationCatalog.cs | 1 advisory rule | 10.00 → 9.69 | Suppress |
| PipelineBuilder.cs | 1 advisory rule | 7.79 → 7.55 | Suppress |
| RelationalDatabaseInbox.cs | 1 advisory rule | 7.79 → 7.55 | Suppress |
| BrighterSpanExtensions.cs | 1 advisory rule | 9.42 → 9.39 | Suppress |
| ServiceCollectionExtensions.cs | 1 advisory rule | 7.62 → 7.60 | Suppress |
| FirestoreOutbox.cs | 1 advisory rule | 6.11 → 6.47 | Suppress |
View Improvements
| File | Code Health Impact | Categories Improved |
|---|---|---|
| InMemoryOutbox.cs | 7.79 → 8.03 | Missing Arguments Abstractions |
| DynamoDbOutbox.cs | 6.90 → 7.11 | Missing Arguments Abstractions, Primitive Obsession |
| DynamoDbOutbox.cs | 6.90 → 7.11 | Missing Arguments Abstractions, Primitive Obsession |
| MongoDbOutbox.cs | 7.55 → 7.79 | Missing Arguments Abstractions |
| FirestoreOutbox.cs | 6.11 → 6.47 | Code Duplication, Missing Arguments Abstractions |
Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.
|
@lillo42 Might be worth you casting an eye over the generated tests for causation in this one |
…ategory trait)
Merging master brought in the generated-test template that emits an xUnit
[Trait("Category", "<provider>")] attribute. The committed Causation outbox
tests predated that template change, so regenerating adds the missing trait
to all 12 provider variants (Spanner already had it). Generated output only —
no logic change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| foreach (var item in queryResponse.Items) | ||
| { | ||
| if (!item.TryGetValue("MessageId", out var messageId)) | ||
| continue; | ||
|
|
||
| // Restore the outstanding marker (from the still-present CreatedTime) and clear the | ||
| // dispatched state so the sweeper resends the message. | ||
| var updateItemRequest = new UpdateItemRequest | ||
| { | ||
| TableName = _configuration.TableName, | ||
| Key = new Dictionary<string, AttributeValue> | ||
| { | ||
| { "MessageId", new AttributeValue { S = messageId.S } } | ||
| }, | ||
| UpdateExpression = "SET OutstandingCreatedTime = CreatedTime REMOVE DeliveryTime, DeliveredAt", | ||
| ConditionExpression = "attribute_exists(MessageId)" | ||
| }; | ||
|
|
||
| await _client.UpdateItemAsync(updateItemRequest, cancellationToken) | ||
| .ConfigureAwait(ContinueOnCapturedContext); | ||
| } |
| foreach (var item in queryResponse.Items) | ||
| { | ||
| if (!item.TryGetValue("MessageId", out var messageId)) | ||
| continue; | ||
|
|
||
| // Restore the outstanding marker (from the still-present CreatedTime) and clear the | ||
| // dispatched state so the sweeper resends the message. | ||
| var updateItemRequest = new UpdateItemRequest | ||
| { | ||
| TableName = _configuration.TableName, | ||
| Key = new Dictionary<string, AttributeValue> | ||
| { | ||
| { "MessageId", new AttributeValue { S = messageId.S } } | ||
| }, | ||
| UpdateExpression = "SET OutstandingCreatedTime = CreatedTime REMOVE DeliveryTime, DeliveredAt", | ||
| ConditionExpression = "attribute_exists(MessageId)" | ||
| }; | ||
|
|
||
| await _client.UpdateItemAsync(updateItemRequest, cancellationToken) | ||
| .ConfigureAwait(ContinueOnCapturedContext); | ||
| } |
|
|
||
| await GenerateAsync( | ||
| configuration, | ||
| Path.Combine("Outbox", prefix, "Generated", "Causation"), |
| await GenerateAsync( | ||
| configuration, | ||
| Path.Combine("Outbox", prefix, "Generated", "Causation"), | ||
| Path.Combine("Outbox", "Causation"), |
|
|
||
| await GenerateAsync( | ||
| configuration, | ||
| Path.Combine("Outbox", prefix, "Generated", "Causation"), |
| await GenerateAsync( | ||
| configuration, | ||
| Path.Combine("Outbox", prefix, "Generated", "Causation"), | ||
| Path.Combine("Outbox", "Causation"), |
| catch (Exception ex) | ||
| { | ||
| findings.Add(ValidationResult.Fail(new ValidationError( | ||
| ValidationSeverity.Warning, | ||
| source, | ||
| $"OnceOnlyAction.Replay could not verify causation-tracking support on the {store} store — " + | ||
| $"the schema capability probe failed ({ex.GetType().Name}: {ex.Message}). Ensure the store is " + | ||
| "reachable and its schema is migrated before relying on Replay"))); | ||
| return null; | ||
| } |
| catch (Exception) | ||
| { | ||
| // best-effort cleanup | ||
| } |
| catch (Exception) | ||
| { | ||
| // best-effort cleanup | ||
| } |
| catch (Exception) | ||
| { | ||
| // Ignoring any error during delete, it's not important at this point | ||
| } |
|
Code Review — Replay Outbox Messages on Inbox Duplicate (#4067) Reviewed against ADR 0057 / spec 0027. This is a large, well-engineered change: the role-interface design (IAmACausationTrackingInbox / IAmACausationTrackingOutbox) is clean and opt-in, the memoized column-existence probe avoids per-deposit cost while keeping deposits working against un-migrated schemas, the replay SQL is correctly parameterized on every backend, and test coverage (base tests, per-backend, end-to-end, validation) is thorough. Migration versioning is internally consistent (outbox V7->V8, inbox V2->V3, Postgres inbox V1->V2, Spanner VLatestOutbox=8 / VLatestInbox=3) with sound idempotency guards. Issues worth addressing, most important first. 🔴 Spanner bulk outbox insert throws for the common null-causation case
🟠 Inbox read path (GetCausationId) is not gated on column existence — breaks the AC10 graceful-degradation promise The write path degrades on an un-migrated table via the memoized 🟡 The feature silently no-ops unless every handler threads its RequestContext into Post/DepositPost The causation id is stamped into the pipeline RequestContext.Bag by UseInboxHandler, and the outbox Add reads it from the bag — but only if the forwarding handler passes its own Context down. 🟢 Minor
🟢 Verified correct Replay SQL (Dispatched = NULL WHERE CausationId = @CausationId on relational; the symmetric SET OutstandingCreatedTime = CreatedTime REMOVE DeliveryTime, DeliveredAt on DynamoDB, with pagination and attribute_exists guard); full parameterization of all queries; column-existence probes (sys.columns, information_schema, pg_attribute, pragma_table_info) mapping via HasRows; single-Add parameter counts; migration idempotency guards; RequestContext.CreateCopy() copies the new InstrumentationOptions; the source-breaking IRequestContext.InstrumentationOptions addition is clearly documented in the release notes; DI registration correctly registers the outbox under the role interface. Reviewed with assistance from parallel analysis of the persistent-store implementations. |
…ths (PR #4067 review) Spanner's single-arg CreateSqlParameter only forced an explicit SpannerDbType for the literal "@CausationId", but the bulk insert path names its per-row params "@p{i}_CausationId". Since Spanner ships the CausationId column in its DDL, includeCausation is always true; in the normal (non-replay) case the causation id is null, so the bulk path produced an untyped DBNull that Spanner rejects — a bulk Add of >1 message threw. Bind the causation parameter via the typed two-arg CreateSqlParameter(name, DbType.String, value) on the single-Add, bulk-Add and replay paths (matching how the bulk insert already types every other column), and drop Spanner's now -redundant name-based special case. Other relational backends tolerate untyped DBNull and are unaffected; verified via SQLite outbox 86/86 + causation 24/24. Adds a Spanner bulk-deposit-with-null-causation regression test (Category =Spanner, CI-excluded; runs against the emulator). Also removes the accidental duplicate spec pointer specs/.current_spec (underscore); specs/.current-spec (hyphen) is the canonical name the /spec skills read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
review) The inbox write path degrades on an un-migrated table via the memoized CausationColumnExists() probe, but GetCausationId/GetCausationIdAsync guarded only on "CausationQueries is null". Every backend implements the causation query interface, so on a backend whose table has not been migrated (the mixed state the write-path gate tolerates) a Replay duplicate — UseInboxHandler calls GetCausationId without first checking SupportsCausationTracking() — ran SELECT ... [CausationId] ... and threw invalid-column instead of no-opping, breaking the AC10 graceful-degradation promise. Pipeline validation only warns here, so startup succeeds and the failure surfaces at runtime on first duplicate. Gate both read methods on CausationColumnExists()/CausationColumnExistsAsync() (which already return false when the query interface is absent), returning null when the column is missing. This also protects ReplayCausation. Regression test: un-migrated (V2) SQLite inbox returns null from GetCausationId instead of throwing. SQLite legacy+causation 27/27, Core OnceOnly 40/40, full SQLite suite 151/151. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…index trap (PR #4067 review) - release_notes: add a "Usage requirement" call-out that Replay only works when handlers thread their RequestContext through Post/DepositPost; a context-less Post creates a fresh context, drops the causation id, and makes Replay a silent no-op. Includes a ❌/✅ handler example. - DynamoDbConfiguration.CausationIndexName: <remarks> warning that the value must stay "Causation" because the matching GSI hash key on MessageItem is a compile -time-constant annotation that cannot read the config; overriding it silently breaks replay (same latent trap as the Outstanding/Delivered index names). - DynamoDbInbox.GetCausationId: correct the misleading comment that implied GetCausationIdAsync adds a span (neither method does today). Docs/comments only — no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #4067: Replay Outbox Messages on Inbox Duplicate DetectionReviewed against ADR 0057 and the merge base. This is a large, well-engineered feature (189 files) with a thorough ADR, an opt-in / non-breaking design, role interfaces that follow the existing Below are the issues found, ranked. Nothing here is a blocker for the design; the notable ones are runtime-robustness gaps in mixed / partial states. 🔴 High / Medium1. Relational Scenario: inbox migrated (has 2. Handler replay callsite mirrors the same gap. 3. DynamoDB 🟡 Low4. Torn read of the memoized 5. DynamoDB replay does not reset/extend 6. 7. MsSql fresh-install index is not idempotent. 8. 9. 🧪 Test coverageCoverage is strong overall — sync+async replay, no-outbox terminal step (both), causation-id defaulting, the validation rule five checks plus probe-throws-degrades-to-Warning, the legacy/un-migrated-schema gate per relational backend, the DynamoDB "no GSI" gate, and base tests inherited by every store. Notable gaps:
Overall this is a solid, carefully-scoped implementation. The highest-value follow-ups are gating relational 🤖 Review assisted by Claude Code (Opus 4.8, 1M context). |
When an Inbox receives a duplicate message, we may choose to replay any outgoing messages that it triggered when it was originally called. This makes us idempotent, in that our behavior matches that of the original call. It also allows us to recreate messages for downstream consumers who might have failed, by triggering from upstream, thus allowing us to replay a whole workflow until we reach the point where it failed.
Closes #2541