Skip to content

Commit 2049b95

Browse files
merge: CLOUD-216 per-subscription fail-closed delivery semantics (spec 0028)
Adds opt-in FailureMode {FailOpen, FailClosed} per subscriber. Fail-closed subscribers halt (hold position, fire on_halt, DLQ the bad event) on deserialize/observer failures in live, catch-up, and drain paths instead of limping past; wedged subscribers are excluded from the shared floor and driven by a private re-seeding fetch so peers keep advancing; gaps are refused (TimeoutBackstop) with FenceCleared rollback recovery; release_halt gives operators a forward-only release; the ReplayAlways HWM advances only across the contiguous prefix (closes CLOUD-227). Reviewed: 3-round spec loop, 3-round plan loop, per-phase reviews (P1-P6), and a 2-round parent-orchestrated pre-merge loop; one P1 (panicking HaltCallback killed the listener) found and fixed in the final round. Consolidated epoch_pg suite 259 passed / 0 failed.
2 parents a19d001 + ce03500 commit 2049b95

16 files changed

Lines changed: 5313 additions & 183 deletions

CHANGELOG.md

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Per-subscriber fail-closed delivery semantics** (`epoch_core`, `epoch_pg`, CLOUD-216) —
13+
an opt-in `FailureMode { FailOpen (default), FailClosed }` letting a subscriber halt
14+
rather than silently skip an event it cannot apply in order:
15+
- **`epoch_core`**`FailureMode` enum (`#[non_exhaustive]`, re-exported from the
16+
prelude); defaulted `failure_mode()` on `EventObserver`, `Projection`, and `Saga`,
17+
forwarded by `ProjectionHandler`, `SagaHandler`, `SagaAdapter`, and the
18+
`impl Saga for Arc<S>` blanket. Existing implementors are unchanged (default `FailOpen`).
19+
- **Live batch path** — a `FailClosed` subscriber that hits a deserialize failure or an
20+
observer-retry exhaustion holds its contiguous checkpoint below the bad sequence
21+
(writing a `unrecoverable: deserialize: …` DLQ row for the deserialize case), fires
22+
the new `on_halt` callback once on entry, and self-heals on the next batch once the
23+
cause is fixed. A panicking observer is now contained (caught in
24+
`process_event_with_retry`) instead of killing the listener task — a deliberate
25+
fail-open behaviour change: the panic routes through the existing retry/DLQ
26+
machinery.
27+
- **Catch-up and drain paths** — the same hold semantics apply during initial catch-up
28+
and inline-drain: a `FailClosed` subscriber that hits a bad row while catching up
29+
halts at that row (the subscriber is still registered and the live listener takes
30+
over from there), and self-heals once the cause is fixed.
31+
- **Gap refusal** — a `FailClosed` subscriber refuses the `gap_timeout` backstop: if
32+
the backstop would advance past an unproven gap, the subscriber halts with
33+
`HaltReason::GapUnproven` and stays held until the gap is proven permanent (fence
34+
clears) or an operator releases it. The fence-clear branch is untouched: a gap
35+
proven never to have existed advances under both modes.
36+
- **Wedge isolation** — a halted `FailClosed` subscriber is excluded from the shared
37+
event-window floor and served by its own private re-seeding fetch each cycle, so a
38+
permanently wedged subscriber cannot pin the delivery window of healthy peers.
39+
- **`PgEventBus::release_halt(subscriber_id, past_sequence)`** — new operator API
40+
for advancing a wedged subscriber's persisted checkpoint past a held sequence it
41+
never finished. Forward-only (rejects `past_sequence` at or below the current
42+
checkpoint with `PgEventBusError::BackwardRelease`). Fires the `on_halt` callback
43+
with `HaltReason::Released` on success. Events already applied above the released
44+
position are folded in without re-delivery.
45+
- **`ReplayAlways` contiguous HWM** (CLOUD-227) — a `FailClosed` `ReplayAlways`
46+
subscriber now tracks a contiguous high-water mark that advances only across the
47+
unbroken prefix of successfully applied sequences, matching the checkpoint semantics
48+
of `Checkpointed` subscribers. A wedged `ReplayAlways` subscriber is likewise
49+
excluded from the shared floor; its remedy is a fresh `subscribe()` call for a full
50+
replay (no `release_halt``ReplayAlways` has no persisted checkpoint row).
51+
- New public API in `epoch_pg::event_bus`: `HaltCallback`, `HaltInfo`, `HaltReason`
52+
(`#[non_exhaustive]`, variants: `DeserializeFailure`, `ObserverFailure`,
53+
`GapUnproven`, `Released`). No schema migration.
54+
1255
- **Subscriber readiness + startup safety** (`epoch_core`, `epoch_pg`, CLOUD-221) —
1356
first-class lag/readiness API on `PgEventBus`, a `ReplayAlways` subscription mode for
1457
in-memory projections that replay from zero every boot, and startup catch-up so a
@@ -212,9 +255,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
212255
`setup_trigger()` once per bus to drop the legacy fixed-name trigger; leaving it in
213256
place is not unsafe, only redundant — it produces one duplicate NOTIFY per insert,
214257
which is discarded by the per-event checkpoint check.
215-
- **BREAKING** (`epoch_pg`): `PgEventBusError` gains two new variants,
216-
`SubscriberNotFound` and `InlineDispatchNotSupported`. The enum is not
217-
`#[non_exhaustive]`, so a downstream exhaustive `match` on it will stop compiling.
258+
- **BREAKING** (`epoch_pg`): `PgEventBusError` gains three new variants,
259+
`SubscriberNotFound`, `InlineDispatchNotSupported`, and `BackwardRelease`
260+
(returned by `release_halt` when `past_sequence` is at or below the current
261+
persisted checkpoint). The enum is not `#[non_exhaustive]`, so a downstream
262+
exhaustive `match` on it will stop compiling.
218263
- Released the `projections` lock across the listener's batch drain instead of
219264
holding it for the whole backlog (`epoch_pg`, CLOUD-225): every readiness method
220265
also locks `projections`, so `subscriber_lag`, `wait_until_caught_up`, and
@@ -248,6 +293,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
248293
`on_gap_timeout: Option<Arc<dyn GapTimeoutCallback>>` field (defaults to `None`).
249294
Code that constructs `ReliableDeliveryConfig` using struct-literal syntax (rather than
250295
`..Default::default()`) must add `on_gap_timeout: None` to the literal.
296+
- **Source-compat note** (`epoch_pg`, CLOUD-216, `feat(pg)!`): `ReliableDeliveryConfig`
297+
gains the new `on_halt: Option<Arc<dyn HaltCallback>>` field (defaults to `None`),
298+
fired when a fail-closed subscriber halts delivery. Code that constructs
299+
`ReliableDeliveryConfig` using struct-literal syntax (rather than
300+
`..Default::default()`) must add `on_halt: None` to the literal.
251301
- **Source-compat note**: `ReliableDeliveryConfig` (`epoch_pg`) gains the new
252302
`snapshot_fencing: bool` field (defaults to `true`). Code that constructs
253303
`ReliableDeliveryConfig` using struct-literal syntax (rather than

epoch_core/src/event_store.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,52 @@ pub trait EventBus {
187187
T: EventObserver<Self::EventType> + Send + Sync + 'static;
188188
}
189189

190+
/// How a subscriber reacts when an event cannot be applied.
191+
///
192+
/// Controls the bus's behaviour at every failure point (deserialisation error,
193+
/// observer error, unproven gap) for a given subscriber.
194+
///
195+
/// # Default
196+
///
197+
/// The default is [`FailureMode::FailOpen`]: on any failure the bus logs, skips
198+
/// the event, and advances the checkpoint past it. This is the correct choice
199+
/// for most subscribers.
200+
///
201+
/// # `FailClosed` and frozen read models
202+
///
203+
/// When a subscriber opts into [`FailureMode::FailClosed`], the bus halts
204+
/// delivery at the first unrecoverable failure: the subscriber's checkpoint is
205+
/// held below the bad event and no further events are applied to *that
206+
/// subscriber* until the condition is resolved (the bad row is fixed, or an
207+
/// operator calls `release_halt`). The halt is **subscriber-local**: healthy
208+
/// subscribers keep advancing because a wedged subscriber is excluded from the
209+
/// shared event-window floor and served by its own private re-seeding fetch,
210+
/// so it cannot pin the delivery window of any healthy peer.
211+
///
212+
/// **Cross-group consequence (R12):** while the halted subscriber's read model
213+
/// is frozen, later priority groups keep running against that stale view. For a
214+
/// deny-heavy oracle (e.g. an auth projection), stale data errs on the side of
215+
/// denying rather than over-admitting — the freeze fails safe. Recovery is
216+
/// self-healing: once the cause is fixed, the held event and everything after
217+
/// it are applied exactly once in order on the next batch cycle. For a gap
218+
/// that never resolves, an operator release advances the subscriber's persisted
219+
/// position past the held sequence. A permanent unreleased halt means a
220+
/// permanently frozen read model for that subscriber; it does not starve or
221+
/// slow any other subscriber on the bus.
222+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
223+
#[non_exhaustive]
224+
pub enum FailureMode {
225+
/// Default. On any delivery failure, log, skip, and advance the checkpoint
226+
/// past the failed event. The read model may silently diverge from the
227+
/// event log, but delivery continues unimpeded.
228+
#[default]
229+
FailOpen,
230+
/// Halt delivery at the first unrecoverable failure. The checkpoint is held
231+
/// below the bad event; no further events are applied until the condition
232+
/// clears. See the [`FailureMode`] docs for the cross-group wedge risk.
233+
FailClosed,
234+
}
235+
190236
/// How a subscriber relates to persisted checkpoints.
191237
///
192238
/// Controls whether the event bus reads and writes a checkpoint row for this
@@ -258,6 +304,15 @@ where
258304
fn subscription_mode(&self) -> SubscriptionMode {
259305
SubscriptionMode::Checkpointed
260306
}
307+
308+
/// How the bus reacts when this subscriber cannot apply an event.
309+
///
310+
/// Defaults to [`FailureMode::FailOpen`]: log, skip, and advance past the
311+
/// failed event. Override and return [`FailureMode::FailClosed`] for
312+
/// subscribers that must never silently diverge from the event log.
313+
fn failure_mode(&self) -> FailureMode {
314+
FailureMode::FailOpen
315+
}
261316
}
262317

