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
|
@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; | ||
| } |
|
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). |
…(PR #4067 review) ReplayCausation/ReplayCausationAsync guarded only on "CausationQueries is null", but every relational backend implements the causation query interface even when its table is un-migrated. In the mixed-migration state the write-path gate was built for -- inbox migrated (has CausationId), outbox NOT migrated (interface present, column absent) -- UseInboxHandler gets a real causation id from the inbox and calls ReplayCausation, which issued UPDATE {0} SET Dispatched = NULL WHERE CausationId = @CausationId against a table with no such column, throwing "invalid column CausationId" at runtime. Pipeline validation only warns for the un-migrated outbox, so startup proceeds and the failure surfaces on first duplicate. Gate both replay paths on the live CausationColumnExists()/Async probe (which already returns false when the query interface is absent), matching Add, bulk Add and the inbox GetCausationId. Closes review findings #1 and #2 (the handler callsite hole is closed at the base, so no per-callsite guard is needed). Regression test: un-migrated (V7) SQLite outbox no-ops on ReplayCausation instead of throwing. SQLite legacy 9/9, causation+outbox 97/97. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4067 review) ReplayCausationAsync issues a per-item conditional UpdateItem (attribute_exists(MessageId)) for every MessageId the Causation GSI returns, but the surrounding try had only a finally. Because the GSI is eventually consistent, the query can return a MessageId for an item since swept / purged / TTL-deleted / concurrently deleted; the condition then fails, ConditionalCheckFailedException propagates, the pagination loop unwinds, and the remaining messages in the causation are silently never re-dispatched. MarkDispatchedAsync already catches this exact exception; replay did not. Catch ConditionalCheckFailedException around the per-item UpdateItem and continue, so a vanished item is skipped and the rest of the causation is still replayed. Applied to both the V3 and V4 DynamoDbOutbox. Closes review finding #3. Regression test drives replay through an AmazonDynamoDBClient subclass that deletes the first GSI-returned item before its update runs, deterministically reproducing the race; asserts no throw and the surviving message is outstanding again. DynamoDB V3 outbox 35/35, V4 causation 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eview) The UseInboxHandler guard "if (!Bag.ContainsKey(CausationId))" is the actual linking semantic the feature is named for, but only its true branch (default the id to the command's own id) was exercised. Add a characterization test that seeds a parent causation id into the shared context bag before Send and asserts the handler does NOT overwrite it -- both in the bag and in the stored inbox entry -- so a child command is linked to its parent's causation. Closes the highest-value test gap called out in the review. No prod code; green on first run. Core UseInboxHandler causation 2/2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4067 review) SqlOutboxBuilder.GetDDL emitted a bare CREATE INDEX [idx_..._CausationId] with no guard, whereas the V8 migration catalog uses a sys.indexes IF NOT EXISTS + EXEC('CREATE INDEX ...') form and the Sqlite/Postgres/Spanner builders use CREATE INDEX IF NOT EXISTS. Safe today only because the MsSql CREATE TABLE is itself unguarded (so GetDDL runs on a true fresh install), but it was the odd one out. Wrap the index in the same sys.indexes guard for parity so re-running the DDL is idempotent; EXEC-wrapped so it compiles at run time. Closes review finding #7. Existing builder-vs-migration assertions are substring (Contains idx_..._ CausationId) and stay green. MsSql outbox project builds clean net9.0; DB-backed run deferred (no MSSQL container this session). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xt break (PR #4067 review) Documentation-only follow-ups from the review: - #6: note on IRelationalDatabaseOutboxCausationQueries.CausationColumnExistsCommand that the backend probes (OBJECT_ID / to_regclass / information_schema) resolve against the connection default schema, so a box table in a non-default schema can read as "column absent" -- a shared limitation of the unqualified box SQL, not a regression. - #8: remarks on DynamoDbInbox.GetCausationId(Async) that timeoutInMilliseconds is accepted for interface compatibility but ignored (DynamoDB handles timeout/retries), consistent with the other methods on the store. - #9: remarks on IRequestContext.InstrumentationOptions + an ADR 0057 Negative- consequences note that adding the member is source/binary-breaking for external implementers (no default interface impl) and should ship in a minor bump. #5 (ADR context_key tag) needed no change: the observability table already lists only request.id / causation_id, matching the emitted tags. No behaviour change. Core + DynamoDB inbox projects build clean net9.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review: Replay Outbox Messages on Inbox Duplicate (#4067)Reviewed the core mechanism ( A few things worth a look before merge. 1. Async telemetry events may be silently dropped on the duplicate paths (correctness, low severity — telemetry only)
Note the Add path deliberately captures 2. Default causation id is not unique per handler invocation (design)On first handling the causation id defaults to This is at odds with the ADR's framing that causation is "scoped to a single handler invocation." It is fine as long as callers who need isolation set a unique causation id in the bag themselves — but the default does not guarantee that scoping. Worth either documenting the caveat prominently or scoping the default (e.g. incorporating the context key) so the out-of-the-box behaviour matches the stated design. 3. Custom
|
…xt replay (PR #4067 review) Addresses three points from the PR #4067 code review: 1. Async telemetry events were silently dropped on the Throw/Warn/Replay duplicate paths: RequestContext.Span is thread-affine, and the branches re-read Context?.Span after the ExistsAsync await, so on a thread-pool continuation (ContinueOnCapturedContext == false) the span was null and the AddEvent never fired. Capture the span once before the first await and reuse it on every path (Throw/Warn/Replay/Add). Mirror the single capture in the sync handler for parity. 2. Default causation id is command.Id, so a message id handled by multiple [UseInbox] handlers shares it and a replay resends all of their outbox messages. This is intended (replay all downstream effects to recreate the original state); documented the caveat in ADR 0057, noting UseInbox is in practice applied to a single-handler Command, and the Bag escape hatch. 3. A custom IRequestContext (not a RequestContext) degrades Replay to a silent no-op. Emit a one-time Warning (CustomContextDisablesReplay) the first time the fallback is hit while OnceOnlyAction.Replay is configured. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #4067: Replay outbox messages on inbox duplicate detectionReviewed the core feature ( A few points below, one worth addressing before merge. 1. (Medium) DynamoDB
|
Code Review — Replay Outgoing Messages When Inbox Receives Duplicate (#4067)Thanks for a substantial, well-structured feature. I reviewed the core mechanics (the sync/async What's strong
Things worth confirming / addressing1. Binary-breaking interface changes — release/versioning. 2. Log-level change is a behavior change beyond feature scope. 3. Causation-inheritance / replay-scope semantics. 4. Replay is unconditional and untransacted. 5. Validation runs a live DB probe at startup. Nits
None of these are blockers on correctness of the happy path, which looks sound end-to-end (bag stamped before Automated review — I built understanding from the diff and source; I was not able to run a local build in this environment, so I'm relying on CI for compile/test verification. |
Code Review: Replay Outgoing Messages When Inbox Receives Duplicate (#4067)I reviewed the core design and implementation (inbox/outbox handlers, the causation-tracking role interfaces, the relational + DynamoDB + in-memory stores, DI registration, and pipeline validation). This is a large but very well-executed change — the ADR-0057 write-up, the backward-compatibility gating, the telemetry, and the test coverage (base tests + generated + a genuine end-to-end pump test) are all excellent. Feedback below is mostly nuance and a couple of footguns worth surfacing. Strengths
Concerns worth a look1. The biggest footgun — silent no-op if 2. 3. Extra probe round-trip on first deposit for all relational users. 4. 5. Multi-subscriber causation collision. As the ADR notes, because the default causation id is Minor
TestsCoverage looks strong: base tests exercised across every persistent backend, generated causation outbox tests, validation tests (including the "replay configured without causation tracking" error case), telemetry assertions, and a real end-to-end pump test that posts a duplicate and asserts the outbox replays. The DynamoDB "vanished item" and legacy/un-migrated-schema paths are explicitly covered too. Nice. Overall: high-quality, well-documented work. Nothing here is a blocker; items 1 and 2 are the ones I'd most want addressed (or explicitly accepted) before merge. Reviewed with Claude Code. |
… (PR #4067 review) Addresses the second PR #4067 review: 1. DynamoDB ReplayCausationAsync (V1 and V4) queried the Causation GSI unconditionally. In the "inbox migrated, outbox not yet" mixed state (AC10) the Dynamo inbox is schemaless so GetCausationId returns a non-null id, the handler reaches ReplayCausationAsync, and a table provisioned before the Replay feature has no Causation GSI — so the query threw a ValidationException that unwound the handler pipeline on every duplicate. Gate replay on the memoized SupportsCausationTrackingAsync probe and degrade to a no-op, matching the relational path. Adds a regression test in both V1 and V4 that drives replay against a GSI-less table (6/6 green against DynamoDB Local). 3. Documented the sync-over-async in the NoSQL replay entry points (DynamoDbOutbox.ReplayCausation x2, FirestoreOutbox.ReplayCausation) with a comment explaining why they block on the async-only SDKs and that this matches the existing convention in those classes. (#2 no change: PROMPT.md is still ignored via the "AI agent session state" .gitignore entry; the branch only dropped a duplicate copy.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review: Replay outbox messages on inbox duplicate detection (#4067)Reviewed against the repo's Strengths
Observations / things to confirm
Correctness spot-checks that look right
Nice work — this is a substantial, careful feature. The main thing I'd want addressed before merge is item #1 (the telemetry can misreport a no-op replay as a successful one in the mixed-migration state); the rest are minor or confirm-and-document. Reviewed with Claude Code. |
…review #1) WriteReplayEvent chose "Duplicate Replay" vs "Duplicate Replay Skipped" solely on causationId being null. In the mixed-migration state (inbox migrated, outbox not) the inbox still returns a non-null causation id while ReplayCausation silently no-ops against the absent column/index, so the span recorded a successful replay when nothing was resent. Make ReplayCausation / ReplayCausationAsync return bool signalling whether a replay was actually performed (false = no-op because the live schema lacks causation tracking). Both handlers pass the result to WriteReplayEvent, which now emits the Skipped event when nothing was replayed. Updated all 6 outbox implementations and the validation test double; added sync+async handler tests for the mixed-state no-op and strengthened both DynamoDB mixed-state tests to assert the false return. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #4067: Replay outbox messages on inbox duplicate detectionReviewed the core library changes (I focused on Below are observations, mostly minor — nothing I'd consider a blocker. Design & correctness
Minor / nits
TestsTest coverage is a real strength — the base-test pattern ( Nice work overall — the concerns above are polish, not correctness blockers. |
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
|
Code Review — PR #4067: Replay outbox messages on inbox duplicate detection Reviewed against the design in One blocking concern and a few smaller notes. 🔴 Blocking — likely compile error in both inbox handlers
private static bool s_warnedAboutCustomContext; // line 51
...
if (_onceOnlyAction is OnceOnlyAction.Replay
&& Interlocked.CompareExchange(ref s_warnedAboutCustomContext, 1, 0) == 0)
The usual idiom is to make the flag an private static int s_warnedAboutCustomContext;(Note: I could not run 🟡 Minor / worth a look
✅ Things I checked and liked
Once the Automated review by Claude Code. |
| { | ||
| // Silent replay degradation is a PITA to diagnose, so warn (once) when a custom context means | ||
| // Replay cannot flow the causation id to the outbox and will therefore be a no-op. | ||
| if (_onceOnlyAction is OnceOnlyAction.Replay && Interlocked.CompareExchange(ref s_warnedAboutCustomContext, 1, 0) == 0) |
| // Silent replay degradation is a PITA to diagnose, so warn (once) when a custom context means | ||
| // Replay cannot flow the causation id to the outbox and will therefore be a no-op. | ||
| if (_onceOnlyAction is OnceOnlyAction.Replay && | ||
| Interlocked.CompareExchange(ref s_warnedAboutCustomContext, 1, 0) == 0) |
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