Found reviewing #3410 (which adds F# codegen for these frames). This is a pre-existing C# defect that the F# work faithfully inherits — not a regression from that PR.
The gap
Saga.Version exists (src/Wolverine/Saga.cs:39), and Wolverine.Marten's LoadDocumentFrame honors it — it maps the saga through ExpectedSagaRevision / IRevisioned so a concurrent write is detected and retried.
The CosmosDB provider does not. CosmosDbPersistenceFrameProvider persists with a blind UpsertItemAsync(saga) — no etag captured on the point read, no ItemRequestOptions.IfMatchEtag on the write.
Failure scenario
Two messages for the same saga id land on two nodes (or two threads on one node) at the same time:
- Node A point-reads
ThingSaga { Count = 1 }.
- Node B point-reads
ThingSaga { Count = 1 }.
- Node A applies its message →
Count = 2 → UpsertItemAsync → stored.
- Node B applies its message →
Count = 2 → UpsertItemAsync → overwrites.
Final state: Count = 2. Correct: Count = 3. Lost update, silently. No exception, no retry, no dead letter — the saga just quietly has the wrong state, which for a saga (a state machine coordinating a long-running workflow) is about the worst possible failure mode.
Cosmos gives us exactly the primitive needed here: every item read carries an ETag, and ItemRequestOptions.IfMatchEtag turns the upsert into a compare-and-swap that 412s on conflict. That 412 should surface the same way Marten's concurrency exception does, so Wolverine's existing saga-retry machinery handles it.
Ask
Capture the ETag on the saga point read, pass it as IfMatchEtag on the upsert/delete, and map the resulting 412 onto whatever exception the saga retry path already understands (see how Marten's LoadDocumentFrame wires ExpectedSagaRevision). Needs a concurrency test in CosmosDbTests — two concurrent messages against one saga id, assert both applied.
Refs #3410.
Found reviewing #3410 (which adds F# codegen for these frames). This is a pre-existing C# defect that the F# work faithfully inherits — not a regression from that PR.
The gap
Saga.Versionexists (src/Wolverine/Saga.cs:39), andWolverine.Marten'sLoadDocumentFramehonors it — it maps the saga throughExpectedSagaRevision/IRevisionedso a concurrent write is detected and retried.The CosmosDB provider does not.
CosmosDbPersistenceFrameProviderpersists with a blindUpsertItemAsync(saga)— no etag captured on the point read, noItemRequestOptions.IfMatchEtagon the write.Failure scenario
Two messages for the same saga id land on two nodes (or two threads on one node) at the same time:
ThingSaga { Count = 1 }.ThingSaga { Count = 1 }.Count = 2→UpsertItemAsync→ stored.Count = 2→UpsertItemAsync→ overwrites.Final state:
Count = 2. Correct:Count = 3. Lost update, silently. No exception, no retry, no dead letter — the saga just quietly has the wrong state, which for a saga (a state machine coordinating a long-running workflow) is about the worst possible failure mode.Cosmos gives us exactly the primitive needed here: every item read carries an
ETag, andItemRequestOptions.IfMatchEtagturns the upsert into a compare-and-swap that 412s on conflict. That 412 should surface the same way Marten's concurrency exception does, so Wolverine's existing saga-retry machinery handles it.Ask
Capture the
ETagon the saga point read, pass it asIfMatchEtagon the upsert/delete, and map the resulting 412 onto whatever exception the saga retry path already understands (see how Marten'sLoadDocumentFramewiresExpectedSagaRevision). Needs a concurrency test inCosmosDbTests— two concurrent messages against one saga id, assert both applied.Refs #3410.