263318
/// Used to construct an event stream from a slice.
@@ -288,7 +343,7 @@ where
288343
/// A reference-based event stream constructed from a slice.
289344
///
290345
/// This is an optimized version of [`SliceEventStream`] that yields references
291-
/// to events instead of cloning them. Used internally by [`Aggregate::handle`]
346+
/// to events instead of cloning them. Used internally by `Aggregate::handle`
292347
/// to avoid cloning freshly created events during re-hydration.
293348
///
294349
/// # Performance

epoch_core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub mod upcasting;
3636
#[cfg(feature = "testing")]
3737
pub mod testing;
3838

39+
pub use event_store::FailureMode;
3940
pub use event_store::SubscriptionMode;
4041
pub use subscriber_id::SubscriberId;
4142

epoch_core/src/projection.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//! be subscribed to the event bus for their own events. This would cause
1212
//! duplicate writes and race conditions.
1313
//!
14-
//! The type system enforces this: `Aggregate` extends [`EventApplicator`](crate::event_applicator::EventApplicator),
14+
//! The type system enforces this: `Aggregate` extends [`EventApplicator`],
1515
//! NOT `Projection`, so aggregates cannot be wrapped in `ProjectionHandler`.
1616
1717
use std::sync::Arc;
@@ -174,12 +174,21 @@ where
174174

