Apply Exactly-Once Contract¶
Verified by tests
PerspectiveApplyExactlyOnceTests, ProcessedEventCacheTests, PerspectiveWorkerDedupTests, PerspectiveWorkerDrainModeTests — library CI run #31657041675 (2026-08-13)
The perspective dispatch contract is simple: Apply(TModel, TEvent) is invoked exactly once per event, per perspective, per stream. Projection Apply methods are pure functions; they are not required to be idempotent. The framework guarantees single dispatch.
The contract¶
For every tuple of (streamId, perspectiveName, eventId), the generated IPerspectiveRunner invokes the projection's Apply method at most once. This holds across all dispatch paths:
- Standard mode —
RunAsyncreads events viaIEventStore.ReadPolymorphicAsyncfrom the cursor forward. - Drain mode —
RunWithEventsAsyncreceives pre-fetched events from the coordinator batch. - Rewind mode —
RewindAndRunAsyncreplays from a snapshot or from zero, withIPerspectiveReplayReadermarking which events are new.
A projection writer should treat Apply as a write to collection state with no pre-existing dedup:
public OrderModel Apply(OrderModel current, OrderLineRowAddedEvent evt) {
current.OrderLineRows.Add(new OrderLineRow { RowId = evt.RowId, /* … */ });
return current;
}
That code is correct. If the contract is ever violated, the symptom is an easy tell: the target collection contains duplicate rows (same RowId), or a counter increments by more than one.
Why this contract exists¶
Making Apply self-idempotent pushes cost onto every projection author — every collection write needs a RowId check, every numeric accumulator needs a stamped eventId set, every scalar overwrite needs a timestamp comparison. The framework already knows which (streamId, perspectiveName, eventId) tuples have been dispatched; the exactly-once guarantee keeps projection code small.
How the guarantee holds¶
Standard mode¶
PerspectiveWorker groups pending PerspectiveWork by (StreamId, PerspectiveName) and invokes runner.RunAsync(streamId, perspectiveName, lastProcessedEventId, …) once per group per cycle. The runner reads events > lastProcessedEventId from the event store and applies them in UUIDv7 order.
Drain mode¶
When the coordinator batch carries leased events, the worker batch-fetches with a single SQL call (get_stream_events) and feeds the pre-deserialized envelopes into runner.RunWithEventsAsync. The upstream SQL joins perspective_events × event_store — the same event can appear in the result multiple times if multiple queue rows reference it. The worker dedupes by MessageId at the group step before dispatching, so the runner sees each event exactly once while every queued EventWorkId still receives its own completion row.
Drain/standard co-fire¶
If a stream appears in both WorkBatch.PerspectiveStreamIds (drain) and WorkBatch.PerspectiveWork (standard), the worker processes it via drain mode and clears the standard-mode work queue for that cycle. The two dispatch paths cannot co-fire for the same cycle.
Rewind mode¶
During a rewind, IPerspectiveReplayReader.ReadReplayEventsAsync annotates each replayed event with an IsNew flag (ReplayEventEnvelope.IsNew — true when the event still has a pending row in the perspective work queue). The runner applies every event in UUIDv7 order to reconstruct model state, but the lifecycle receptors only fire for IsNew == true — see Exactly-Once Receptor Firing for the receptor-side of this contract.
Re-delivery guard: ProcessedEventCache¶
Perspective completions are written back to the database in batches. Between Apply and the database acknowledging that completion, SQL polling can re-deliver the same wh_perspective_events rows. PerspectiveWorker guards this window with an in-memory two-phase TTL cache (ProcessedEventCache):
- InFlight — event work IDs are added to the cache after Apply, with no expiry, guarding until the database confirms the completion batch.
- Retained — once the batch is acknowledged (
ActivateRetention), the TTL countdown starts, aligned to the lease duration. - Evicted — expired entries are removed at the start of each poll cycle (
EvictExpired); SQL re-delivery is then allowed again, which is correct for rewind/rebuild scenarios.
Before grouping standard-mode work, the worker filters out any work item whose WorkId is already in the cache. The cache also provides force-removal (Remove/RemoveRange) for rewind scenarios, allowing replay of previously processed events.