Expand description
The executable conformance kit for mnesis-store adapters — and the guide
to writing one (issue #281).
Every store adapter (mnesis-inmemory, mnesis-fjall, mnesis-postgres,
and any future one) implements the same seam:
RawEventStore +
WakeSource, optionally AtomicAppend
and SnapshotStore. That seam has
a contract that goes well beyond “the trait compiles” — inclusive vs.
exclusive read bounds, optimistic-conflict rejection, subscription
catch-up→live ordering, concurrent-writer linearizability. This crate
pins that contract as runnable checks, organized into four
cross-cutting categories (CLAUDE.md rule 7) plus two opt-in capability
modules:
sequence— multi-step protocol: append/read round-trips, optimistic conflict + retry,$allordering and resume, subscription catch-up-then-live.boundary— defensive inputs: version gaps, wrong first version, metadata absent vs. present, max-length event types, prefix-colliding stream ids.linearizability— concurrent writers: single-winner on a contended stream, all-land on distinct streams, wake-after-idle, theCaughtUp-boundary race.lifecycle— close → reopen (persistent adapters only): events, the$allwatermark, and conflict state all survive a reopen.atomic(featureatomic-append) —AtomicAppend: multi-stream commits are all-or-nothing.snapshot(featuresnapshot) —SnapshotStore: state and position commit/hydrate atomically together, and a schema bump reads backStale, never decode garbage.
The rest of this page is the writing-a-store-adapter guide. Sections
1–4 restate the seam’s contract in one place (the trait docs in
mnesis-store remain the normative source; follow the links); section 5
shows how to prove an implementation with the kit; section 6 lists the
pinned ambiguities that trip up new adapters. It assumes no knowledge of
the shipped adapters — you never need to read mnesis-fjall or
mnesis-postgres source.
§What you implement
One store type implements two mandatory traits.
RawEventStore — bytes in, bytes
out. The adapter never sees typed events or codecs; the repository facade
encodes into PendingEnvelopes
before calling you. You supply:
type Error— your own error type, boundcore::error::Error + Send + Sync + 'static. Keep it distinct frommnesis-store’s facade error types; the facade wraps yours, and a shared type would double-wrap.type Stream— the per-stream read cursor: an owned,'static,Sendfutures::StreamwithItem = Result<PersistedEnvelope, Self::Error>(theEventStreammarker bound).type AllPosition— your store-local$allresume position: anyCopy + Ord + Send + Sync + Debug + 'statictype implementingAllPosition. A scalar sequence for an embedded store, a commit-ordered composite for a concurrent SQL store. It is never carried on the envelope; it rides only on$allitems.mnesis-storeships no scalar impl, and the orphan rule blocksimpl AllPosition for u64in your crate — define a local newtype:struct MyPos(u64); impl AllPosition for MyPos {}(plus the derives the supertraits need).type AllStream— the all-streams read cursor: an owned,'static,Sendfutures::StreamwithItem = Result<(Self::AllPosition, StreamKey, PersistedEnvelope), Self::Error>— every item is tagged with its position AND the originStreamKey(stream attribution is a store guarantee — a$allconsumer routes without decoding the payload). The per-streamread_streamdoes NOT stamp the id: there it is the query argument, so re-stamping it on every item would be redundant.- Make both stream types
Unpin. The trait imposes no such bound, but the subscription path (Subscription) requires it. - Three methods —
append,read_stream,read_all— whose contracts are the next two sections.
WakeSource — how live subscriptions
learn that a commit landed. See “The wake contract” below.
Optional capability traits, each with its own kit module:
AtomicAppend(atmnesis_store::import::AtomicAppend, behindmnesis-store’simportfeature) — commit several per-stream runs in one transaction, all-or-nothing; the primitive bulk import needs. Each write’sexpected_versionis validated against the target’s running head (counting earlier writes to the same target inside the batch); any mismatch aborts the whole transaction withAtomicAppendError::Conflict { index, actual }, and on any failure no write is applied. On success return the highestAllPositionthe transaction committed across every stream (Option,Noneiffwritesis empty) — the whole-batch read-your-writes token; unlikeappend, an emptywritesis a legitimate no-op here, henceOption.SnapshotStore<Vec<u8>, P>— atomic persistence of derived state plus the position it was folded to (hydrate/commit). Byte-level:S = Vec<u8>; typed state is a codec bridge upstream, not your concern. Two useful instantiations:P = Version(aggregate snapshots) andP =yourAllPosition(projection checkpoints).hydratereturns the three-stateHydrated—Absent(never saved),Stale(saved under a different schema version; the caller rebuilds),Found(position + state). State and position commit together: the trait has no “save state alone”, and your implementation must persist the pair atomically so a half-write is unrepresentable.
Consumers never call you directly — they go through the Store<S>
handle, repositories, and subscriptions, all generic over the seam.
Implement the traits and everything above them works.
§Storing an event
append hands you a PendingBatch — a
non-empty run of PendingEnvelopes (batch.first(), batch.last(),
batch.iter(), batch.len() which is NonZeroUsize) — and reads must hand back
PersistedEnvelopes. The
supported recipe is the canonical wire frame: persist, per event, the
Version (from PendingEnvelope::version()) plus the output of
encode_frame:
let frame = mnesis_store::wire::encode_frame(
env.schema_version_value(),
&env.event_type_value(),
&env.payload_value(),
env.metadata_value().as_ref(),
)?; // EncodedFrame { value: Bytes, offsets: FrameOffsets }Store frame.value (one contiguous Bytes buffer), frame.offsets,
and the SchemaVersion; on read, rebuild with
PersistedEnvelope::try_new
(version, value, schema_version, offsets.event_type, offsets.payload, offsets.metadata). The frame lands the payload on a 16-byte boundary
inside the buffer — an invariant zero-copy codecs (rkyv, POD) rely on. A
custom storage layout is allowed, but then payload alignment and field
re-validation are on you.
§The append contract
append(id, expected_version, envelopes)
is optimistic concurrency:
expected_versionis the stream head the caller last saw:None= a fresh stream with no events,Some(v)= the head is exactlyv. Compare it against the stream’s actual current head; on mismatch returnAppendError::Conflictcarrying the stream id, the caller’s expectation, and the actual head — the caller reloads fromactualand retries. The diagnostic id field ismnesis::ErrorId, built truncation-aware from the key’sDisplay:stream_id: ErrorId::from_display(id).- The head check and the event insertion must be one atomic step (a transaction, CAS, or a lock). A check-then-insert with a window between lets a concurrent writer slip in and corrupt the stream; the kit’s linearizability checks race real writers at exactly this seam.
- Envelope versions must run strictly sequentially from
expected_version + 1(from1whenNone). A gap, duplicate, or out-of-order batch is rejected in theConflictdomain — and nothing lands: a rejected append leaves the store byte-identical, per-stream and$allalike. In thatConflict,expectedis the caller’s stated expectation andactualis the store’s current head — the fields describe the head disagreement, never the malformed batch. - Stamp every accepted event with the next
AllPosition: strictly monotonic across all streams in commit order, not required to be gapless — an aborted append may burn positions, and readers tolerate the gaps. - Return the
AllPositionyou stamped on the run’s LAST event. That is the read-your-writes token: a caller awaits its$allconsumer reaching this position and then knows its own write is visible there. Return the position the committing transaction actually assigned — not a counter read back afterwards, which a concurrent append could make disagree. BecausePendingBatchis non-empty there is always exactly one position to return; there is no write-nothing case (that is why the input is aPendingBatch, not a slice — the empty case is answered once, at the type, instead of by every adapter). - After the commit is durable — never before — fire your wake path (see “The wake contract”).
§The read contract
Two read methods, deliberately asymmetric.
read_stream(id, from)
— a bounded scan of one stream:
fromis inclusive: yield every event withversion >= from, in ascendingVersionorder, then terminate withNone.- An absent stream is an empty stream, never an error.
- After
Nonethe stream staysNone(fused) — the kit polls again to prove it. - Internal batching/pagination is allowed and must be invisible; bounding resident memory is your concern (fjall, for instance, holds one lazy LSM cursor rather than fixed-size batches).
read_all(from: Option<AllPosition>)
— a bounded scan across all streams:
fromis exclusive:None= from the very beginning,Some(p)= strictly afterp. Yield in ascending position order, each item tagged(position, stream key, envelope), then terminate withNonewhen caught up.- Resume is
Ord-based: the subscription loop reopens with the last position it delivered, and there is deliberately no successor function. Your scan must read “strictly greater thanfrom” — tolerating gaps by scanning a range, never by stepping+1.
The asymmetry (inclusive Version vs. exclusive AllPosition) is
intentional: a single stream’s versions are a gapless successor sequence,
so the resume seam computes v + 1 itself and asks inclusively — while a
composite $all position (e.g. postgres’s transaction-ordered pair) has
no natural +1, so resume must be “strictly after what I saw”. Both
reads serve the same strict-after resume; the difference is who computes
the successor.
What a scan opened before a concurrent commit observes is adapter-unspecified — see “Contract notes”.
§The wake contract
A live subscription is a catch-up-then-park loop; the loop itself ships
generically in mnesis-store and works for any adapter. Your half is
WakeSource: two methods and one
call-site discipline.
register(stream: Option<&[u8]>)— called once, synchronously, when a subscription opens (Noneregisters for$all). Return aWakeRegistrationthat keeps wake-routing alive until dropped.arm— returns an owned'staticfuture. Contract: the future captures a “seen point” at the momentarmis called and resolves once a wake is delivered after that point — a wake landing betweenarmand the.awaitmust NOT be lost. The generic loop arms before its confirming re-scan whenever it thinks it is caught up; that ordering plus your arm-time capture is the entire lost-wakeup defense.wake(stream)— call after every durable commit tostream, never before (a woken subscriber immediately re-reads and must see the data). A per-stream commit is also an$allevent:$allobservers must be woken too.- Spurious wakes are permitted — each costs one empty re-scan. Lost wakes
are not — a lost wake is a subscription hung forever, and the kit’s
check_wake_after_idleandcheck_caught_up_boundary_raceexist to catch exactly that.
In-process adapters should not build this machinery: embed
mnesis_wake::StreamNotifiers and delegate — the exact shape
mnesis-inmemory and mnesis-fjall ship:
use std::sync::Arc;
use mnesis_store::wake::WakeSource;
use mnesis_wake::{NotifyError, StreamNotifiers, WakeReg};
struct MyStore {
// ... your storage ...
notifiers: Arc<StreamNotifiers>, // StreamNotifiers::new() -> Arc<StreamNotifiers>
}
impl WakeSource for MyStore {
type Registration = WakeReg;
type Error = NotifyError;
fn register(&self, stream: Option<&[u8]>) -> Result<WakeReg, NotifyError> {
self.notifiers.register(stream)
}
fn wake(&self, stream: &[u8]) {
self.notifiers.wake(stream); // per-stream subscribers + the `$all` generation
}
}…and at the end of a successful non-empty append, after the commit is
durable: self.notifiers.wake(id.as_ref()); (one call — it wakes the
stream’s subscribers and bumps the store-wide $all generation).
A distributed adapter implements the same two traits over its own signal
(postgres: LISTEN/NOTIFY).
§Running the kit
An adapter proves conformance by invoking the conformance! macro (and
the capability macros it needs) once, from one test file. Each generates
one named #[tokio::test] per check, so nextest reports every contract
rule as its own test — a failure names the exact rule that broke, not
“some test in the suite.” Dependencies you’ll need: tokio with
macros + rt-multi-thread (plus sync/time if your adapter uses
tokio primitives), thiserror for your error enum (workspace rule), and
mnesis-wake for the in-process WakeSource.
§The factory contract
Every macro takes a factory: Fn() -> Fut where
Fut: Future<Output = (S, C)> + Send, S: RawEventStore + WakeSource.
C is an adapter-chosen guard kept alive for the check’s duration —
a TempDir for fjall, () for an adapter that owns its storage outright
(in-memory, a connection pool). The factory is called once per generated
test and must produce a fresh store each call; checks never share
state across tests.
mnesis_store_testing::conformance! {
factory: || async { (InMemoryStore::new(), ()) },
}§Capability and lifecycle macros
conformance_atomic_append!—AtomicAppendchecks; requires theatomic-appendfeature and anS: AtomicAppendfactory.conformance_snapshot!—SnapshotStorechecks; requires thesnapshotfeature, anS: SnapshotStore<_, P>factory, and two pairs of ascending samplePpositions:positions(ordinary values) andextremes(the representable edges, proving the position codec has no off-by-one at either end).conformance_lifecycle!— close/reopen checks against the SAME backing storage; skipped entirely by in-memory adapters (nothing to reopen), run by every persistent adapter (fjall, postgres). Takes two closures:open(the usual factory shape) andreopen(Fn(S, C) -> Fut<Output = (S, C)>), which consumes the prior pair so it can drop the store before reopening the same storage.
§skip_unless: for environment-gated adapters
Every macro accepts an optional skip_unless: <fn() -> bool> that guards
each generated test: when it returns false the test returns
immediately (a vacuous pass, not a failure). mnesis-postgres uses this
to skip the whole matrix when DATABASE_URL is unset locally, while
still running for real under the nixosTest CI attribute that supplies a
live database.
§Contract notes
Ambiguities pinned during this work, load-bearing for anyone writing a new adapter:
- Read visibility under concurrent append is adapter-unspecified.
Whether a reader started before a concurrent commit observes it is not
part of the contract —
FjallStorepins one snapshot at scan-open (repeatable-read),InMemoryStorere-reads live state on each refill. Both are conformant; the kit asserts eventual convergence (every committed event is observed once the reader catches up), never mid-flight visibility. GlobalSeq/$allpositions are strictly monotonic but never gapless. An aborted append may burn a position with no event landing there. Checks assert strict ordering, never contiguity.- Empty stream ids are a permitted adapter limitation. fjall/LSM rejects an empty key, so the kit only ever constructs non-empty stream ids — an adapter is not required to support the empty id.
Some(empty)metadata is unrepresentable by construction. The envelope’s metadata value type rejects a zero-lengthSomeat construction (ValueError::MetadataEmpty) — the wire format reservesu32::MAXas the absent-metadata sentinel, so “empty but present” would collide with “absent”. The boundary check ischeck_metadata_absent_vs_present_distinct, not …_vs_empty_.- Public error enums are
#[non_exhaustive].AppendError,AtomicAppendError, and their siblings may grow variants without a major bump — match the variant you handle (Conflict) plus a wildcard arm, never exhaustively.
Re-exports§
pub use row::ConformanceRow;pub use row::SubId;
Modules§
- atomic
AtomicAppendcapability conformance: several per-stream runs commit in ONE transaction — all land or none do.- boundary
- Defensive Boundary conformance: inputs that violate the append protocol must be rejected cleanly and completely — nothing lands, nothing corrupts — and legal-but-extreme values must round-trip.
- lifecycle
- Lifecycle conformance (opt-in): close → reopen must preserve events,
versions, and the
$allposition watermark. In-memory adapters have nothing to reopen and skip this module. - linearizability
- Linearizability/Isolation conformance: genuinely-overlapping writers and a
parked subscriber. Real overlap via
tokio::spawn+Barrier(CLAUDE rule 8 — never sequential-then-check). - row
- Shared test-data row, id type, and drive/drain helpers used by every conformance module.
- sequence
- Sequence/Protocol conformance: multi-step interactions on one store —
append→read round-trips, optimistic-conflict protocol,
$allordering and resume, and the subscription catch-up→live protocol. - snapshot
SnapshotStorecapability conformance: state and position commit and hydrate together; a schema change reads back asStale, never as decode garbage.
Macros§
- conformance
- Run the full core conformance matrix (sequence + boundary + linearizability) against a store factory.
- conformance_
atomic_ append - Run the
AtomicAppendcapability conformance (featureatomic-append). - conformance_
lifecycle - Run the lifecycle conformance (persistent adapters only):
openyields a fresh(store, ctx);reopenconsumes both and reopens the SAME storage. - conformance_
snapshot - Run the
SnapshotStorecapability conformance (featuresnapshot).