175175
/// How this projection relates to persisted checkpoints.
176176
///
177-
/// Defaults to [`SubscriptionMode::Checkpointed`]. Override and return
178-
/// [`SubscriptionMode::ReplayAlways`] for in-memory projections that must
177+
/// Defaults to [`crate::event_store::SubscriptionMode::Checkpointed`]. Override and return
178+
/// [`crate::event_store::SubscriptionMode::ReplayAlways`] for in-memory projections that must
179179
/// replay from sequence 0 on every process start.
180180
fn subscription_mode(&self) -> crate::event_store::SubscriptionMode {
181181
crate::event_store::SubscriptionMode::Checkpointed
182182
}
183+
184+
/// How the bus reacts when this projection cannot apply an event.
185+
///
186+
/// Defaults to [`crate::event_store::FailureMode::FailOpen`]: log, skip, and advance past the
187+
/// failed event. Override and return [`crate::event_store::FailureMode::FailClosed`] for
188+
/// projections that must never silently diverge from the event log.
189+
fn failure_mode(&self) -> crate::event_store::FailureMode {
190+
crate::event_store::FailureMode::FailOpen
191+
}
183192
}
184193

185194
/// Wraps a [`Projection`] to implement [`EventObserver`](crate::event_store::EventObserver)
@@ -260,6 +269,10 @@ where
260269
fn subscription_mode(&self) -> crate::event_store::SubscriptionMode {
261270
self.0.subscription_mode()
262271
}
272+
273+
fn failure_mode(&self) -> crate::event_store::FailureMode {
274+
self.0.failure_mode()
275+
}
263276
}
264277

