Skip to content

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 modeRunAsync reads events via IEventStore.ReadPolymorphicAsync from the cursor forward.
  • Drain modeRunWithEventsAsync receives pre-fetched events from the coordinator batch.
  • Rewind modeRewindAndRunAsync replays from a snapshot or from zero, with IPerspectiveReplayReader marking 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.IsNewtrue 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.