Versioned Apply — Strict Cross-Pod Ordering¶
Verified by tests
VersionedApplyTargetTests, CrossPodStaleReadRegressionRaceTests — library CI run #31657041675 (2026-08-13)
When you need it¶
Two pods are processing the same per-item stream. Each loads the projection row at roughly the same time and both see "no row" (or an earlier state). Each applies its own event and writes back. The writes go through independent transactions. If the staler write commits last, the row regresses — Pod A's Completed write gets overwritten by Pod B's stale-read Running write a few milliseconds later.
This race produced a real production per-item saga strand that survived even the stream-affinity gate: 2 of 350 items stranded at Running even though both their SagaItemStartedEvent and SagaItemCompletedEvent were durably committed to wh_event_store. The framework reconciler healed the saga, but the projection itself stayed wrong.
The default storage contract is deliberately permissive for this case — see CrossPodStaleReadRegressionRaceTests. Whizbang's chosen v0.740 solution was upstream: PerspectiveWorker's (streamId, perspectiveName) affinity gate plus wh_active_streams cross-pod ownership. Together they pin a stream to one pod at a time. But the gate has narrow windows where the strand can still form (lease handoff during pod restarts, the gap between wh_active_streams row expiry and the next pod's claim, etc.).
IVersionedApplyTarget is the opt-in marker that closes those windows for models that can't tolerate them. It adds a strict-greater UUIDv7 EventId check to the UPSERT WHERE clause: a stale write whose metadata.EventId is lexicographically older than what's already on the row is silently dropped.
How it works¶
Opt in
using Whizbang.Core.Perspectives;
public class SagaItemModel : ISagaItem, IVersionedApplyTarget {
// ... model fields unchanged
}
The Postgres atomic UPSERT path detects the marker at compile-time-pinned runtime check and substitutes the WHERE clause:
-- Default (non-opted-in models)
WHERE wh_per_X.metadata->>'CommitSequence' IS NULL
OR EXCLUDED.metadata->>'CommitSequence' IS NULL
OR (EXCLUDED.metadata->>'CommitSequence')::bigint >=
(wh_per_X.metadata->>'CommitSequence')::bigint
-- IVersionedApplyTarget opt-in
WHERE wh_per_X.metadata->>'EventId' IS NULL
OR EXCLUDED.metadata->>'EventId' > wh_per_X.metadata->>'EventId'
UUIDv7 from TrackedGuid.NewMedo() orders lexicographically by emission time. "Newer event wins" is the simple, total ordering rule. Same EventId (a transport redelivery, a consumer retry) is a strict-greater fail → the redundant UPDATE is skipped → version stays at the previous value → the Apply is idempotent.
Behavior summary¶
| Scenario | Default contract | Opt-in (IVersionedApplyTarget) |
|---|---|---|
| Newer event applied later | Wins | Wins |
| Stale event applied later (older EventId) | Wins (overwrites the row) | Skipped |
| Same EventId re-applied | Wins (bumps version) |
Skipped (version unchanged) |
Null metadata.CommitSequence on both sides |
Last-writer-wins (current contract) | Falls back to EventId comparison |
Null metadata.EventId on existing row |
Initial write proceeds | Initial write proceeds |
When to opt in¶
Opt in when all of:
- The model is updated from a per-item or other narrow stream where two pods can briefly race on the same row.
- The Apply transitions are state-machine-like — once advanced to a terminal state, regressing is wrong (e.g., a
SagaItemModelgoing fromCompletedback toRunning). - Stamper-lag forwarding doesn't apply — the model's writes ship with
metadata.CommitSequence = nulland you can't rely on the CommitSequence-based legacy guard.
The first opt-in is Whizbang.Sagas.Models.SagaItemModel (2026-06-26 release). Other model authors can add the marker on the same release without further changes.
When NOT to opt in¶
Don't opt in for:
- Replay-driven models that depend on stamper-lag forwarding. The forwarding contract assumes the storage layer is permissive when CommitSequence is null. Pinning to strict EventId ordering breaks that.
- Models with concurrent Apply paths that don't share a stream id. EventId ordering across distinct streams isn't meaningful — newer EventId can be a totally unrelated event.
- Aggregations / global perspectives. These typically need event coalescing logic that EventId ordering doesn't express.
If in doubt, leave the marker off. The default contract is the documented, locked-in behavior; the opt-in is the safety upgrade for the narrow class of streams that need it.
Why opt-in, not default¶
The opt-in design preserves backward compatibility with three deliberate behaviors:
- Stamper-lag forwarding. Existing consumers depend on the storage layer accepting null-CommitSequence writes so the runner's idempotency logic can decide whether to forward.
- Order-independent forward progress. Two unrelated forward writes (e.g.,
Runningfirst,Completedsecond) must converge to the most-advanced state regardless of arrival order. The default contract preserves this. - Last-writer-wins regression test lock-ins (
CrossPodStaleReadRegressionRaceTests). A regression here would silently break consumers; the opt-in surfaces deliberate intent and isolates the change.
Related¶
- Completion Orchestration & Adaptive Watchdog — covers the saga-side framework that depends on
SagaItemModel's strict ordering for cross-pod strand prevention. - PublishOnceAsync — the dispatcher-level exactly-once primitive sagas use for terminal-event emission.
Whizbang.Data.EFCore.Postgres.Tests/CrossPodStaleReadRegressionRaceTests.cs— the lock-in tests for the default non-strict contract this opt-in deliberately bypasses.