265278
#[cfg(test)]

epoch_core/src/saga.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,22 @@ where
129129

130130
/// How this saga relates to persisted checkpoints.
131131
///
132-
/// Defaults to [`SubscriptionMode::Checkpointed`]. Override and return
133-
/// [`SubscriptionMode::ReplayAlways`] for in-memory sagas that must
132+
/// Defaults to [`crate::event_store::SubscriptionMode::Checkpointed`]. Override and return
133+
/// [`crate::event_store::SubscriptionMode::ReplayAlways`] for in-memory sagas that must
134134
/// replay from sequence 0 on every process start.
135135
fn subscription_mode(&self) -> crate::event_store::SubscriptionMode {
136136
crate::event_store::SubscriptionMode::Checkpointed
137137
}
138138

139+
/// How the bus reacts when this saga cannot apply an event.
140+
///
141+
/// Defaults to [`crate::event_store::FailureMode::FailOpen`]: log, skip, and advance past the
142+
/// failed event. Override and return [`crate::event_store::FailureMode::FailClosed`] for
143+
/// sagas that must never silently diverge from the event log.
144+
fn failure_mode(&self) -> crate::event_store::FailureMode {
145+
crate::event_store::FailureMode::FailOpen
146+
}
147+
139148
/// Processes an incoming event, applies it to the saga, and persists the resulting state.
140149
///
141150
/// This method is called by the blanket [`EventObserver`] implementation. It handles:
@@ -230,6 +239,10 @@ where
230239
fn subscription_mode(&self) -> crate::event_store::SubscriptionMode {
231240
(**self).subscription_mode()
232241
}
242+
243+
fn failure_mode(&self) -> crate::event_store::FailureMode {
244+
(**self).failure_mode()
245+
}
233246
}
234247

235248
/// A wrapper type that provides an [`EventObserver`] implementation for [`Saga`] types.
@@ -297,6 +310,10 @@ where
297310
fn subscription_mode(&self) -> crate::event_store::SubscriptionMode {
298311
self.0.subscription_mode()
299312
}
313+
314+
fn failure_mode(&self) -> crate::event_store::FailureMode {
315+
self.0.failure_mode()
316+
}
300317
}
301318

302319
/// Adapter that lets a [`Saga`] subscribe to a *foreign* event bus carrying a
@@ -458,6 +475,10 @@ where
458475
fn subscription_mode(&self) -> crate::event_store::SubscriptionMode {
459476
self.saga.subscription_mode()
460477
}
478+
479+
fn failure_mode(&self) -> crate::event_store::FailureMode {
480+
self.saga.failure_mode()
481+
}
461482
}
462483

463484
#[cfg(test)]

epoch_core/src/upcasting.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Event schema evolution via forward-only **upcasting**.
22
//!
33
//! Once an event variant is persisted, its serialized shape is frozen in the store
4-
//! forever, yet the in-memory [`EventData`](crate::event::EventData) type it must
4+
//! forever, yet the in-memory [`EventData`] type it must
55
//! deserialize into keeps changing as the domain evolves. Upcasting bridges that gap:
66
//! a stored payload is transformed forward, **one version at a time**, to the current
77
//! schema *before* it is deserialized into the domain type.
@@ -38,7 +38,7 @@ use uuid::Uuid;
3838

3939
use crate::event::EventData;
4040

41-
/// Re-export [`SchemaVersion`](crate::event::SchemaVersion) from the event module so
41+
/// Re-export [`SchemaVersion`] from the event module so
4242
/// that code importing from this module gets a consistent, unambiguous type.
4343
pub use crate::event::SchemaVersion;
4444

@@ -88,7 +88,7 @@ impl<'a> UpcastContext<'a> {
8888
/// projections.
8989
pub trait Upcaster: Send + Sync {
9090
/// The `event_type` (PascalCase, matching
91-
/// [`EventData::event_type`](crate::event::EventData::event_type)) this step applies
91+
/// [`EventData::event_type`]) this step applies
9292
/// to.
9393
fn event_type(&self) -> &str;
9494

0 commit comments

Comments
 (0)