All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
read_events_rangebounded-replay primitive (epoch_core,epoch_pg,epoch_mem, CLOUD-183) — a newEventStoreBackend::read_events_range(stream_id, from: Option<u64>, to: Option<u64>)primitive that pushes inclusive[from, to]stream_versionbounds down to storage, eliminating the full-stream over-read previously required for upper-bounded replay:epoch_core—read_events_rangeadded as the new required method;read_eventsandread_events_sinceconverted to default methods delegating to it (None, NoneandSome(version), Nonerespectively). ⚠ Breaking change for externalEventStoreBackendimplementors: the new required method must be added.epoch_pg—PgEventStore::read_events_rangebuilds optional>= from/<= topredicates with dynamic$Nbinding, staying sargable on the existingUNIQUE (stream_id, stream_version)index; theread_events/read_events_sinceoverrides are removed.epoch_mem—InMemoryEventStore::read_events_rangewith early-termination on the upper bound;InMemoryEventStoreStreamgainsto_version: Option<u64>; theread_events/read_events_sinceoverrides are removed.- No schema migration, no new dependency, no event-format change.
-
Versioned snapshot store with configurable capture & retention (
epoch_core,epoch_pg,epoch_mem, CLOUD-184) — an opt-in, version-keyed historical snapshot capability distinct from the single-snapshotStateStoreBackend. Aggregates with no snapshot config have no observable behaviour change versus before (no extra I/O; the defaulted async hook adds one boxed-future allocation per command):epoch_core::snapshot(new module, re-exported from the prelude) —SnapshotStore<S>trait (load_snapshotnearest≤ target_version, idempotentsave_snapshot,apply_retention), plusSnapshot<S>,SnapshotConfig,SnapshotTrigger(Manual|Automatic { interval }) andSnapshotRetention(Unlimited|KeepLast(n)). The trigger/retention enums are#[non_exhaustive]for future variants.Aggregate::after_persist— new defaulted no-op lifecycle hook invoked once byhandle()in the state-present branch afterpersist_state; externalAggregateimplementors compile unchanged. Thedelete_statebranch is untouched.SnapshottingAggregate<ED>extension trait —capture_snapshot_if_due(captures iff anintervalboundary is crossed by the command, then prunes per retention; store failures are logged, never fatal — a snapshot is a rebuildable cache) and a manualsave_snapshot(id)path.interval == 0never captures.SaveSnapshotError.epoch_core::snapshot::state_at(...)— free function reconstructing the state of a stream as of a given version, equivalent to a full replay from zero but using the nearest snapshot≤ versionas a fast start;StateAtError. UsesEventStoreBackend::read_events_range(stream_id, Some(from), Some(version))for I/O-optimal bounded replay.InMemorySnapshotStore<S>(epoch_mem) andPgSnapshotStore<S>(epoch_pg,S: Serialize + DeserializeOwned) implementSnapshotStore<S>; both re-exported.- Migration
m013_create_snapshots_table(version 13) — creates theepoch_snapshotstable (stream_id,version,data jsonb,created_at) withPRIMARY KEY (stream_id, version); additive, forward-only, no backfill.
-
Event schema evolution / upcasting mechanism (
epoch_core,epoch_pg,epoch_derive, CLOUD-173) — a first-class, versioned, fail-loud-by-default upcasting system so that historic persisted events can be transformed forward to the current schema before deserialization, eliminating both hard replay aborts and silent event loss:epoch_core::upcasting(new, opt-inupcastingfeature) —Upcastertrait,UpcasterRegistry,FailurePolicy(Fail|DeadLetter),UpcastError,UpcastContext,DeadLetterSink,DeadLetteredEvent,UpcastCounters. An empty registry (the default) is a zero-cost no-op: events that still deserialize cleanly behave exactly as before.EventData::schema_version()— new defaulted method returning1; all existingEventDataimpls compile unchanged.#[derive(EventData)]accepts an optional#[event_data(schema_version = N)]enum-level attribute.Event<D>/EventBuilder<D>gain aschema_version: SchemaVersionfield (default1), populated fromEventData::schema_version()on the write path and from the stored column on the read path, and preserved through all envelope conversions (to_subset_event,to_subset_event_ref,to_superset_event,into_builder,build).- Migration
m012_add_schema_version_to_events— adds a nullableschema_version INT DEFAULT 1column toepoch_eventsviaADD COLUMN IF NOT EXISTS; no backfill, no table rewrite. Pre-migration rows keepNULL, interpreted as version1by the read path. Registered at version 12. PgEventStore::with_upcasters(pool, bus, Arc<UpcasterRegistry>)— new constructor that wires the registry into the read path; the existing constructors set an empty registry (identical behavior for projects that register no upcasters).PgEventStoreError::Upcast— new variant that wrapsUpcastErrorand propagates hard failures through the event stream underFailurePolicy::Fail.ensure_schema_version_columnhelper (epoch_pg) — idempotentADD COLUMN IF NOT EXISTSfor custom tables created viaPgEventStore::with_table.epoch_core::testing::verify_upcasting_chain— reusable contract-test helper (gated behind thetestingfeature, usable from downstream crates) that asserts a multi-step upcasting chain correctly advances a v1 payload to the current version and deserializes it.docs/schema-evolution.md— new design guidance document covering the change-kind matrix, worked examples for all five mutation kinds (add optional field, rename field, remove field, change field type, add required field with default), failure policy trade-offs, the split/merge pattern, and an operational checklist.- Upcasting observability counters (
applied,succeeded,failed,dead_lettered) accessible viaUpcasterRegistry::counters(); DEBUG/ERROR structured logs emitted per step and per failure.
-
EventStoreBackend::store_events()atomicity contract test (epoch_core, CLOUD-171) — a reusable, backend-agnostic helper that verifies any backend'sstore_events()honours the all-or-nothing persistence guarantee:epoch_core::testing::verify_store_events_atomicity(backend, make_event)seeds one event, submits a mid-batch duplicate-version batch (which must be rejected), then asserts that no event from the failed batch was persisted — catching partial writes. It also exercises an empty-batch no-op and a fully-valid batch.- Gated behind the new
testingfeature inepoch_core; never ships in production builds. Enable it in[dev-dependencies]to run the helper from your own test suite. - Both first-party backends (
epoch_mem,epoch_pg) run this contract test as part of their integration test suites and pass with no behavioral change.
-
Snapshot fencing for gap resolution (
epoch_pg, CLOUD-180) — the event-bus gap resolver now proves whether aglobal_sequencegap can still be filled by an in-flight transaction instead of guessing on a wall-clock timer:- Migration
m011_add_txid_to_eventsadds a nullabletxid BIGINTcolumn toepoch_eventswith aDEFAULT (pg_current_xact_id()::text::bigint)(PostgreSQL 13+ only) and a partial index. Existing rows keepNULL(no backfill, no table rewrite). - The reader queries the current transaction-id snapshot
(
pg_snapshot_xmin/xmax(pg_current_snapshot())) once per batch that has active or newly-detected gaps and holds the checkpoint for a gap whose writer is still in-flight until it commits (gap fills) or aborts (gap proven permanent — advanced asFenceCleared, silently, with no record). gap_timeoutis demoted to a backstop: it still fires (and records via the CLOUD-169 machinery, plus a dedicated persistent-pinWARNwith the currentxmin/fence_xmax) for pathological cases where the fence cannot clear (abandoned prepared transactions,idle-in-transactionsessions pinningxmin).ReliableDeliveryConfig::snapshot_fencing(defaults totrue) toggles the feature;falsereverts to the legacy timeout-only resolver. Fencing also auto-disables for a batch if the snapshot query fails or thetxidprimitives are unavailable — degrading gracefully without panics.PgEventStore::with_table(andPgEventBusinit for a customevents_table) auto-migrates the custom table at startup via an idempotentADD COLUMN IF NOT EXISTS+SET DEFAULT+CREATE INDEX IF NOT EXISTS; on failure it logs awarn!and degrades to timeout-only for that table.- PostgreSQL 13+ is now the minimum supported version for the event bus
(uses the
pg_current_xact_id/pg_current_snapshotepoch-extended id APIs).
- Migration
-
Gap-timeout observability (
epoch_pg) — when a subscriber's checkpoint is advanced past a missingglobal_sequencedue togap_timeout, the event bus now:- Emits a
WARNlog withbus_name,subscriber_id,skipped_sequence, andgap_durationfor immediate operational visibility. - Persists a durable record to the new
epoch_event_bus_gap_timeoutstable (migration m009, fire-and-forget detached task — does not block checkpoint advancement). - Invokes the optional
on_gap_timeoutcallback (seeGapTimeoutCallback/GapTimeoutInfo) after a new record is persisted (exactly once per durable record), enabling metrics counters, alerting, and custom recovery actions.
- Emits a
-
GapTimeoutCallbacktrait andGapTimeoutInfostruct (epoch_pg) — the callback counterpart toDlqCallback; set viaReliableDeliveryConfig::on_gap_timeout. -
GapTimeoutEntrystruct (epoch_pg) — represents one row inepoch_event_bus_gap_timeouts, returned bylist_gap_timeouts. -
PgEventBus::list_gap_timeouts(subscriber_id, unresolved_only, offset, limit)— paginated query for gap-timeout records scoped to this bus; supports optional per-subscriber filter andunresolved_onlyflag. -
PgEventBus::resolve_gap_timeout(id, resolved_by, resolution_notes)— marks a gap-timeout record as resolved; returnstrueon success,falseif already resolved or not found (idempotent second call). -
Migration
m009_create_gap_timeout_log— creates theepoch_event_bus_gap_timeoutstable with aUNIQUE (bus_name, subscriber_id, skipped_sequence)constraint (making the recording insert idempotent), a sequence index, and a partial unresolved index. -
GapTimeoutCallback,GapTimeoutInfo,GapTimeoutEntryare re-exported fromepoch_pg(and transitively fromepoch). -
Integration tests can require a live database with
EPOCH_REQUIRE_DB=1(epoch_pg) — turns the graceful "skip when Postgres is unreachable" behaviour into a hard failure, for CI environments where skipping would mask misconfiguration. -
EventStoreBackend::read_last_event(stream_id)returning the most recent event of a stream (Option<Event>), with a default implementation and efficient overrides inPgEventStoreandInMemoryEventStore -
Command::with_causation_id(uuid)builder for threading causation when only the causing event's ID is available -
CheckpointMode::Batchedfor high-throughput scenarios with configurable batch size and max delay -
CheckpointMode::batched()andCheckpointMode::batched_default()helper constructors -
InstanceMode::Coordinatedfor multi-instance coordination using PostgreSQL advisory locks -
ProjectionHandler<P>wrapper type for subscribing projections to the event bus -
SagaHandler<S>wrapper type for subscribing sagas to the event bus -
Event::to_subset_event_ref()method for reference-based event conversion -
TryFrom<&D>implementation generated by#[subset_enum]macro for efficient reference-based conversion -
RefEventStreamtrait for reference-based event streaming (internal use) -
SliceRefEventStreamfor zero-copy event iteration from slices -
Projection::re_hydrate_from_refs()method for reference-based event stream processing -
Improved documentation for event ownership model
- BREAKING:
EventStoreBackend::store_events()no longer has a default implementation (epoch_core, CLOUD-171). It is now a required trait method. Third-party backends that previously relied on the (non-atomic) default loop must provide an explicit implementation. See the migration guide below. - BREAKING:
EventBus::publishnow takesArc<Event<T>>instead ofEvent<T> - BREAKING:
EventObserver::on_eventnow takesArc<Event<ED>>instead ofEvent<ED> - BREAKING:
Projection::apply_and_storenow takes&Event<ED>instead ofEvent<ED> - BREAKING:
Saga::handle_eventnow takes&Event<Self::EventType>instead ofEvent<Self::EventType> - BREAKING:
Saga::process_eventnow takes&Event<ED>instead ofEvent<ED> - BREAKING: Projections must now be wrapped in
ProjectionHandlerto subscribe to the event bus - BREAKING: Sagas must now be wrapped in
SagaHandlerto subscribe to the event bus - BREAKING:
Projection::EventTypenow requiresTryFrom<&ED, Error = EnumConversionError>instead ofTryFrom<ED> - BREAKING:
Saga::EventTypenow requiresTryFrom<&ED, Error = EnumConversionError>instead ofTryFrom<ED> InMemoryEventStorenow stores events asArc<Event<D>>internally for efficient sharing- Internal event conversion now uses
to_subset_event_ref()for better performance - Source-compat note:
ReliableDeliveryConfig(epoch_pg) gains the newon_gap_timeout: Option<Arc<dyn GapTimeoutCallback>>field (defaults toNone). Code that constructsReliableDeliveryConfigusing struct-literal syntax (rather than..Default::default()) must addon_gap_timeout: Noneto the literal. - Source-compat note:
ReliableDeliveryConfig(epoch_pg) gains the newsnapshot_fencing: boolfield (defaults totrue). Code that constructsReliableDeliveryConfigusing struct-literal syntax (rather than..Default::default()) must addsnapshot_fencing: true(orfalse) to the literal. - Source-compat note (
epoch_core, CLOUD-173):Event<D>andEventBuilder<D>gain a newschema_version: SchemaVersionfield (default1). Code that constructsEventorEventBuildervia struct-literal syntax (rather than the builder) must addschema_version: 1. Builder users (Event::builder()…build()) are unaffected. - Source-compat note (
epoch_pg, CLOUD-173):PgEventStoreErrorgains a newUpcast(epoch_core::upcasting::UpcastError)variant. Match arms that previously exhaustively matchedPgEventStoreErrormust addUpcast(_)(or use a wildcard arm).
- Blanket
EventObserverimplementation forProjection(replaced withProjectionHandler) EventObserversupertrait requirement fromSagatrait
- Eliminated unnecessary event cloning when publishing to multiple subscribers
- Events are now shared via
Arcinstead of cloned for each observer - Saga and projection event handlers receive references, avoiding clones
#[subset_enum]macro generatesTryFrom<&D>which only clones matched variant's fields instead of the entire enumAggregate::handlenow usesSliceRefEventStreamto avoid cloning events during internal re-hydration
read_events_range() is now a required trait method on EventStoreBackend. Any
third-party backend must add an implementation. The read_events and read_events_since
methods no longer need to be overridden — they are now default methods that delegate to
read_events_range.
async fn read_events_range(
&self,
stream_id: Uuid,
from: Option<u64>,
to: Option<u64>,
) -> Result<Pin<Box<dyn EventStream<Self::EventType, Self::Error> + Send + 'life0>>, Self::Error> {
// Implement bounded replay:
// - from = None means no lower bound (start from the first event)
// - to = None means no upper bound (read to the end of the stream)
// - Both inclusive: stream_version in [from, to]
// - from > to should return an empty stream (no error)
todo!()
}store_events() is now a required trait method. Any third-party backend that previously
relied on the removed non-atomic default loop must add an explicit implementation.
If your backend supports transactions (e.g. SQL), wrap all inserts in a single transaction and publish only after committing:
async fn store_events(
&self,
events: Vec<Event<Self::EventType>>,
) -> Result<(), Self::Error> {
if events.is_empty() {
return Ok(());
}
// Begin transaction, insert all events, commit, then publish.
// See `PgEventStore::store_events` for a complete example.
todo!()
}If your backend is in-memory or lock-based, validate all stream_versions first
(under the lock), then commit all events, then publish after releasing the lock:
async fn store_events(
&self,
events: Vec<Event<Self::EventType>>,
) -> Result<(), Self::Error> {
if events.is_empty() {
return Ok(());
}
// Hold lock: validate all versions, then write all events.
// See `InMemoryEventStore::store_events` for a complete example.
todo!()
}Verify your implementation using the portable contract test (add the testing
feature to your [dev-dependencies]):
[dev-dependencies]
epoch_core = { version = "0.1", features = ["testing"] }#[tokio::test]
async fn my_backend_satisfies_atomicity_contract() {
let backend = MyBackend::new();
epoch_core::testing::verify_store_events_atomicity(backend, |stream_id, version| {
Event::<MyEventType>::builder()
.stream_id(stream_id)
.stream_version(version)
// ... build event ...
.build()
.unwrap()
})
.await;
}// Before
event_bus.subscribe(my_projection).await?;
// After
use epoch::ProjectionHandler;
event_bus.subscribe(ProjectionHandler::new(my_projection)).await?;// Before (with manual EventObserver impl)
event_bus.subscribe(my_saga).await?;
// After
use epoch::SagaHandler;
event_bus.subscribe(SagaHandler::new(my_saga)).await?;// Before
async fn handle_event(
&self,
state: Self::State,
event: Event<Self::EventType>,
) -> Result<Option<Self::State>, Self::SagaError> {
match event.data {
// ...
}
}
// After
async fn handle_event(
&self,
state: Self::State,
event: &Event<Self::EventType>, // Now takes reference
) -> Result<Option<Self::State>, Self::SagaError> {
match &event.data { // Borrow the data
// ...
}
}// Before
async fn on_event(&self, event: Event<ED>) -> Result<(), ...> {
// use event
}
// After
async fn on_event(&self, event: Arc<Event<ED>>) -> Result<(), ...> {
// use &*event or event.field (auto-deref)
}Projection::EventType and Saga::EventType now require TryFrom<&ED, Error = EnumConversionError>
instead of TryFrom<ED>. This enables efficient reference-based conversion internally.
For subset enums generated by #[subset_enum], this is automatic - the macro generates both impls.
For identity conversions (where EventType == ED), add this impl:
impl TryFrom<&MyEvent> for MyEvent {
type Error = EnumConversionError;
fn try_from(value: &MyEvent) -> Result<Self, Self::Error> {
Ok(value.clone())
}
}The #[subset_enum] macro now generates TryFrom<&D> in addition to TryFrom<D>.
The framework internally uses to_subset_event_ref() for efficient conversion that
only clones matched variant fields instead of the entire enum.
You can also use it directly:
// Reference-based conversion (only clones matched variant's fields)
let subset_event = event.to_subset_event_ref::<SubsetEvent>()?;This release adds an opt-in upcasting mechanism for evolving persisted event schemas.
For projects that do not register any upcasters, behavior is unchanged — the only
visible change is a new nullable schema_version column in the database.
Step 1 — Run pending migrations (adds the schema_version column; idempotent):
epoch_pg::Migrator::run(&pool).await?;No backfill required. Pre-migration rows have schema_version = NULL, which the
read path interprets as version 1.
Step 2 — (only if you have breaking event-type changes) Write an upcaster, bump
the schema_version on the type, and wire the registry:
use epoch_core::upcasting::{Upcaster, UpcastContext, UpcastError, SchemaVersion,
UpcasterRegistry};
use epoch_pg::PgEventStore;
use serde_json::Value;
use std::sync::Arc;
// 1. Define the upcaster for OrderPlaced v1 → v2 (e.g., rename a field).
struct OrderPlacedV1ToV2;
impl Upcaster for OrderPlacedV1ToV2 {
fn event_type(&self) -> &str { "OrderPlaced" }
fn from_version(&self) -> SchemaVersion { 1 }
fn upcast(&self, _ctx: &UpcastContext<'_>, mut payload: Value)
-> Result<Value, UpcastError>
{
if let Value::Object(map) = &mut payload {
if let Some(v) = map.remove("amount") {
map.insert("total_amount".to_string(), v);
}
}
Ok(payload)
}
}
// 2. Build the registry.
let mut registry = UpcasterRegistry::new();
registry.register(OrderPlacedV1ToV2);
// 3. Wire it to the store.
let store = PgEventStore::with_upcasters(pool, bus, Arc::new(registry));Step 3 — Bump the schema version on your event type:
#[derive(Debug, Clone, Serialize, Deserialize, EventData)]
#[event_data(schema_version = 2)] // ← bump here
pub enum AppEvent {
OrderPlaced { total_amount: u64 },
}For the full change-kind matrix, worked examples, failure-policy guidance, and an operational checklist, see docs/schema-evolution.md.