Skip to main content

Crate tephra

Crate tephra 

Source
Expand description

A DCB-compliant, immutable event store with global ordering.

Tephra is a Dynamic Consistency Boundary (DCB) event store. Instead of a static consistency boundary baked into an aggregate, the boundary is derived per decision from a Query. Events carry an EventType plus a set of Tags, so one event can belong to several entities at once, and a decision reads exactly the events it depends on and guards exactly those on append (an AppendCondition).

This crate is the embedded engine: the durable log, the single writer, the index, and the read paths. Use it directly in-process, or reach it over the network with the tephra-server TCP server and the tephra-client client.

§Design

The log is the source of truth and everything else is derived. Data is written once, never updated and never deleted, keyed by a dense monotonic Position assigned by the single writer. Indexes need no write-ahead log and no fsync on the write path, because they can be rebuilt by replaying the log. The ARCHITECTURE.md document in the repository records the full rationale and the alternatives that were rejected.

§Example

use tephra::{
    AppendCondition, Event, EventType, Position, Query, QueryItem, SegmentConfig,
    SegmentSet, Tag, Tags, WriteCoordinator, WriterConfig,
};

// Open (or create) a log directory and start the single-writer coordinator.
let set = SegmentSet::open("tephra-data", SegmentConfig::new(256 * 1024 * 1024))?;
let (coordinator, handle) = WriteCoordinator::start(set, WriterConfig::default())?;

// Build a packed event, then append it guarded so it fails if course:c1 already exists.
let ty = EventType::new("CourseOpened")?;
let tags = Tags::new([Tag::new("course:c1")?])?;
let event = Event::new(&ty, &tags, br#"{"course":"c1","seats":30}"#)?;
let guard = AppendCondition::new(Query::item(QueryItem::with_tags(
    Tags::new([Tag::new("course:c1")?])?,
)));
handle.append(vec![event], Some(guard))?;

// Reads run on the caller's thread over a snapshot published at each commit. `read` returns
// a lending iterator, so it is consumed with `while let`, not a `for` loop.
let query = Query::item(QueryItem::with_tags(Tags::new([Tag::new("course:c1")?])?));
let mut reads = handle.read(&query, Position::ZERO, None);
while let Some(item) = reads.next() {
    let seq = item?;
    println!("{} {}", seq.position, seq.event.event_type());
}

// Shutdown joins the writer thread and flushes cleanly.
coordinator.shutdown();

§One writer per directory

A data directory takes one writer at a time, and that is now enforced: opening a SegmentSet read-write takes a lock on a LOCK file in the directory and holds it for the set’s lifetime. A second writer, in this process or any other, is refused with LogError::Locked rather than quietly corrupting the log. The kernel releases it however the process exits, so a leftover LOCK file blocks nothing, and because it is a POSIX record lock rather than a descriptor-based one, a forked child does not inherit it.

§Reading from a second process

Follower opens the same directory read-only and tracks a live writer, without creating, deleting or writing anything and without taking any lock. It hands out an ordinary ReadHandle, so queries, backward reads and subscriptions all work as they do against a writer.

use std::sync::Arc;
use std::time::Duration;
use tephra::{Follower, FollowerConfig, SegmentConfig};

let follower = Follower::open(
    "tephra-data",
    FollowerConfig::new(SegmentConfig::new(256 * 1024 * 1024)),
)?;

// Advance to whatever the writer has committed, then read at that tip.
let tip = follower.refresh()?;
println!("following up to {tip}");

// Or let a background thread advance it, which is what makes subscriptions work.
let follower = Arc::new(follower);
let _poller = follower.poll_every(Duration::from_millis(10));

What a follower sees is always a committed prefix: gap-free, duplicate-free, and only growing. It is not a durability oracle, though, and it lags. See the follow module docs for the full argument and the caveats before relying on one.

Re-exports§

pub use event::Event;
pub use event::EventRef;
pub use follow::Follower;
pub use follow::FollowerConfig;
pub use follow::FollowerError;
pub use follow::FollowerPoller;
pub use follow::PollerHealth;
pub use log::set::PositionRange;
pub use log::set::Refreshed;
pub use log::set::SegmentConfig;
pub use log::set::SegmentSet;
pub use query::Matches;
pub use read::DEFAULT_MAX_BATCH_EVENTS;
pub use read::ReadConfig;
pub use read::ReadError;
pub use read::ReadHandle;
pub use read::Subscription;
pub use read::WaitOutcome;
pub use writer::AppendError;
pub use writer::ConflictClause;
pub use writer::ConflictSite;
pub use writer::WriteCoordinator;
pub use writer::WriteHandle;
pub use writer::WriterConfig;

Modules§

event
Event codec.
follow
Reading a store that another process is writing.
index
Layer 3: the derived index.
log
query
Query match predicate over an encoded event.
read
Layer 5: off-thread read paths.
writer
Layer 2: the write coordinator.

Structs§

AppendCondition
The guard on an append call: two independent checks, OR’d, so the append is rejected if either fires.
EventType
An event type. An arbitrary opaque, non-empty string (the spec never parses it), stored as an exact-sized Box<str>.
Position
QueryItem
One alternative in a Query: a type constraint AND a tag constraint.
Tag
A tag, e.g. course:c1. An arbitrary opaque, non-empty string (the spec does not split it into key/value), stored as an exact-sized Box<str>. Like EventType, it has no Deref; use as_str, AsRef<str>, or Display.
Tags
A sorted, duplicate-free set of tags.

Enums§

NameError
Error constructing an EventType or Tag.
Query
A query: a set of QueryItems OR’d together, or the catch-all Query::All.
TagsError
Error constructing Tags.

Constants§

MAX_NAME_LEN
Maximum length, in bytes, of an EventType or Tag. Each is stored with a fixed-width u16 length in the engine’s encoded header, so the field capacity is the limit.