lazily
Lazy reactive primitives for Rust — Context, Slots, Cells with automatic dependency tracking and cache invalidation.
Overview
lazily provides five core primitives for reactive computation:
- Context — owns all reactive state and manages the dependency graph
- Slot — a lazily-computed cached value that automatically tracks dependencies
- Cell — a mutable value that invalidates dependent Slots when changed
- Signal — an eager derived value that recomputes the instant a dependency invalidates, with no intermediate unset value
- Effect — a side-effect callback that automatically reruns after tracked dependencies invalidate
Values are lazy by default: dependents are marked dirty on invalidation but only validated or recomputed when accessed. When you need eager push-style semantics — recompute immediately, observe v1 -> v2 with no unset window — reach for Signal, which layers a puller effect over a memoized slot. The Slot -> Cell -> Signal progression lets you choose lazy or eager per derived value within one graph.
ctx.computed() cells are guarded (T: PartialEq): if recomputation produces the same value, downstream dirty caches and effects are left alone. There is no unguarded mode.
Multiple updates can be grouped with ctx.batch(...) so invalidation and effect reruns happen once after the outermost batch exits.
Feature Set
Coverage by feature family across every binding, generated from
coverage.json in lazily-spec. Legend: ✅ shipped · ~ partial · — absent · ⊘ not applicable. The
canonical matrix with per-cell notes and platform carve-outs lives in
lazily-spec § Cross-Language Coverage.
Summary — family × language
| Family | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ | C# | GDScript |
|---|---|---|---|---|---|---|---|---|---|---|
| Reactive graph | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ~ |
| Materialization | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Family sync | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Statecharts | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Keyed collections | ✅ | ✅ | ✅ | ✅ | ✅ | ~ | ✅ | ✅ | ✅ | — |
| Reactive queue | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Broadcast topic | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Work queue | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| CRDT data types | ✅ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ✅ | — |
| Lossless tree | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Egress | ✅ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | — |
| Ingress | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Wire codec | ✅ | ✅ | ✅ | ✅ | ~ | ✅ | ✅ | ✅ | ✅ | — |
| Transport & FFI | ✅ | ✅ | ✅ | ~ | ~ | ✅ | ✅ | ~ | ✅ | — |
| Message passing | ✅ | ✅ | ✅ | ✅ | ✅ | ~ | ✅ | ✅ | ✅ | — |
| Reliable sync | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | — |
| Distributed plane | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Causal receipts | ~ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Security boundary | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Membership | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Coordination | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Presence | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Temporal | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Rate shaping | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Windowing | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Resilience | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Portable stdlib | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Service plane | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Instrumentation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
Roll-up rule: a family cell is ✅ only when every required row in that family is ✅; ~ when the family is mixed (some shipped or partial); — when no required row is shipped or partial; ⊘ only when every required row in the family is not applicable. Rows the spec marks MAY (optional) are excluded from the roll-up — declining an optional feature is not a gap.
A family cell summarises 74 feature rows. For row-level marks, per-cell notes, and platform carve-outs see the canonical coverage matrix in lazily-spec.
CRDT convergence and the wire protocol are pinned by the shared conformance fixtures
and JSON Schemas in lazily-spec and the Lean models in
lazily-formal.
Development
Minimum supported Rust version (MSRV): 1.88 — declared via rust-version in Cargo.toml. The crate uses let_chains (stabilized in 1.88) pervasively.
Run the local CI-equivalent suite with:
make check
The Makefile also exposes focused targets such as make test-tokio,
make test-loom, make benchmark-evidence, make benchmark-check, and
make benchmark-update.
make check measures the benchmark budgets rather than assuming them: it runs
benchmark-evidence (a reduced-sample Criterion pass over the budgeted groups,
plus the deterministic instrumentation profile) and then benchmark-check, which
fails if that evidence is absent or if it measured a different source tree than
the one checked out. There is no “budgets skipped” path that still exits 0.
Usage
#![allow(unused)]
fn main() {
use lazily::Context;
let ctx = Context::new();
// Create a mutable cell
let counter = ctx.source(0i32);
// Create a derived value (automatically tracks dependencies)
let doubled = ctx.computed(|ctx| {
let val = counter.get(ctx);
val * 2
});
assert_eq!(doubled.get(&ctx), 0);
// Mutate the cell — dependents are marked dirty (not recomputed yet)
counter.set(&ctx, 5);
// Slot recomputes lazily on next access
assert_eq!(doubled.get(&ctx), 10);
// Effects run immediately and then after tracked dependencies change
let effect = ctx.effect(move |ctx| {
println!("counter = {}", counter.get(ctx));
});
counter.set(&ctx, 6); // schedules and runs the effect once
effect.dispose(&ctx); // unsubscribes and prevents future reruns
// Batch writes coalesce invalidation and effect reruns.
ctx.batch(|ctx| {
counter.set(ctx, 7);
counter.set(ctx, 8);
});
}
Lossless CRDT documents and durable replay
CrdtTree is the shared document contract for identity-preserving merge,
version-vector deltas, and materialized values. A snapshot is deliberately the
same operation as delta_since an empty frontier, so full hydration and
incremental synchronization cannot drift into separate semantics. TextCrdt
implements the contract, and downstream document CRDTs can implement it without
depending on a storage backend.
Reliable senders use Outbox<S> for one append/ack/prune/replay protocol and an
OutboxStore for five ordered-byte persistence operations. InMemoryStore
exercises the same path in tests; the durable-sqlite feature adds
SqliteStore/SqliteOutbox, partitioned by document hash, so acknowledged
epochs remain pruned across process restarts.
Decorator-style typed factories
#[lazily::source] and #[lazily::computed] provide the same factory style as
lazily-py: the factory takes only a typed context and Lazily memoizes the
source/computed handle on that context. ctx.get(factory) reads a memoized cell,
and ctx.set(source_factory, value) mutates a memoized source cell.
(#[lazily::cell] / #[lazily::slot] remain as deprecated v1 aliases.)
This example is covered by tests/decorator_factories.rs.
#![allow(unused)]
fn main() {
use lazily::TypedContext;
lazily::define_schema!(CounterSchema);
type CounterContext = TypedContext<CounterSchema>;
#[lazily::source]
fn counter(_ctx: &CounterContext) -> i32 {
0
}
#[lazily::computed]
fn doubled(ctx: &CounterContext) -> i32 {
ctx.get(counter) * 2
}
let ctx = CounterContext::new();
assert_eq!(ctx.get(doubled), 0);
ctx.set(counter, 5);
assert_eq!(ctx.get(doubled), 10);
}
define_schema! intentionally creates a concrete, uninhabited marker type for
stable Rust 2024. It is “opaque” in the everyday sense that user code should not
construct or inspect values of it; Lazily uses only the type identity to prevent
mixing handles from different context families. Rust nightly’s
#[define_opaque] for type Alias = impl Trait is a separate unstable compiler
feature for hidden concrete return types, and is not needed for Lazily context
schemas.
Actor recipe (mailbox + RPC)
An actor — private state that talks only through messages — falls out of two
primitives: a QueueCell mailbox the actor drains, and correlation-by-id for
request/response RPC. No thread, no polling loop, no async runtime.
- Mailbox:
QueueCell<Request>. A push flips theis_emptyreader empty → non-empty, which reruns the actor’s drain effect; the single-threaded scheduler flushes effects synchronously, so the message is handled by the timesendreturns. - RPC (request → response): each request carries a correlation
id; the actor answers on a shared outboxQueueCell<Reply>and the caller pops the reply whose id matches. Correlating by id (rather than embedding a reply queue in each message) keeps every payloadPartialEq + Clone—QueueCell<T>’s bound on its element. - Fire-and-forget: a request with no reply is pure message passing — the actor mutates its private state and returns nothing.
- The actor’s own state lives in a plain
Cell, deliberately outside the reactive graph, so the drain effect subscribes to the mailbox alone.
#![allow(unused)]
fn main() {
use std::cell::Cell as StdCell;
use std::rc::Rc;
use lazily::{Context, QueueCell};
let ctx = Context::new();
let mailbox: QueueCell<(u64, i64)> = QueueCell::new(&ctx); // (id, delta); id 0 == "report"
let outbox: QueueCell<(u64, i64)> = QueueCell::new(&ctx); // (id, total)
let total = Rc::new(StdCell::new(0i64)); // private actor state
// Drain effect: wakes on every push, drains to empty, answers reports on the outbox.
let _drain = {
let (mailbox, outbox, total) = (mailbox.clone(), outbox.clone(), Rc::clone(&total));
ctx.effect(move |ctx| {
while !mailbox.is_empty(ctx) {
let Ok((id, delta)) = mailbox.try_pop(ctx) else { break };
if id == 0 {
total.set(total.get() + delta); // fire-and-forget
} else {
let _ = outbox.try_push(ctx, (id, total.get())); // RPC reply
}
}
})
};
mailbox.try_push(&ctx, (0, 5)).unwrap(); // handled synchronously on push
mailbox.try_push(&ctx, (0, 3)).unwrap();
mailbox.try_push(&ctx, (7, 0)).unwrap(); // request a report, id 7
assert_eq!(outbox.try_pop(&ctx).unwrap(), (7, 8));
}
The full runnable version — a typed CounterActor with a send/get API and
id-matched reply dispatch — is in
examples/actor_rpc.rs (cargo run --example actor_rpc). For a distributed actor whose messages cross a process boundary
with causal-receipt delivery guarantees, project this same shape onto the
command/RPC plane (CommandRpcClient / CommandTransport in src/command.rs,
feature ipc).
Competing-consumer work queue
WorkQueueCell is the pull-based local-authority work queue: claim hands the
oldest pending item to exactly one worker under a fresh delivery ID; only that
worker can ack or nack it. Unacked leases redeliver after their strict
visibility deadline, and items reaching max_deliveries move to the DLQ.
#![allow(unused)]
fn main() {
use lazily::{Context, WorkQueueCell};
let ctx = Context::new();
let work = WorkQueueCell::<String>::new(&ctx, 30, 3);
work.push(&ctx, "render-report".into());
let delivery = work.claim(&ctx, "worker-a".into(), 100).unwrap();
assert!(work.ack(&ctx, &"worker-a".into(), delivery.delivery_id));
}
The instance is the serialization point. A distributed/HA backend must put
claim behind its leader or consensus log; the local shell does not pretend to
provide cross-process consensus.
Why Lazy?
| Lazy (Slots) | Eager (Signals) | |
|---|---|---|
| When does recomputation happen? | On access (get) | Immediately on change |
| Wasted work | Zero — only compute what’s read | Can compute values nobody uses |
| Glitch-free | By construction | Requires topological sorting |
| Ordering | Irrelevant — pull-based | Critical — push-based DAG walk |
| Use case | Request handling, data pipelines | UI rendering, real-time updates |
In a web server handling requests, you might have 50 computed values available but any given request only uses 5. With eager reactivity, all 50 recompute on every change. With lazy, only the 5 actually accessed compute.
lazily defaults to lazy but does not force the choice on you: derive with ctx.computed() for pull-based laziness, or ctx.signal() for the eager column above (UI rendering, real-time mirrors, always-materialized values). Both share the same context, dependency graph, glitch-freedom, and equality guard — pick per value.
Core Concepts
Context
Context owns all Slots and Cells. It manages the dependency graph and provides the API for creating, reading, and mutating reactive values. Think of it as the “world” for your reactive computations — in web frameworks, this maps to a request context, application scope, or component tree.
The current Context is intentionally single-threaded. It uses RefCell and
non-Send callback storage to keep the fast path allocation-only and mutex-free.
Create independent contexts per OS thread for local graphs, or use
ThreadSafeContext when one reactive graph must be shared across threads.
Slot
A Computed<T> wraps a compute function Fn(&Context) -> T. The result is cached after first access. Dependencies are discovered automatically via a thread-local tracking stack — any Slot or Cell accessed during computation becomes a dependency. ctx.computed() is the derived-value constructor and is guarded (T: PartialEq): equal recomputations suppress downstream work. ctx.slot() is the bound-free storage-sense primitive (no guard, holds non-PartialEq values).
When a dependency is invalidated, the Slot marks its cached value dirty. It does not validate or recompute until ctx.get() is called again.
For ctx.computed() cells, if recomputation returns a value equal to the previous cache, downstream dirty Slots become fresh without recomputing, and scheduled effects that only depended on unchanged Slots skip cleanup/rerun.
Dependencies are dynamic. Every time a Slot recomputes, it re-discovers its dependencies from scratch. If your compute function has conditional branches that access different Cells depending on state, the dependency graph updates automatically. No stale subscriptions, no manual cleanup.
Cell
A Source<T> holds a mutable value. source.set(&ctx, value) and ctx.set() compare old and new values via PartialEq — if unchanged, no invalidation occurs. If changed, all dependent Slots are recursively marked dirty.
Signal
A SignalHandle<T> is an eager derived value — a derived construct, not a core primitive (Signal ≡ Computed.eager: a guarded computed cell plus a puller Effect). Where a Slot only marks itself dirty on invalidation and recomputes on the next read, a Signal recomputes the instant a dependency is invalidated, before the invalidating set/set/batch call returns. The value is always materialized, so observers never see an intermediate unset value — a dependency change drives the value directly from v1 to v2.
#![allow(unused)]
fn main() {
let n = ctx.source(1);
let doubled = ctx.signal(|ctx| n.get(ctx) * 2); // materialized now: 2
n.set(&ctx, 5); // doubled is already 10 — eager
assert_eq!(doubled.get(&ctx), 10);
}
A Signal is composed from existing primitives, not a parallel engine: a guarded computed cell (ctx.computed) supplies glitch-free, pull-based, equality-guarded recomputation, and a small puller Effect re-materializes that slot after every invalidation to supply the eagerness. Consequently a Signal inherits the equality guard (an equal recompute suppresses downstream work) and diamond glitch-freedom (D = f(A, g(A)) never surfaces a mixed new-A/old-g(A) intermediate), and batched writes settle to one consistent recomputation at batch exit.
ctx.signal() requires T: PartialEq + 'static (the equality guard); get_signal additionally requires T: Clone. signal.dispose(&ctx) removes the eager puller — the value stays readable but reverts to lazy (recompute-on-read) behavior. The same primitive is available on ThreadSafeContext (signal, returning a Send + Sync ThreadSafeSignalHandle<T>) and AsyncContext (signal_async, with a non-blocking get_signal snapshot and an awaiting get_signal_async); see SPEC.md for the per-context type bounds and the async eagerness caveat.
Batch Updates
ctx.batch(|ctx| { ... }) groups multiple cell updates and explicit slot/cell clears into one invalidation pass. Nested batches flush only when the outermost batch exits. Direct ctx.get() reads inside the callback see the latest cell value immediately; changed-cell dependents are marked dirty after the batch, so Slot reads during the callback return their pre-batch cached value until the batch completes.
Effect
An Effect represents a side-effect callback registered with ctx.effect(). Effects run immediately, track any Slots or Cells read during that run, and rerun after those dependencies invalidate. Scheduled effect reruns are flushed after the invalidation pass, so diamond dependency paths coalesce to one rerun. Effects scheduled only by dirty Slot dependencies first validate those Slots and skip cleanup/rerun when values are unchanged.
Effects can return a cleanup closure. Cleanup runs before the next rerun and when the handle is disposed:
#![allow(unused)]
fn main() {
let effect = ctx.effect(move |ctx| {
let value = counter.get(ctx);
move || println!("cleanup for {value}")
});
effect.dispose(&ctx);
}
Durable effect sinks (#lzdurablesink)
Durable storage is an effect sink, not a transition authority. While a Lazily runtime is live, transitions are decided from live state; durable storage receives a projection or an ordered fact as an effect, and a sink MUST NOT reload storage to arbitrate the transition it is currently persisting. Authority flows one way:
cold durable state ──hydrate once──▶ live Lazily state
│ (computed / fact stream)
▼
Effect / AsyncEffect
▼
write-only durable sink
│ ack / failure
└────────▶ live Lazily state
- Projection (latest recoverable state): a
Computedread by anEffect/AsyncEffectdoes an idempotent upsert of the settled epoch. Lazily’s existing effect-batch coalescing means a batchA → B → Cpersists onlyC— correct for a current-state projection. - History (every accepted fact, ordered): use the existing
TopicCell/Outboxdrain — append, replay-from-cursor,ack_through— not ordinary effects. Do not modify effects to retain intermediate values; that would duplicate the ordered-stream primitives. - Acknowledgement: success advances a monotone
durable_through(epoch); a sink failure is represented in live state aspending/retrying/backpressuredand MUST NOT trigger a storage reload at the decision seam. - Markers: values on the
Ephemeralplane MUST NOT enter a durable sink — theEphemeral/Durablemarkers statically reject the mismatch (compile-fail doctest insrc/presence.rs). Cold loading and migration belong to a separate startup hydrator, not the runtime effect.
Lazily ships no storage backend for this — the sink is an application-owned
write-only trait (the OutboxStore boundary is the existing example). The
authority rule, the projection-vs-history shape table, the two reference examples
(coalesced projection; lossless ordered fact sink), and the caller-chosen
eventual_projection / durable_before_applied / ephemeral visibility
policies live in lazily-spec § Durable Effect Sinks;
the formal backstop is lazily-formal/LazilyFormal/DurableSink.lean.
API
| Method | Purpose |
|---|---|
Context::new() | Create a new context |
lazily::define_schema!(Name) | Define an uninhabited schema marker for TypedContext<Name> |
ctx.computed(|ctx| T) | Create a derived lazily-computed value |
ctx.slot(|ctx| T) | Create a lazily-computed slot; synonym of ctx.computed() |
ctx.memo(|ctx| T) | Create a lazily-computed slot with a PartialEq memoization guard |
ctx.memoized_slot::<Key, T, _>(|ctx| T) | Return a context-local factory slot handle, creating it on first use |
slot.get(&ctx) | Get value (computes if unset) |
ctx.get(&slot) | Context method alias for slot.get(&ctx) |
ctx.source(value) | Create a mutable cell |
ctx.memoized_cell::<Key, T, _>(|ctx| T) | Return a context-local factory cell handle, creating it on first use |
source.get(&ctx) | Get cell value |
ctx.get(&cell) | Context method alias for source.get(&ctx) |
ctx.set(&cell, value) | Update cell (marks dependents dirty if changed) |
source.set(&ctx, value) | Handle method alias for ctx.set(&cell, value) |
#[lazily::computed] fn name(ctx: &TypedContext<_>) -> T | Decorator-style typed computed factory over TypedContext |
#[lazily::source] fn name(ctx: &TypedContext<_>) -> T | Decorator-style typed source factory over TypedContext |
ctx.signal(|ctx| T) | Create an eager derived value (recomputes on invalidation, no unset window); T: PartialEq + 'static |
signal.get(&ctx) | Get the signal’s value (T: Clone); also ctx.get_signal(&signal) |
signal.dispose(&ctx) | Remove the eager puller; value reverts to lazy recompute-on-read |
signal.is_active(&ctx) | Check whether the eager puller is still registered |
ctx.batch(|ctx| { ... }) | Defer changed-cell dirty marking and explicit clears until the outermost batch exits |
ctx.effect(|ctx| { ... }) | Run an effect immediately and rerun it after tracked dependencies invalidate |
ctx.is_set(&slot) | Check if slot has a cached, fresh value |
slot.clear(&ctx) | Clear cached value and cascade to dependents |
cell.clear_dependents(&ctx) | Clear downstream slots without changing cell value |
effect.dispose(&ctx) | Dispose an effect and unsubscribe dependencies |
effect.is_active(&ctx) | Check whether an effect is still registered |
Lazily standard library
lazily::stdlib contains optional-convenience semantics built from the
portable primitives without moving runtime policy into the graph kernel. Its
Timer adapts the logical TimerCore to Rust’s monotone Instant: use
Timer::after / Timer::at, non-blocking poll / deterministic poll_at, or
the zero-async-runtime blocking wait.
Timeout<T> composes that timer with caller-owned operation and cancellation
probes. It latches typed completed, timed-out, cancelled, or unavailable
outcomes without owning a future, executor, or hidden thread. poll_at is the
deterministic clock seam; wait_with additionally accepts the caller’s clock
and wait policy so channels, condition variables, reactors, or test schedulers
can supply wakeups. The deadline is strict, while completion wins a
completion/cancellation race observed before it.
RevisionBarrier is the corresponding blocking coordination bridge. Producers
publish monotone revisions with advance; waiters require both a revision newer
than their captured after_revision and a derived predicate. Predicate checks
run outside the barrier lock and are retried if a revision or receipt
notification races them, closing the check-to-sleep lost-wakeup window. Optional
Timer and barrier-owned cancellation inputs produce typed satisfied,
timed-out, cancelled, disposed, or unavailable outcomes. Keyed effect-receipt
storage and transport remain application-owned; update that ledger and call
notify.
This layer deliberately does not add generic mutexes, semaphores, latches,
channels, or task groups: Rust already supplies local synchronization and Lazily
already supplies distributed LockCell, SemaphoreCell, BarrierCell, and
QuorumCell. New standard-library primitives should be admitted only when they
add a reactive revision or effect-lifecycle guarantee.
ThreadSafeContext
Enable the thread-safe feature (v0.18.0+, was default before):
cargo test --features thread-safe
ThreadSafeContext is the mutex-backed counterpart for sharing one reactive
graph across OS threads. It mirrors the core Context methods while requiring
Send + Sync + 'static values and compute/effect callbacks. The graph lock is
released before user compute callbacks, effect callbacks, or cleanup closures
run, so callbacks can re-enter the same context without deadlocking. If a slot
is invalidated while its callback is running, the stale result is discarded and
the getter retries before returning a fresh value.
Cell values use a read-scaling sidecar (v0.23.0+): ctx.source() reads take a
shared RwLock read (concurrent readers don’t serialize), and ctx.source_copy()
opts small Copy values into a wait-free inline seqlock — no heap allocation,
no refcount traffic on read. Both mirror the slot fast-path design.
The graph state lock is an RwLock (v0.24.0+, #lzstateinvalidation):
read_state() acquires a shared read lock, lock_state() an exclusive write
lock. All invalidation routes through the state-locked path — one lock for the
entire BFS pass, with atomics-only dirty marking (no per-node Mutex
acquisitions). This mirrors lazily-cpp’s single-recursive-mutex model: fewer,
coarser locks with a fast inner loop beat many fine-grained locks for reactive
fan-out workloads.
Design
- Lazy by default, eager on demand: Slots mark dirty on invalidation and validate/recompute on access;
ctx.signal()opts a value into eager recomputation (a guarded computed cell + puller-effect composition) with no intermediate unset state - Derived constructor:
ctx.computed()names guarded derived values while preservingctx.slot()as the bound-free storage-sense primitive - PartialEq guard:
Source::set()only invalidates when value actually changes - Guarded computed: every
ctx.computed()cell (T: PartialEq) compares recomputed values and suppresses downstream recomputation/effect reruns when values are equal — there is no unguarded mode - Dynamic dependencies: Edges re-discovered on each recomputation (no stale subscriptions)
- Batching: Multiple writes share one invalidation/effect flush boundary
- Effect scheduling: Effects rerun after dependency invalidation and coalesce duplicate schedules
- Slot-id-indexed contiguous node storage for the single-threaded fast path
- Interior mutability via
RefCell(single-threaded) - Thread-local tracking stack for automatic dependency discovery
- Zero mandatory runtime dependencies in the default library surface
- Optional
instrumentationfeature for benchmark counters, lock timing, and thread-safe lock attribution
Threading Roadmap
lazily-rs guarantees local, single-threaded Context graphs plus an explicit
ThreadSafeContext for shared graphs. Computed<T> and Source<T> are
Send + Sync when T is Send + Sync, and Effect is also Send + Sync,
but handles must be used with their owning context.
Enable the optional loom feature to run the thread-safe synchronization model:
cargo test --features loom --test thread_safe_loom
Enable the optional tokio feature for sync-on-Tokio integration tests and the
tokio_sync example (requires thread-safe since v0.18.0 — the integration
exercises ThreadSafeContext through tokio::spawn):
cargo test --features "tokio thread-safe"
cargo run --example tokio_sync --features "tokio thread-safe"
The feature proves ThreadSafeContext can be shared through tokio::spawn and
tokio::task::spawn_blocking. It does not add async computations or effects;
those need the separate AsyncContext design captured in SPEC.md, including
in-flight future deduplication, stale completion handling, cleanup ordering, and
separate Send versus LocalSet surfaces.
ThreadSafeContext intentionally keeps one state RwLock (v0.24.0+,
#lzstateinvalidation) while fresh cached slot reads use a per-slot read-mostly
cached-value sidecar. Dependency edges, dirty/revision state, cached-value
publication, batching, and effect queues all mutate under the state lock.
In-flight recompute waiters use per-slot generation Condvar sidecars so they
can park while the compute owner runs user code, and a completion only wakes
waiters for that finished slot. Changed-cell and slot-value invalidation build
an explicit frontier plan, then apply dirty flags, revisions, and effect
scheduling in one state-lock mutation boundary with atomics-only dirty marking.
The thread_safe_graph_propagation benchmarks compare fan-out eager validation,
fan-out/fan-in lazy dirty epoch publication, and fan-in batched flush behavior
with lock attribution. Sharded-lock or CAS variants should wait for lock
wait/hold benchmark evidence and a Loom or Shuttle safety model for stale
in-flight completion, invalidation during compute, dynamic dependency
cleanup/disposal, effect scheduling/disposal, and re-entrant callbacks. A
lock-free versioned optimistic read path is deferred until cached values can be
retained independently of graph-protected erased-value storage.
Benchmarks
See BENCHMARKS.md for full benchmark results, regression budgets, lock attribution, instrumentation profiles, and a cross-language comparison with lazily-cpp and lazily-zig.
For large-graph evidence, see the Scale (≥1M cells) section (a criterion-tracked scale group): a spreadsheet-shaped graph of ~2M nodes builds in ~0.13 s and fully recomputes from cold in ~0.10 s, while a single-cell edit + bounded viewport read recomputes only the viewport (~11.5 µs / 1,000 cells, ~5,000× cheaper than a full recalc).
Google Sheets scale (10,000,000 cells/workbook — the documented limit). Run at the full Sheets cap, lazily builds the whole workbook in ~0.7 s, recomputes it cold in ~0.5 s, and still does a viewport edit in ~11 µs (scale-independent). (Microsoft Excel’s 1,048,576 × 16,384 = 17,179,869,184-cell grid is capacity, not populated cells — lazily’s sparse arena only pays for populated cells, so the limit is populated-cells vs RAM, not the grid.)
A “cell count” here counts two cells per row — the benchmark models a column of formulas
=A_i + A_{i-1}, so each row is one input cellA_iplus one formula cell.Nrows ⇒Ninputs +Nformulas =2Ncells, matching how a real sheet mixes value cells and formula cells. (Each formula depends on two inputs, but is itself a single cell.) So “10M cells” = 5,000,000 inputs + 5,000,000 formulas.
cargo bench --features scale-bench --bench scale # default 1M (2M nodes)
LAZILY_SCALE_N=5000000 cargo bench --features scale-bench --bench scale # Google Sheets 10M cells
Multi-Language
lazily is implemented across three languages with shared semantics:
| lazily-rs | lazily-zig | lazily-py | |
|---|---|---|---|
| Context | Owned Context struct | Explicit allocator | Plain dict |
| Slot creation | Box<dyn Fn> closures | comptime function pointers | Lambdas |
| Cell equality | PartialEq trait | std.meta.eql | != operator |
| Thread safety | Single-threaded Context; explicit ThreadSafeContext | Mutex by default | GIL |
| Storage | Unified generics | .direct / .indirect | Object identity |
Cross-Channel Compatibility
The cross-language family should use one graph-state protocol across channels:
IpcMessage::Snapshot and IpcMessage::Delta. Rust FFI is viable as a narrow
C ABI adapter with opaque handles and owned byte buffers, not by sharing live
Rust contexts, closures, typed handles, or references across the ABI.
IPC, WebSocket frames, WebRTC data channels, and FFI byte buffers can then carry the same permission-filtered snapshots and deltas. Transport code owns framing, memory ownership, reliability, and back-pressure; lazily semantics stay in the shared message schema.
Enable the ffi feature for the C ABI adapter. It exposes an opaque
LazilyFfiChannel, JSON IpcMessage validation/classification helpers, and
Rust-owned LazilyFfiBytes buffers with an explicit free function. The adapter
re-encodes every accepted frame as canonical IpcMessage JSON, so FFI callers
share the same state plane as other channels.
Cross-Process Zero-Copy Transport (#lzzcpy)
A Snapshot / Delta / CrdtSync message may carry large payloads (an Arrow
record-batch, an image, a serialized sub-document). Copying those bytes through
the wire codec on every hop is the dominant cost of a distributed deployment.
The zero-copy transport instead spills a large payload to a blob backend
and ships a small ShmBlobRef descriptor; the receiver resolves the
descriptor against the same backend and reads the bytes in place — no copy, no
checksum recompute.
Spec: lazily-spec/docs/zero-copy-transport.md.
Formal: lazily-formal/LazilyFormal/ZeroCopyTransport.lean
— proves spill-then-resolve identity, backend isolation, ABA/generation safety,
and checksum integrity for any backend satisfying the contract.
#![allow(unused)]
fn main() {
use lazily::{
BlobBackend, BlobRouter, InProcessBackend, ArrowBackend,
spill_message, Delta, DeltaOp, IpcMessage, NodeId,
};
let mut inproc = InProcessBackend::new()?; // wraps ShmBlobArena (in-process)
let mut arrow = ArrowBackend::new()?; // holds Arrow IPC stream bytes
// Producer: spill large Inline payloads above a threshold.
let big = vec![0x5Au8; 500];
let mut msg = IpcMessage::Delta(Delta::next(1, vec![DeltaOp::slot_value(NodeId(7), big.clone())]));
let spilled = spill_message(&mut msg, &mut inproc, 64);
assert_eq!(spilled, 500); // payload replaced with a SharedBlob descriptor
// Receiver: resolve by routing the descriptor's `backend` discriminator.
let mut router = BlobRouter::new();
router.register(&inproc).register(&arrow);
// ...after decoding the wire message...
// let bytes = router.resolve(&payload); // zero-copy view into the backend
}
Three backends ship:
| Backend | Holds the bytes | Cross-process? | Feature |
|---|---|---|---|
InProcessBackend | wraps ShmBlobArena (single address space) | no | ipc |
ArrowBackend | Arrow IPC stream bytes (zero-copy columnar) | no | ipc |
ShmBackend | POSIX shm_open + mmap region | yes (same host) | shm |
The ShmBlobRef descriptor gained an optional backend discriminator
(BlobBackendKind::Shm | Arrow | InProcess), defaulting to Shm so
legacy descriptors validate unchanged. New backends (RDMA/verbs, CUDA IPC) plug
in by implementing the BlobBackend trait and adding a discriminator value — no
transport or codec change.
The lazily family
lazily is one reactive model implemented across many languages — the same cell kernel, the same keyed collections and CRDTs, and the same wire protocol — so peers written in different languages talk to each other without a translation layer.
lazily-spec— the language-agnostic wire protocol, the cross-language feature matrix, and the conformance corpus every binding replays.lazily-formal— the Lean 4 formal model every binding inherits its proofs from.
| Repo | Language |
|---|---|
lazily-rs | Rust — the reference implementation (you are here) |
lazily-py | Python |
lazily-go | Go |
lazily-kt | Kotlin / JVM |
lazily-js | JavaScript / TypeScript |
lazily-cs | C# / .NET |
lazily-cpp | C++ |
lazily-zig | Zig |
lazily-dart | Dart / Flutter |
lazily-react | React / Preact bindings layered over lazily-js (not a separate language binding) |
Per-binding parity, with per-cell notes and platform carve-outs, lives in
lazily-spec § Cross-Language Coverage
— it is generated from coverage.json, so it does not rot the way a hand-copied
table would.
Related
- lazily-spec — language-agnostic wire protocol + conformance fixtures shared by every binding
- lazily-formal — Lean 4 formal model (flat FSM kernel, full Harel state chart, reactive graph kernel, keyed collections, ordered tree, LIS reconciliation, async slot state) with universal proofs every binding inherits
- lazily-zig — Zig implementation with FFI support
- lazily-py — Python implementation with context-as-dict
- Blog post: Lazily — Reactive Primitives Done Right
License
MIT
lazily-rs Specification
Rust library for lazy evaluation with context-aware dependency tracking and cache invalidation. Counterpart to lazily-zig and lazily-py.
Core Concepts
Context
Container for all slots and cells. Owns all allocations via interior mutability
using a single RefCell<ContextInner>.
#![allow(unused)]
fn main() {
struct ContextInner {
nodes: Vec<Option<Node>>,
next_id: u64,
free_ids: Vec<u64>,
pending_effects: VecDeque<SlotId>,
scheduled_effects: HashSet<SlotId>,
flushing_effects: bool,
batch_depth: usize,
batched_cells: HashSet<SlotId>,
batched_cell_clears: HashSet<SlotId>,
batched_slots: HashSet<SlotId>,
}
pub struct Context {
inner: RefCell<ContextInner>,
}
}
API:
| Method | Purpose |
|---|---|
Context::new() | Create a new context |
ctx.computed(|ctx| T) | Create a derived lazily-computed value |
ctx.slot(|ctx| T) | Create a lazily-computed slot; synonym of ctx.computed() |
ctx.memo(|ctx| T) | Create a lazily-computed slot with a PartialEq memoization guard |
slot.get(&ctx) | Get value (computes if unset) |
ctx.get(&slot) | Context method alias for slot.get(&ctx) |
ctx.get_rc(&slot) | Get slot value as Rc<T>, avoiding deep clone |
ctx.source(value) | Create a mutable cell |
source.get(&ctx) | Get cell value |
ctx.get(&cell) | Context method alias for source.get(&ctx) |
ctx.get_rc(&source) | Get source value as Rc<T>, avoiding deep clone |
ctx.set(&cell, value) | Update cell (marks dependents dirty if changed) |
source.set(&ctx, value) | Handle method alias for ctx.set(&cell, value) |
ctx.batch(|ctx| { ... }) | Defer changed-cell dirty marking and explicit clears until the outermost batch exits |
ctx.effect(|ctx| { ... }) | Run an effect immediately and rerun it after tracked dependencies invalidate |
ctx.is_set(&slot) | Check if slot has a cached, fresh value |
slot.clear(&ctx) | Clear cached value and cascade to dependents |
cell.clear_dependents(&ctx) | Clear downstream slots without changing cell value |
effect.dispose(&ctx) | Dispose an effect, unsubscribe dependencies, and run cleanup |
effect.is_active(&ctx) | Check whether an effect is still registered |
Context stores nodes in a slot-id-indexed Vec<Option<Node>> rather than a
hash map. SlotId values are allocated sequentially; effect disposal returns the
ID to a free list for reuse, preventing unbounded Vec growth from transient
effects while keeping lookups contiguous and hash-free.
Dependency and dependent edges use SmallVec<[SlotId; 4]> rather than HashSet<SlotId>.
For the typical 1-3 dependency fan-out, SmallVec stores edges inline without heap
allocation, avoids hash computation overhead, and eliminates the temporary
Vec<SlotId> allocations that were previously required in every refresh/clear/dirty
hot path (replaced with SmallVec::clone() or std::mem::take). Sets that require
true dedup semantics (scheduled effects, batch queues, tracking frames) remain as
HashSet<SlotId>.
The single-threaded Context consolidates all mutable state behind one
RefCell<ContextInner> instead of ten separate RefCell fields. This reduces
borrow-check overhead from 4-6 flag modifications per get() to 1 and eliminates
the risk of re-entrant borrows across fields. Slot and effect compute closures are
stored as Rc<dyn Fn> so they can be cloned cheaply without unsafe pointer copies.
ThreadSafeContext
Mutex-backed counterpart to Context for sharing one reactive graph across OS
threads. It mirrors the core local-context API and requires thread-safe values
and callbacks.
#![allow(unused)]
fn main() {
pub struct ThreadSafeContext {
inner: Arc<ThreadSafeInner>,
}
}
API:
| Method | Purpose |
|---|---|
ThreadSafeContext::new() | Create a new thread-safe context |
ctx.computed(|ctx| T) | Create a Send + Sync derived lazily-computed value |
ctx.slot(|ctx| T) | Create a Send + Sync lazily-computed slot |
ctx.memo(|ctx| T) | Create a Send + Sync lazily-computed slot with a PartialEq memoization guard |
slot.get(&ctx) | Get value from any thread (computes if unset) |
ctx.get(&slot) | Context method alias for slot.get(&ctx) |
ctx.get_arc(&slot) | Get slot value as Arc<T>, avoiding deep clone (the Send + Sync analog of ctx.get_rc) |
ctx.source(value) | Create a mutable Send + Sync cell |
source.get(&ctx) | Get cell value from any thread |
ctx.get(&cell) | Context method alias for source.get(&ctx) |
ctx.set(&cell, value) | Update cell and invalidate dependents across threads |
ctx.batch(|ctx| { ... }) | Defer invalidation until the outermost shared batch exits |
ctx.effect(|ctx| { ... }) | Run a Send + Sync effect immediately and rerun it after tracked dependencies invalidate |
ctx.clear(&slot) | Clear cached value and cascade to dependents |
ctx.clear_cell_dependents(&cell) | Clear downstream slots without changing cell value |
ctx.dispose_effect(&effect) | Dispose an effect, unsubscribe dependencies, and run cleanup |
ctx.is_effect_active(&effect) | Check whether an effect is still registered |
Slot
Lazily-computed cached value with dependency tracking. A Slot is fresh, dirty, or unset; dirty slots may retain a previous cached value for memo validation.
ctx.memo() creates a Slot whose values implement PartialEq so dirty caches can be compared against recomputed values.
#![allow(unused)]
fn main() {
type ComputeFn = dyn Fn(&Context) -> Rc<dyn Any>;
type EqualsFn = dyn Fn(&dyn Any, &dyn Any) -> bool;
struct SlotNode {
value: Option<Rc<dyn Any>>,
type_id: TypeId,
compute: Rc<ComputeFn>,
equals: Option<Box<EqualsFn>>,
dependencies: SmallVec<[SlotId; 4]>,
dependents: SmallVec<[SlotId; 4]>,
dirty: bool,
force_recompute: bool,
}
}
Semantics:
- Activation: First
ctx.get()calls the compute function, caches the result - Computed alias:
ctx.computed()creates the same Slot asctx.slot()for derived-value ergonomics - Invalidation: Marks the cached value dirty and marks downstream slots dirty without discarding their cached values
- Clearing: Explicit
slot.clear(&ctx)removes the cached value and clears all dependent slots recursively - Memo guard: Dirty
ctx.memo()slots compare recomputed values with the previous cache viaPartialEq; equal values make downstream dirty slots fresh without recomputing them - Dependencies: If Slot B accesses Slot A during computation, B depends on A. If A clears, B clears automatically; if A’s value changes after dirty validation, B is forced stale
- Immutable by default: Once set, a Slot’s value doesn’t change — only clear + recompute
- Dynamic: Dependencies re-discovered on each recomputation (no stale subscriptions)
Cell
Mutable value container. Changing a Cell’s value marks dependent Slots dirty.
#![allow(unused)]
fn main() {
struct CellNode {
value: Rc<dyn Any>,
type_id: TypeId,
dependents: SmallVec<[SlotId; 4]>,
}
}
Semantics:
ctx.set()andsource.set(&ctx, value)compare old and new viaPartialEq- If unchanged, no invalidation occurs (no-op)
- If changed, dependent Slots are marked dirty while cached values are preserved for memo validation
Effect
Side-effect callback that automatically tracks dependencies. Effects run immediately on creation, then rerun after any Cell or Slot read during the last run is invalidated.
#![allow(unused)]
fn main() {
type EffectFn = dyn Fn(&Context) -> Option<Box<dyn FnOnce()>>;
struct EffectNode {
run: Rc<EffectFn>,
dependencies: SmallVec<[SlotId; 4]>,
cleanup: Option<Box<dyn FnOnce()>>,
force_run: bool,
}
}
Semantics:
- Immediate activation:
ctx.effect()runs the callback once during creation - Auto-tracking: Any Slot or Cell accessed during the callback becomes a dependency
- Scheduling: Dependency invalidation schedules the effect, then the context flushes scheduled effects after the invalidation pass
- Coalescing: An effect scheduled through multiple dependency paths in the same invalidation pass runs once
- Memo guard: Effects scheduled by dirty slot dependencies first validate those slots and skip cleanup/rerun when values are unchanged
- Cleanup: Returning a cleanup closure runs it before the next rerun and on disposal
- Disposal:
effect.dispose(&ctx)unsubscribes from dependencies, removes pending scheduled work, and prevents future reruns
Signal
Eager derived value. A Signal sits one step beyond a Slot on the
Slot -> Cell -> Signal progression: where a Slot is lazy (invalidation
only marks it dirty; the value is recomputed on the next read), a Signal is
eager — it recomputes the instant any dependency is invalidated. The value
is always materialized, so observers never see an intermediate unset value: a
dependency change drives the value directly from v1 to v2.
A Signal is composed from existing primitives: a memoized Slot (ctx.memo)
plus a small puller Effect that re-materializes the slot after every
invalidation. This composition is intentional — it inherits the Slot’s
glitch-free, pull-based recomputation and the memo guard, while the Effect
supplies eagerness.
#![allow(unused)]
fn main() {
let n = ctx.source(1);
let doubled = ctx.signal(|ctx| n.get(ctx) * 2); // materialized now: 2
n.set(&ctx, 5); // doubled is already 10
assert_eq!(doubled.get(&ctx), 10);
}
Semantics:
- Eager activation:
ctx.signal()computes the value once at creation; the value is set from the start - Eager recomputation: Dependency invalidation recomputes the value during the invalidation flush, before the invalidating
set/set/batchcall returns — no read is required to drive it - No unset state: The backing slot is invalidated via dirty-marking (not hard-cleared), so the value transitions
v1 -> v2and is never observed as unset - Memo guard: Backed by
ctx.memo, a recomputation that yields an equal value (viaPartialEq) does not invalidate downstream dependents - Glitch-free: Recomputation is pull-based; a Signal that reads other Signals/Slots always observes values consistent with the current inputs (e.g. a diamond
D = f(A, g(A))never surfaces a mixed new-A/old-g(A)intermediate) - Batch coalescing: Writes inside
ctx.batch()settle to a single consistent recomputation at batch exit - Type bounds:
signal<T>requiresT: PartialEq + 'static(for the memo guard);get_signaladditionally requiresT: Clone - Disposal:
signal.dispose(&ctx)removes the eager puller; the value remains readable and reverts to lazy (recomputed on next read) behavior
Signal across context types
The eager-Signal primitive is exposed on all three context types with the same
memo-slot + puller-effect composition, so shared-graph and async consumers get
the same always-set, glitch-free v1 -> v2 derived values that the
single-threaded Context provides (#lzsignalparity).
ThreadSafeContext::signal— shared-graph counterpart. Returns aThreadSafeSignalHandle<T>with.get/.dispose/.is_active(&ctx)helpers and matchingctx.get_signal/dispose_signal/is_signal_active. Recomputation is eager (driven during the invalidation flush before theset/batchcall returns), glitch-free, memo-guarded, and batch-coalesced — identical to the single-threaded semantics above. Type bounds addSend + Sync:signal<T>requiresT: PartialEq + Send + Sync + 'static;get_signaladditionally requiresT: Clone. The handle isCopy + Send + Syncand may be read from any thread sharing the context.AsyncContext::signal_async— async counterpart. Returns anAsyncSignalHandle<T>backed bymemo_asyncplus aneffect_asyncpuller that awaits the slot after every invalidation. Reads:ctx.get_signal(orhandle.get) returnsOption<T>as a non-blocking snapshot;ctx.get_signal_async(orhandle.get_async) awaits the up-to-date value. Inside a slot/effect callback,AsyncComputeContext::get_signal_asyncreads a signal and registers its backing slot as a dependency, enabling chained async signals and downstream observers. Type bounds:T: PartialEq + Clone + Send + Sync + 'static.- Eagerness is runtime-driven: because resolution is asynchronous, the puller drives the recompute to completion on the runtime shortly after the invalidating write rather than synchronously within it.
- Propagation is not suppressed on equal recompute: the async memo guard
keeps the value correct on an equal recompute, but — unlike the
single-threaded/thread-safe graph — does not suppress downstream
propagation (async invalidation force-reruns effect dependents on every
upstream change). No inconsistent (glitch) value is ever observed. This
matches the documented
async memo does not suppress downstream propagationbehavior ofmemo_async.
Batch
Write-coalescing boundary for multiple cell updates or explicit slot clears.
#![allow(unused)]
fn main() {
ctx.batch(|ctx| {
ctx.set(&a, 1);
ctx.set(&b, 2);
});
}
Semantics:
- Outermost boundary: Nested batches flush only when the outermost batch exits
- Changed cells:
ctx.set()still updates the cell value immediately, but dependent dirty marking is queued until batch exit - Explicit clears:
slot.clear(&ctx)andcell.clear_dependents(&ctx)are queued until batch exit - Coalescing: Repeated updates to the same cell or clears of the same slot queue one invalidation root
- Thread-safe local batching: same-thread
ThreadSafeContextbatch writes buffer changed cells and clears in a thread-local batch frame, then merge that frame into the graph-owned batch queue at batch exit; cross-thread writes during another active batch still fall back to the graph-owned queue - Effect flushing: Effects scheduled by batched invalidation rerun after the batch invalidation pass and coalesce duplicate schedules
- Reads during a batch: Direct
ctx.get()reads see the latest cell value immediately; dependent slot reads keep their pre-batch cached value until dirty marking flushes at batch exit
State Machine
A finite state machine built on top of Source and the reactive graph.
StateMachine<S, E> wraps a Source<S> as the current state and a pure
transition function Fn(&S, &E) -> Option<S>.
#![allow(unused)]
fn main() {
use lazily::{Context, StateMachine};
let ctx = Context::new();
let m = StateMachine::new(&ctx, Door::Closed, |s, e| match (s, e) {
(Door::Closed, DoorEvent::Button) => Some(Door::Opening),
(Door::Opening, DoorEvent::Open) => Some(Door::Open),
_ => None,
});
m.send(&ctx, DoorEvent::Button);
assert_eq!(m.state(&ctx), Door::Opening);
}
API:
| Method | Description |
|---|---|
StateMachine::new(ctx, initial, transition_fn) | Create with initial state + pure transition function |
send(ctx, event) -> bool | Evaluate transition; true if accepted, false if rejected (None) |
state(ctx) -> S | Read the current state |
state_handle() -> Source<S> | Underlying cell for reactive dependencies |
on_transition(ctx, |old, new| ...) -> EffectHandle | Observer that fires on each state change with (old, new) |
state_is(ctx, target) -> SignalHandle<bool> | Eager signal: true when in target state |
Semantics:
- PartialEq guard: A transition to an equal state is accepted (
true) but does not invalidate dependents (theSourceequality guard suppresses no-op updates). To force re-entry, callcell.clear_dependents(ctx)beforesend. - Reactive integration: Any
ctx.computed,ctx.memo,ctx.signal, orctx.effectthat readsstate_handle()automatically recomputes/reruns on transition. - On-enter / on-exit: Use
ctx.effectwith cleanup — the effect body is on-enter, the returned cleanup closure is on-exit (runs before the next rerun). Alternatively, useon_transitionfor a single(old, new)observer. - Batch atomicity:
ctx.batch()coalesces multiplesend()calls — effects fire once after the batch settles. - Single-threaded:
StateMachineis backed byContext(single-threadedRefCell).
ThreadSafeStateMachine — cross-thread
ThreadSafeStateMachine<S, E> is the lock-backed counterpart to
StateMachine, mirroring the same API over ThreadSafeContext.
The transition function and state must be Send + Sync + 'static, so the
machine and its owning context can be shared across OS threads. The machine is
Clone — cloning yields another handle to the same state cell and transition
function.
#![allow(unused)]
fn main() {
use lazily::{ThreadSafeContext, ThreadSafeStateMachine};
use std::sync::Arc;
let ctx = Arc::new(ThreadSafeContext::new());
let m = ThreadSafeStateMachine::new(&ctx, Door::Closed, |s, e| match (s, e) {
(Door::Closed, DoorEvent::Button) => Some(Door::Opening),
_ => None,
});
m.send(&ctx, DoorEvent::Button);
assert_eq!(m.state(&ctx), Door::Opening);
}
on_transition returns an EffectHandle (dispose via
ctx.dispose_effect); state_is returns a ThreadSafeSignalHandle<bool>.
Observers fire synchronously within the invalidating send/batch call,
preserving the glitch-free pull-based ordering of ThreadSafeContext.
AsyncStateMachine — Tokio (async feature)
AsyncStateMachine<S, E> is the async counterpart, backed by
AsyncContext. The state lives in an AsyncSource<S>;
because cells are the synchronous input layer of AsyncContext, send and
state are synchronous. Reactive observers use the async effect/signal APIs:
on_transition returns an AsyncEffectHandle and state_is returns an
AsyncSignalHandle<bool>. Because resolution is asynchronous, eager
recomputation settles on the runtime rather than synchronously within send.
#![allow(unused)]
fn main() {
use lazily::{AsyncContext, AsyncStateMachine};
let ctx = AsyncContext::new();
let m = AsyncStateMachine::new(&ctx, Door::Closed, |s, e| match (s, e) {
(Door::Closed, DoorEvent::Button) => Some(Door::Opening),
_ => None,
});
m.send(&ctx, DoorEvent::Button);
assert_eq!(m.state(&ctx), Door::Opening);
}
Regression property harness
The default Rust test suite includes tests/property_graph.rs, a proptest
harness that drives a fixed reactive graph with generated programs of cell
sets, equal-value sets, cell.clear_dependents, slot.clear, memo-guarded
dependencies, batch boundaries, reads, and effect disposal/recreation.
Each generated program is checked against a pure model:
- Cell and slot reads must match the model after every operation.
- Equal-value cell sets must not schedule effect cleanup/rerun work.
- Same-parity cell changes through a
memoslot must preserve downstream effect run counts when the observable output is unchanged. - Explicit slot and cell-dependent clears must hard-clear cached slots when no effect is active, and must rerun active effects to re-prime their dependencies.
- Operations inside
ctx.batchmust not clear cached dependent slots or run effect cleanups/reruns until the outermost batch exits; dependent reads during the batch must continue to see the pre-batch cached value.
SlotId
Unique identifier for reactive nodes. Lightweight Copy type wrapping a u64.
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SlotId(u64);
}
Both Computed<T> and Source<T> wrap a SlotId with PhantomData<T> for type safety.
Keyed cell collections (CellFamily / SourceMap)
SourceMap<K, V> and CellFamily<K, V> add a keyed layer over the flat SlotId
address space: a hash collection whose membership is itself reactive, with one
independently-tracked value cell per entry.
SourceMap<K, V>— aK → Source<V>map with two independent reactivity surfaces:- Per-entry value reactivity. Each entry is its own cell, so a reader that
depends on entry
ais invalidated only whenachanges — never when a sibling entrybchanges. This is the fine-grained model; it is the opposite of a coarseCell<HashMap<K, V>>, where any single-entry write replaces the whole map and invalidates every reader. - Reactive membership and order (two independent signals). Two version cells are
tracked separately: a set-membership signal (bumped when a key is added or
removed) and an order signal (bumped on add/remove and on move/reorder).
len(),is_empty(), andcontains_key()subscribe to set-membership only;keys()subscribes to the order signal. So adding/removing invalidates both; a pure reorder invalidates onlykeys()readers, leavinglen()/contains_key()readers cached. Mutators bump via an untracked write so they never register a spurious dependency on the caller’s frame.
- Per-entry value reactivity. Each entry is its own cell, so a reader that
depends on entry
CellFamily<K, V>— a parameterized factory (à la Recoil/JotaiatomFamily) layered onSourceMap: it lazily mints and caches one cell per distinct key on firstget(key), via aFn(&K) -> Vfactory. Repeatedgets of the same key return the same cell.
Wire-stable keyed addressing (#lzwirekey)
A SourceMap/CellFamily entry is addressable internally by its key, but on the
wire (PROTOCOL.md) a peer historically could only refer to it by the opaque,
volatile NodeId it happened to receive — which a producer may re-mint under a
new value after a resync or a remove-then-readd. The wire protocol therefore
carries an optional, wire-stable NodeKey: a /-joined path (scores/alice,
outer/k1/inner/k2) attached to the NodeSnapshot and NodeAdd ops that
introduce a node.
- Additive and backward compatible.
NodeKeynever changesNodeIdsemantics; it is an optional field. Self-describing codecs (JSON, MessagePack) omit it when absent, so pre-keyencoders/decoders and existing conformance fixtures round-trip unchanged; positional Postcard always carries the optional discriminant for binary schema stability. - Path = nesting. A multi-segment path addresses nested collections (an
entry of a
SourceMapinside aSourceMapentry) with no extra machinery. Length (≤ 1024 bytes) and segment count (≤ 32) are bounded and rejected on the wire. - Consumer key index. A subscriber maintains a bijective
NodeKey ↔ NodeIdindex (KeyIndex): ingesting aSnapshotor applying a keyedNodeAdd/NodeRemovekeeps a key resolvable across NodeId churn, so a key-expressed subscription (node_for_key("scores/alice")) stays valid when the entry is removed and re-added under a fresh NodeId. - Producer wiring is staged. The wire types, codecs, and consumer index land
with
#lzwirekey; threading each entry’sNodeKeythrough the runtime→IPC projection waits on the graph→snapshot producer (todayNodeSnapshots are constructed only at the transport seam, not minted from a liveSourceMap). Multi-producer key-uniqueness is owned by the distributed plane’s last-writer rule (#lzcrdtplane), not this protocol.
Atomic ordered move (#lzcellmove)
SourceMap exposes move_to(key, index), move_before(key, anchor), and
move_after(key, anchor): the atomic, optimized reorder. A move MUST keep the
entry’s same value cell (handle identity), its dependents, and its CRDT lineage —
unlike the naive remove + entry, which re-mints the cell and bumps membership twice.
A move MUST bump only the order signal (once), and MUST be a no-op (no invalidation) when
the requested position equals the current position. index is clamped to [0, len).
Ordered keyed tree (SourceTree, #lzordtree)
SourceTree<Id, V> is a shallow-or-deep, ordered, stably-keyed tree composed from the
primitives above — the document shape (root → components → items, each with a stable id)
that keyed reconciliation (#lzkeyrecon) and per-cell CRDT merge build on. Each node is
(id, value_cell, ordered children):
- Stable
idsurvives reorder and value edits. valuecell gives per-node value reactivity: editing nodeXMUST invalidate only readers ofX, never a sibling or descendant.- Ordered children are a
SourceMap-backed reactive collection, sochild_ids()/len()and order are reactive per level: a reader of one node’s children MUST NOT be invalidated by a membership/order change in a sibling subtree or a deeper descendant.move_child*inherit the atomic-move guarantee (child node keeps identity + subtree).
SourceTree is cheap to Clone (Rc to shared node state), giving structural sharing:
the same subtree node may be held in several places. Conformance: per-node value
reactivity, per-level membership/order reactivity, and atomic child move MUST hold; a
SourceTree is a composition of cells (value cells + a keyed ordered collection), not a
new cell kind.
Conformance: a keyed collection MUST keep value reactivity, set-membership reactivity, and
order reactivity independent (an entry-value write MUST NOT invalidate membership or order
readers; a pure reorder MUST NOT invalidate membership readers), and MUST return a stable
cell handle for the lifetime of a key. Entry removal clears the removed entry’s dependents;
the underlying SlotId is not required to be recycled (the runtime exposes no node-free
API today). Each entry remains an ordinary cell, so the single-writer / multi-write
classification and per-cell merge rules in the cell model apply to entries unchanged — a
keyed collection (and the tree composed from it) is a composition of cells, not a new
cell kind.
Keyed reconciliation (#lzkeyrecon)
reconcile(old, new) diffs two keyed sequences by stable key, not position, and
returns the minimal {Insert, Remove, Move, Update} op set that transforms old into
new:
- Keys in
oldonly →Remove; keys innewonly →Insert{index}; keys in both with a changed value →Update. - Move-minimization is mandatory: of the keys present in both, those already in relative
order (the longest-increasing-subsequence over their old indices) MUST stay put; only the
remainder emit
Move{to}. Move count therefore equals(keys in both) − |LIS|, which is the minimum number of single-element moves — strictly fewer than a remove-all + insert-all whenever any key is shared.index/toare positions in the final sequence; applied in emitted order (removes, then inserts/moves left-to-right, then updates) they reproducenew.
apply_to_map (and the SourceMap::reconcile convenience) drive a reactive SourceMap from
the op set. Conformance for the reactive application: a stable entry (in the LIS, value
unchanged) MUST NOT have its value cell invalidated by a sibling reorder — moves go through
the atomic-move path, so reactivity is proportional to what actually changed. This minimal
per-item op set, applied per-cell, is the enabling step for per-cell CRDT merge of document
trees (replacing whole-subtree replacement).
Manufactured identity for text (#lzstableid)
Plain markdown has no node ids, so reconciliation needs identity manufactured from text.
block_key / align / assign_stable_keys provide it in three layers of decreasing
certainty:
- Anchored ids — an in-band marker/id on a block (agent-doc emits these). Exact, and
MUST survive an arbitrary rewrite of the block body (matched by id, classified
Same). - Content-derived keys — a hash of the block’s normalized text (whitespace collapsed), so an unchanged block keeps its key across reflow/rewrap/reorder; an edit changes it.
- Alignment — a block with no exact key match is matched to the most-similar unmatched
old block above a threshold (word-LCS ratio
2·|LCS|/(|a|+|b|), nearest index breaking ties) and classifiedEdited; otherwiseInserted. Unmatched old blocks areRemoved. This distinguishes an edit from a real insert; a true rewrite legitimately reads as insert+remove — no identity remains to preserve.
assign_stable_keys is the bridge to #lzkeyrecon: a Same/Edited block reuses its
matched old block’s key so identity flows through an edit (the reconciler emits Update,
not remove+insert); an Inserted block gets its own key. This is why agent-doc leans on
in-band anchors — the cheapest way to buy stable identity over fundamentally unstable text.
Memoized semantic tree (SemTree, #lzsemtree)
The syntactic SourceTree holds input cells; the semantic tree (unresolved prompts,
drainable heads, section summaries) is a layer of memoized computed nodes derived from
it. SemTree::build creates one memo slot per node that folds
(node value, child derived values) -> D.
Because each node has its own memo slot and a parent reads its children’s derived slots (not their raw cells), the derivation MUST be incremental and glitch-free:
- Editing one node recomputes only that node’s ancestor chain — a sibling subtree’s derived value MUST stay cached (it is not in the dirty cone). This is the lazy-pull win: cost is proportional to the diff, not the document.
- The memo guard means a node edit that does not change the folded result MUST NOT cause a downstream consumer of an ancestor to re-run (the value version doesn’t bump).
Incrementality covers value edits, removals, and reorders of children; a child insertion
adds a node the captured fold doesn’t know about yet, so structural growth calls build
again (cheap — only slot allocation; unchanged subtrees still won’t recompute later). The
rule: don’t materialize semantics eagerly — derive them as memoized computeds and pay only
for what actually changed.
Free-text CRDT + re-parse (TextCrdt, #lztextcrdt)
The anchored layers above buy identity for controlled structure. For arbitrary prose with
no anchors under concurrent edits, the merge unit drops to characters. TextCrdt
merges keystrokes; the structural tree is then a projection of the merged text
(re-parse), not the merge unit itself.
- Algorithm — a Fugue/RGA-style tree CRDT. Each inserted character is an element with a
unique
OpIdand a left origin (the element it was typed after). The sequence is the in-order traversal of the origin tree, same-origin siblings ordered byOpIddescending. Deletes are tombstones carrying the delete’s ownOpId(not a bare flag) so GC can test deletion stability;orderis therefore a pure function of the element set, somerge(union of elements, tombstones sticky — concurrent deletes converge to the smaller deleteOpId) MUST be commutative, associative, and idempotent, and concurrent inserts at the same point MUST converge deterministically with both preserved. - Tombstone GC (
#lztombgc) —gc_with(is_stable)reclaims tombstones the caller proves causally stable (“every replica has observed the deletion”; the policy is supplied by the distributed plane#lzcrdtplane, never derived from a single replica’s clock). It is deliberately conservative: an element is collected only when it is not referenced as any element’s left origin, so removal never orphans a survivor; interior tombstones are reclaimed bottom-up as their descendants are collected. Contiguous-run compaction with origin-rewrite is the heavier follow-up. - Re-parse —
parse_blockssplits merged text into blocks; feed them throughassign_stable_keys(#lzstableid) +reconcile(#lzkeyrecon) to project the merged text onto the keyed tree. An unchanged block keeps its key (identity) across the text-CRDT merge. - Honest floor — a true rewrite is a replace: there is no character identity to preserve through it. The anchored layer keeps per-node lineage; the free-text layer’s guarantee is “merge the text, re-derive the tree,” which is the correct floor for prose the user can edit arbitrarily.
Together, the seven primitives (#lzcellmove atomic move → #lzordtree ordered keyed tree
→ #lzkeyrecon keyed reconciliation → #lzseqcrdt sequence order → #lzstableid
manufactured identity → #lzsemtree memoized semantics → #lztextcrdt free-text CRDT) are
the substrate for a reactive, per-cell-mergeable agent-doc document.
Dependency Tracking
Uses a thread-local tracking stack (mirroring lazily-zig’s TrackingFrame approach).
- When a Slot computes, it pushes a frame onto the tracking stack
- When an Effect runs, it also pushes a frame onto the tracking stack
- Any nested slot/cell access sees the parent frame
- The child registers the parent as a dependent
- When a dependency clears, slot dependents hard-clear recursively; when a Cell changes, slot dependents are marked dirty and effect dependents are scheduled
Threading and Concurrency Contract
Current Context
Context is intentionally local to one OS thread. It owns RefCell graph state,
cached values as Box<dyn Any>, compute callbacks as Box<dyn Fn(&Context)>,
effect callbacks as Box<dyn Fn(&Context)>, and cleanups as Box<dyn FnOnce()>.
Those storage choices avoid synchronization overhead for the common single-threaded
path and make Context neither Send nor Sync.
Current guarantees:
- Independent
Contextinstances may be used on different OS threads - A single
Contextmust not be moved into, shared with, or accessed from another thread Computed<T>andSource<T>are lightweight ids and areSend + SyncwhenTisSend + Sync, but they are only meaningful with their owning contextEffectHandleis a lightweight id; effect execution and cleanup remain tied to the owning context thread- Dependency tracking is thread-local; a compute/effect callback cannot split work onto another thread and expect nested reads there to attach to the original tracking frame
ThreadSafeContext
Thread-safe support is explicit rather than a silent change to Context.
ThreadSafeContext mirrors the existing Context methods while preserving the
single-threaded fast path.
API bounds:
| Method family | Additional bounds |
|---|---|
cell, get, set | T: PartialEq + Clone + Send + Sync + 'static |
slot, computed | T: Clone + Send + Sync + 'static; compute closure Fn(&ThreadSafeContext) -> T + Send + Sync + 'static |
memo | T: PartialEq + Clone + Send + Sync + 'static; compute closure Send + Sync + 'static |
effect | effect callback Fn(&ThreadSafeContext) -> R + Send + Sync + 'static; cleanup FnOnce() + Send + 'static |
| handles | remain id-only and copyable; usable from any thread only with the owning ThreadSafeContext |
Locking model:
- Uses one context-level
Mutexsynchronization primitive for graph state before introducing finer-grained graph locks ThreadSafeStatestores nodes in a slot-id-indexedVec<Option<ThreadSafeNode>>matching the single-threadedContext, eliminating hash-map lookup overhead on every node access. Slot IDs are reused via a free list on effect disposal.- Fresh cached slot reads use a per-slot read-mostly cached-value sidecar; dependency-edge changes, invalidation frontier application, batch queues, effect queues, and disposal remain graph mutex mutations
- Read-mostly cached slot access is versioned optimistically: the getter loads a per-slot atomic cache revision before cloning the retained cached
Arc, then validates the revision and dirty/force flags again after the clone. Any concurrent invalidation, clear, or value publish changes the revision and forces the getter onto the graph-validated refresh path. - Each thread-safe slot also owns a per-slot recompute/value-publish sidecar for the cached-value visibility flags, in-flight bit, waiter
Condvar, and revision used to reject stale callback results. Graph-state dirty/revision fields remain mirrored under the context mutex for dependency-frontier traversal and tests. - Each thread-safe slot sidecar mirrors a per-slot dependency summary: the
current dependency ids plus a slot-dependency count. A cell-only dirty refresh
may claim the SlotId-partitioned recompute sidecar, snapshot old dependencies,
and skip the graph-locked
get_refreshdependency scan. The owner still takes the finalpublishgraph mutation to diff dynamic dependencies, publish the value, notify dependents, and reject stale in-flight revisions. - Cells and slots mirror their dependent frontiers into per-node sidecars keyed
by
SlotId. Changed-cell invalidation may use these sidecars without taking the context graph mutex only when no callback is actively discovering dependencies, the context is not inside a batch, and the discovered frontier contains slots only. The per-slot cache revision acts as the dirty epoch for sidecar publication, and instrumentation records each epoch advance so parallel writers can publish version state without the graph mutex while cached reads still reject mid-read invalidation races. Effect scheduling, batching, dynamic dependency discovery, disposal, and any frontier that reaches an effect fall back to the graph mutex. - Do not hold the graph lock while running user compute callbacks, effect callbacks, or cleanup closures
- Re-acquire the lock only to publish computed values, dependency edges, invalidation state, and pending effect work
- Slot refresh must avoid helper-level lock churn: a fresh cached get should clone the value through the per-slot fast path without taking a
get_refreshgraph lock or recursively validating unchanged dependencies, dependency refresh should not take a separate node-kind probe lock before recursively validating a dependency, cell dependencies should not be probed as refreshable slots, clean dirty flags should be folded into the refresh decision lock, and recompute must diff old/new dependency sets at publish so unchanged edges stay subscribed while only stale edges are removed - Recompute dependency tracking must skip graph-lock edge registration for dependencies already present in the slot’s previous dependency set, while still eagerly registering newly discovered dependencies during the callback so concurrent invalidation can mark the in-flight result stale
- Effect rerun dependency tracking must also preserve unchanged edges: dependencies already present on the effect remain subscribed through the rerun, newly discovered dependencies are registered during tracking, and stale dependencies are removed in one post-callback graph mutation before the next cleanup is stored
- Re-entrant user code must be able to call back into the same context without deadlocking
- Concurrent first access and dirty same-slot contention share one in-flight computation for the current slot revision; waiters check the per-slot recompute sidecar before the
get_refresh/publishgraph-lock path, park on that slot’s notification primitive, then return the published cache or retry if an invalidation makes the in-flight result stale - Recompute waiters observe the per-slot in-flight/revision state while holding the sidecar mutex, then park on the same sidecar
Condvar. Finishers publish value and dirty-state sidecar updates before clearing the in-flight bit and notifying, so a stale in-flight completion cannot be missed. - Recompute notifications are scoped to the slot that finished. A completion for one in-flight slot must not wake waiters parked behind another in-flight slot.
- Per-slot recompute wakeups use a waiter-counted handoff instead of
notify_all: the finisher callsnotify_onewhen waiters exist, and each awakened waiter notifies the next parked waiter after observing the completed sidecar state. This drains all waiters without a completion-wide wakeup stampede. - Optimistic cached reads fall back whenever the context is dirty, forced to recompute, or racing with a sidecar revision change. They do not publish dependency edges, flush effects, observe batch-local unflushed invalidations as fresh, or replace graph-locked refresh for ambiguous callback/dependency states.
- If an upstream invalidation happens while a slot callback is running, the in-flight stale result is not published as fresh; the getter retries until it can return a value that matches the latest dependency state
- Batch exit, effect scheduling, disposal, and explicit clears must each have a single atomic graph mutation boundary and one coalesced effect flush per outermost invalidation pass
- The outermost thread-safe batch exit must collect dependents for all changed cells and apply one coalesced frontier invalidation, so a shared dependent reached through many changed cells is marked dirty and advances revision once per batch flush
- Thread-safe invalidation uses an explicit
InvalidationPlancomputed from a frontier work queue under the graph mutex instead of recursive dependent walks. Changed-cell and slot-value-change roots snapshot dependent frontiers, coalesce duplicate slot ids in one invalidation pass, preserve direct changed-valueforce_recomputeupgrades when a slot is reached through both direct and downstream paths, snapshot hard-clear frontiers for explicit slot/cell clears, then apply dirty, clear, revision, and effect-scheduling mutations at the same graph mutation boundary. The plan shape is partitionable for future bounded worker traversal, but this prototype keeps snapshot and application under the context mutex until benchmark and model-checking evidence proves a parallel apply path safe. - Thread-safe stress coverage must run the same contention script under both
LowConcurrencyandHighConcurrency, mixing batched cell writes, explicit slot and cell-dependent clears, effect cleanup/rerun, effect disposal racing with writers, and concurrent cached reads. The harness lives intests/thread_safe_stress.rsand is part ofmake check.
Lock strategy evaluation:
-
Keep one context-level graph synchronization primitive until benchmark instrumentation shows a finer-grained design improves the relevant workload without trading off other contention cases
-
ThreadSafeContextuses read-mostly per-slot cached-value sidecars for fresh cached reads, a per-slot recompute/value-publish sidecar for in-flight same-slot waiters, a per-slot dependency summary for cell-only dirty refresh routing, and per-node dependent frontier sidecars for slot-only changed-cell invalidation; same-thread batches use thread-local batch frames to coalesce changed-cell queueing before the graph-owned batch flush; dependency graph mutations still require the context mutex -
The read-mostly cached-value sidecar is an optimistic validation path, not a lock-free graph replacement. Its atomic cache revision rejects mid-read invalidation and mid-read publish races, then falls back to the existing graph refresh path.
-
ThreadSafeContextuses per-slot sidecar recomputeCondvars for in-flight waiters. Those Condvars guard only per-slot in-flight/revision/cache-visibility state, use waiter-countednotify_onehandoff wakeups to avoid broadnotify_allcontention, and must not mutate dependency graph state independently of the context mutex -
The read-mostly prototype is benchmark-gated by the 1/2/4/8/16-worker
same_slot_write_read,independent_slots,read_mostly_waiters, andbatched_write_burstsmatrix after the#lazybatch1and#lazybatch2invalidation/read-churn fixes -
The dependent-frontier sidecar prototype is benchmark-gated by
thread_safe_contention / independent_slotsandset_cell_invalidation / independent_slot_contentionat 8 and 16 workers. It should reduceset_cell_invalidationgraph-lock acquisitions for independent slot-only roots without changing effect, batch, or dynamic-dependency semantics. -
A sidecar frontier invalidation that reaches a slot with recompute in flight falls back to the graph-locked invalidation path, so stale publishes cannot clear newer dirty markers.
-
High-parallel graph propagation profiles gate the lazy-invalidation path with
thread_safe_graph_propagationat 8 and 16 workers. The matrix compares fan-out eager validation, fan-out lazy dirty epoch publication, fan-in lazy dirty epoch publication, and fan-in batched flush behavior using throughput, p50/p95 latency, lock attribution, effect queue pushes, dependency-edge counters, sidecar dirty marks, sidecar fallbacks, and dirty epoch advances. -
The local batch-frame prototype is benchmark-gated by
set_cell_invalidation / batched_write_burstsandthread_safe_contention / batched_write_burstsat 8 and 16 workers. It should reduce per-write graph queueing during same-thread batches while preserving one coalesced dirty/effect flush at the outermost batch exit. -
Effect-heavy contention profiles gate any queue or batch synchronization change.
thread_safe_effect_contentionisolates effect queue coalescing, cleanup execution, and nested batch flush behavior at 8 and 16 workers with deterministic lock-site budgets before a sharded graph-lock design can be considered. -
Synchronization strategy comparison is release-gated by one fixed evidence table. The current
std::syncmutex/Condvar path is the baseline; narrower Condvar wakeups are adopted only for per-slot recompute waiters; parking_lot style parking and targeted CAS remain candidates. A candidate must report throughput plus p50/p95 latency for the required 8/16-worker contention and effect-heavy cases, stay within lock-site budgets, and carry Loom/Shuttle proof for stale completion, effect scheduling/disposal, batch flush, and re-entrant callbacks before release. -
Benchmark watch items from generated README deltas must be confirmed with a controlled A/B rerun before tuning. The rerun should use the same benchmark filter on the same host/toolchain when possible, isolate baseline and current code in clean worktrees or Criterion baselines, and record whether the signal reproduces. If confidence intervals overlap or Criterion reports no statistically significant change, document the watch item and avoid speculative synchronization changes.
-
Edge storage A/B benchmarking procedure: the
vec_edgesfeature flag switchesEdgeVecfromSmallVec<[SlotId; 4]>(default) toVec<SlotId>. To compare:cargo bench --bench context -- dependency_fan_out,set_cell_invalidation/high_fan_out --save-baseline smallveccargo bench --bench context --features vec_edges -- dependency_fan_out,set_cell_invalidation/high_fan_out --baseline smallvec- Compare Criterion output — if confidence intervals overlap, keep SmallVec; if Vec is faster at the tested fan-out widths, reconsider the default. The comparison must run on the same host/toolchain with no other workload.
-
Cached-read strategy is runtime-selectable (#vd5v / #rdstrat1). Both read paths are compiled in and chosen at context construction via
ThreadSafeContext::with_read_strategy(ReadStrategy), defaulting toLowConcurrency; the slot sidecarThreadSafeSlotFastPath.valueis aCachedReadStorageenum:LowConcurrency→parking_lot::RwLock<Option<Arc<dyn Any + Send + Sync>>>read — optimal uncontended / low core counts (the default).HighConcurrency→arc_swap::ArcSwapOption<Arc<dyn Any + Send + Sync>>wait-free load — no read lock; optimal at 8+ cores. (arc-swap’sRefCntisSized-only, so it storesArc<Arc<dyn Any>>; the extra outerArcis allocated only on the cold publish path, never on the read.)
Both reconstruct
&Tvia the inlinetype_idwithout vtable indirection, and both carry the same atomiccache_revision+dirty/force_recomputevalidation envelope (loaded before, re-checked after the clone), so agetstarting after a completed cross-thread invalidation cannot return the pre-invalidation value regardless of strategy. The runtime selection costs one per-read enum branch (the price of compiling both paths). 0.9.0 shipped arc-swap as the default; 0.10.0 (#rdstrat1) flips the default toLowConcurrencywith explicit opt-in toHighConcurrency. Verified by the full default suite (both strategies) and thethread_safe_loommodel (the validation algorithm is identical across variants). The inline small-Copyseqlock fast path that subsumes this tradeoff for small values is #rdstrat2 (implemented; opt-in viaslot_copy/computed_copy/memo_copy— see Inline small-Copyseqlock below).Contention tradeoff (rigorous isolated-worktree A/B, #xtwf). This is a deliberate low-contention-for-high-contention trade, not a free win. Comparing
463ca71(arc-swap) against its parent06fd3c2(theparking_lot::RwLockread) on a shared Criterion target dir, same host/toolchain:benchmark arc-swap vs RwLock cached_reads/thread_safe_context(1 thread)+3.1% (slower) read_mostly_waiters/4+11.9% (slower) read_mostly_waiters/8−6.5% (faster) read_mostly_waiters/16−28.6% (faster) arc-swap’s wait-free read wins at high core counts where
RwLockread-lock cache-line traffic dominates, but its debt-tracking load plus theArc<Arc<…>>double-indirection costs a few percent when uncontended. lazily-rsThreadSafeContexttargets highly-parallel reactive graphs, so the 8/16-worker win is the operative case and the ≤~3% uncontended/low-contention regression is accepted (see the revised benchmark gate below). The contended numbers carry wide confidence intervals; treat the crossover (~4→8 workers) as approximate. -
Any future sharding or CAS path must include a Loom or Shuttle safety model covering concurrent first get, stale in-flight completion, invalidation during compute, effect scheduling/disposal, and re-entrant callbacks before it can replace the single-graph-lock design
-
The current sidecar
Mutex/Condvarwaiter path, optimistic cached-read fallback, and explicit invalidation-plan safety envelope are covered bycargo test --features loom --test thread_safe_loom, which models concurrent first get, scoped slot notification, waiter-counted handoff wakeup draining, stale in-flight completion and retry, read-mostly waiter handoff, mid-read optimistic validation fallback, invalidation during compute, fast-frontier fallback while dependency discovery is active, dynamic dependency switch/disposal cleanup, effect scheduling/disposal races, re-entrant callback graph access, duplicate diamond paths marking each frontier slot once, effect enqueue coalescing, nested batch invalidation flushing only at the outermost boundary, and the inline small-Copyseqlock (#rdstrat2) refusing torn reads and stale post-invalidation reads under concurrent single-writer publish -
A lock-strategy change must preserve the rule that user compute/effect/cleanup callbacks never run while holding graph-state locks
Sharded/versioned storage evaluation:
- After the frontier invalidation and read-churn prototypes, the
thread_safe_contentionbenchmark and instrumentation rows are the gate for storage changes. The read-mostly cached-value sidecar is limited to fresh cached slot reads; write-side graph mutation remains serialized so the same benchmark matrix can show whether read-side wins trade off same-slot writes, independent slots, or batched aggregate writes. - Do not replace the single graph lock with sharded storage in the current implementation. Sharding may help independent roots and slots, but it does not remove serialization for same-root writes, shared aggregate slots, effect queues, batch-depth accounting, disposal, or dynamic dependency edge changes. A shard design must first define shard ownership for dependency edges that cross shards, a deterministic merge/apply order for dirty/revision/effect mutations, and a Loom or Shuttle model for deadlock-free multi-shard invalidation and disposal.
- Do not treat versioned optimistic reads as an invalidation optimization.
Versioned reads target fresh cached
getlatency, while the isolatedset_cell_invalidationprofiles attribute invalidation pressure to write-side graph mutation. The current prototype keeps independently retained sidecarArcsnapshots plus atomic dirty/revision validation so agetstarting after a cross-thread invalidation cannot return the pre-invalidation cached value. - The next storage experiment, if pursued, should be a benchmark-gated
prototype rather than a replacement: shard independent
ThreadSafeStatemutation by stable node id, keep effect queue and batch flush as one deterministic merge boundary, and require the isolatedthread_safe_effect_contentionprofiles,set_cell_invalidationmatrix, and Loom/Shuttle coverage to improve before adopting it.
Typed cache fast-path
Context and ThreadSafeContext store cached values as type-erased trait
objects (Rc<dyn Any> and Arc<dyn Any + Send + Sync>). Every cached read
calls downcast_ref::<T>(), which invokes a virtual type_id() through the
trait object’s vtable, compares the returned TypeId, and then performs a
pointer cast. The vtable call adds indirect branching overhead to the hot
cached-read path.
The typed cache fast-path stores a TypeId inline in each node at creation
time. Cached reads compare the stored TypeId directly (a single inline
u64 equality check) and, on match, use an unchecked pointer cast to recover
the typed reference. This eliminates the vtable indirection on every cached
slot and cell read for both Context and ThreadSafeContext.
Storage layout:
SlotNode.type_id: TypeId— set once atslot()/computed()/memo()creation fromTypeId::of::<T>()CellNode.type_id: TypeId— set once atcell()creationThreadSafeSlotFastPath.type_id: TypeId— set once at slot creationThreadSafeCellFastPath.type_id: TypeId— set once at cell creation
Read-path fast-path:
- Load the node’s
type_idfield (inlineu64load, no vtable) - Compare with
TypeId::of::<T>() - On match, cast the stored value pointer to
&Twithout going throughdyn Any::downcast_ref - On mismatch, panic with the same “type mismatch” message as before
get_rc() follows the same pattern for both computed and source handles but clones the
reference-counted pointer instead of cloning the inner value.
ThreadSafeContext::get_arc() is their Send + Sync analog. It reads the
authoritative Arc on the node rather than the cached-read sidecar (which
stores T by value and so has no box to share), which means it is a wash for
small Copy values and a win in proportion to the cost of cloning T.
The compute-function signature (dyn Fn(&Context) -> Rc<dyn Any> for
Context, dyn Fn(&ThreadSafeContext) -> Box<ThreadSafeAny> for
ThreadSafeContext) remains unchanged; the typed cache is a storage-side
optimization that does not affect the closure API.
Benchmark gate:
cached_reads/contextmust show a measurable improvement over thedowncast_refbaseline. The current baseline is approximately 8 ns per cached read; the typed cache target is to reduce this by the vtable call overhead (typically 0.5–1.5 ns on x86-64).cached_reads/thread_safe_context(single-thread, uncontended) is a bounded-regression gate, not a no-regression gate, as of the #vd5v lock-free read. Thearc-swapread sidecar may regress the uncontended cached read by up to ~3% in exchange for the high-contention win (read_mostly_waiters−6%/−29% at 8/16 workers; see Lock strategy evaluation → contention tradeoff). A regression beyond ~3% uncontended, or any regression in the 8/16-worker read-contention matrix, fails the gate and must be investigated. The earlier inline-TypeIdvtable-elimination win forcached_reads/context(single-threadedContext) is unaffected and must still hold.- The improvement must reproduce under controlled Criterion A/B comparison (same host, same toolchain, non-overlapping confidence intervals).
Future implications:
- Fully lock-free cached reads require typed storage so that a reader can
reconstruct a typed reference from an atomically published pointer without
vtable indirection. The inline
TypeIdis a prerequisite: it proves the type at compile time and makes the unchecked cast sound. Implemented (#vd5v):ThreadSafeSlotFastPath.valueis now anarc_swap::ArcSwapOption, soread_freshloads the published snapshot wait-free and recovers&Tvia the inlinetype_id— see Lock strategy evaluation above. - A future
ErasedValuestorage type could replaceRc<dyn Any>/Arc<dyn Any>entirely, storing the value inline for small types and avoiding heap allocation on compute. The current inline-TypeIdstep preserves theRc<dyn Any>/Arc<dyn Any>layout while unlocking the fast-path read optimization. Partially implemented forThreadSafeContextcached reads (#rdstrat2): the slot cached-read sidecar (CachedReadStorage::Inline) stores smallCopyvalues inline behind a wait-free seqlock — see Inline small-Copyseqlock below. The node still retains itsArc<dyn Any>value (the inline buffer is a read-acceleration duplicate); a fullErasedValuethat removes theArcfor non-Copytypes remains future work.
Inline small-Copy seqlock (#rdstrat2)
For small Copy values, the ThreadSafeContext slot cached-read sidecar
(ThreadSafeSlotFastPath.value) selects a third CachedReadStorage variant,
Inline, instead of the strategy-selected Locked/LockFree path. The value’s
bytes are stored inline in [AtomicU8; INLINE_CAP] (INLINE_CAP = 24,
alignment bound 16) behind a single-writer / multi-reader seqlock, with no
heap Arc, no refcount traffic on either read or publish. The inline path is
optimal under both ReadStrategy modes; the runtime mode only governs the
large / non-Copy fallback.
- Soundness conditions. Inline is chosen only when
T: Copyandsize_of::<T>() <= INLINE_CAPandalign_of::<T>() <= 16.Copyremoves anyDrop/ ownership hazard from a discarded torn read; the size/alignment bound keeps the byte copy in-bounds. The bytes are read/written with relaxed atomic per-byte operations (not a plainmemcpy), so a reader racing the single writer is well-defined under the Rust memory model — unlike a classic non-atomic seqlock, which has a formally-UB benign data race. The inlinetype_idprovesTso the validated byte snapshot can be reconstructed intoTwithout a vtable. - Single-writer invariant. Every
write(value publish inrecompute_slot_now, clear inapply_locked) runs while holding the graph state write lock, so writes are serialized; only reads are lock-free. Theseqcounter is even when stable, odd while a write is in progress; a reader observing an odd or changedseqdiscards its snapshot and retries. The closingReleasestore of the evenseqand the reader’s bracketing Acquire loads (with the canonical Acquire fence) make an accepted snapshot a consistent image of exactly one publish. - Same validation envelope. The inline path carries the identical atomic
cache_revision+dirty/force_recomputeenvelope as the other two strategies, so a read racing a publish/invalidation is rejected identically. - Opt-in constructors. Inline selection is not automatic on the generic
slot/computed/memoconstructors: stable Rust cannot branch onT: Copyinside a generic fn that lacks the bound (method resolution is pre-monomorphization, so aCopy-gated impl is never applicable where the bound is unprovable; automatic detection would require nightlyspecialization). The inline path is therefore opt-in through theCopy-boundedslot_copy/computed_copy/memo_copyconstructors, which fall back transparently to the strategy path when the value exceeds the inline size/alignment bound. - Loom gate. The seqlock orderings (single-writer publish vs. concurrent
lock-free readers, plus the
cache_revision/dirtyenvelope) are modeled bycargo test --features loom --test thread_safe_loom(inline_seqlock_reader_never_observes_torn_value,inline_seqlock_envelope_rejects_torn_and_stale_under_concurrent_publish,inline_seqlock_read_after_completed_invalidation_is_rejected). The two single-property models run unbounded (exhaustive); the combined envelope model uses a preemption-boundedBuilder(bound 4, 60 s duration cap) because the full 6-atomic envelope across two threads plus the driving body makes the unbounded permutation space non-terminating — the bound is validated by confirming the model still flags an injected torn-read regression, satisfying the lock-strategy Loom gate before landing.
Tokio integration is scoped in two stages:
- Synchronous thread-safe sharing first:
ThreadSafeContextshould work insidetokio::spawnandtokio::task::spawn_blockingwhen all captured values and callbacks satisfy theSend + Syncbounds above. This is exposed behind the optionaltokiofeature with async tests and thetokio_syncexample; it must not introduce async compute/effect semantics. - True async computations/effects are separate future work. They need explicit
semantics for in-flight future deduplication, cancellation, dependency tracking
across
.await, stale future completion, cleanup ordering, andSendversusLocalSetfutures.
AsyncContext
True async support is a new explicit async context surface, not an overload of
Context or ThreadSafeContext. The AsyncContext API lives behind a separate
async feature flag so downstream users do not accidentally accept the larger
semantic surface. The async feature depends on tokio for runtime primitives
(spawn, JoinHandle, notification).
AsyncContext type definitions
#![allow(unused)]
fn main() {
pub struct AsyncContext {
inner: Arc<AsyncContextInner>,
}
pub struct AsyncComputed<T> {
id: SlotId,
_marker: PhantomData<T>,
}
pub struct AsyncSource<T> {
id: SlotId,
_marker: PhantomData<T>,
}
pub struct AsyncEffectHandle {
id: SlotId,
}
pub struct AsyncComputeContext<'a> {
context_id: AsyncContextId,
node_id: SlotId,
inner: &'a AsyncContextInner,
}
}
AsyncContext API surface
| Method | Signature | Purpose |
|---|---|---|
new | fn new() -> Self | Create a new async context |
source | fn source<T>(&self, value: T) -> AsyncSource<T> | Create a mutable source (T: PartialEq + Clone + Send + Sync + 'static) |
get (source) | fn get<T>(&self, handle: &AsyncSource<T>) -> T | Get source value synchronously through the unified Read API |
set | fn set<T>(&self, handle: &AsyncSource<T>, value: T) | Update a source and invalidate dependents |
computed_async | fn computed_async<T, F, Fut>(&self, compute: F) -> AsyncComputed<T> | Create an async computed slot |
get (computed) | fn get<T>(&self, handle: &AsyncComputed<T>) -> Option<T> | Synchronous cached read; returns Some(T) if resolved, None otherwise. Avoids async overhead on warm paths |
get_async | async fn get_async<T>(&self, handle: &AsyncComputed<T>) -> T | Await slot value; uses get() fast-path for resolved slots, otherwise spawns async compute |
memo_async | fn memo_async<T, F, Fut>(&self, compute: F) -> AsyncComputed<T> | Like computed_async with PartialEq memo guard |
effect_async | fn effect_async<F, Fut, C, CleanupFut>(&self, effect: F) -> AsyncEffectHandle | Create an async effect |
dispose_async_effect | fn dispose_async_effect(&self, handle: &AsyncEffectHandle) | Dispose async effect and await cleanup |
batch | fn batch<F, R>(&self, run: F) -> R | Synchronous batch boundary; schedules async reruns at batch exit |
API bounds:
| Method family | Additional bounds |
|---|---|
get | T: Clone + Send + Sync + 'static |
source, source get/set | T: PartialEq + Clone + Send + Sync + 'static |
computed_async, memo_async | T: PartialEq + Clone + Send + Sync + 'static; compute Fn(AsyncComputeContext) -> Fut + Send + Sync + 'static; future Future<Output = T> + Send + 'static |
effect_async | effect Fn(AsyncComputeContext) -> Fut + Send + Sync + 'static; future Future<Output = Option<C>> + Send + 'static; cleanup FnOnce() -> CleanupFut + Send + 'static; cleanup future Future<Output = ()> + Send + 'static |
| handles | remain id-only and copyable; usable from any task only with the owning AsyncContext |
AsyncSlotNode state machine
Each async slot tracks its state through a finite state machine:
#![allow(unused)]
fn main() {
enum AsyncSlotState {
Empty,
Computing { revision: u64, handle: JoinHandle<()> },
Resolved,
Error,
}
}
States:
- Empty: no cached value, no in-flight computation. Entered on creation and after hard clear.
- Computing: a
JoinHandletracks the in-flight future for the current revision. Concurrentget_asynccallers attach waiters to the same in-flight result instead of spawning duplicate futures. - Resolved: the cached value is fresh. The value remains until dependency invalidation transitions back to Computing.
- Error: the last computation failed. Callers receive the error or retry on
the next
get_async.
State transitions:
- Empty → Computing: first
get_asynccall or dependency invalidation when no cached value exists. - Computing → Resolved: future completes with
Ok, and the recorded revision still matches the current slot revision. The value is cached. - Computing → Error: future completes with
Err, and the recorded revision still matches. - Computing → Computing (stale): dependency invalidation advances the slot revision during an in-flight computation. The completing future finds its revision no longer matches and discards the result. A new future is spawned for the updated revision.
- Resolved → Computing: dependency invalidation marks the cached value stale and spawns a new computation.
- Error → Computing:
get_asyncretry after an error.
Revision tracking ensures stale completions are discarded: an async computation records the slot revision at start; at publish time the graph accepts the value only if the revision is still current.
AsyncContext cancellation contract
-
Waiter cancellation is safe: dropping one
get_asyncfuture does not cancel the shared in-flight computation while other waiters still need it. Each waiter holds a shared handle (e.g., oneshot receiver orShared<...>); dropping the receiver does not abort theJoinHandle. -
Stale completion handling: when dependency invalidation advances the slot revision during an in-flight computation, the completing future finds its recorded revision no longer matches and discards the result. Waiting callers are retried against the new revision or attached to the newly spawned future.
-
Explicit cancellation:
slot.clear(), dependency invalidation, or context disposal may mark the in-flight revision as canceled. If the runtime provides an abort handle, the task is aborted. User futures must be cancellation-safe because aborting drops them at an.awaitboundary. -
Context disposal: dropping the
AsyncContextcancels all in-flight computations via theirJoinHandle::abort()handles and awaits completion of all active cleanup futures before returning. -
Effect cleanup futures must complete before the next effect body starts. Disposal removes pending reruns before awaiting cleanup.
get_async re-resolve contract (#k03k)
get_async must treat the slot state as authoritative and re-resolve rather
than assert, because the slot can change between its lock acquisitions and a
notifier can close under it. It runs an outer loop that, each pass, re-reads the
slot via the get() fast path and then re-locks to attach to / spawn a
computation. Two concurrency windows are load-bearing:
- Resolved-since-
get(): the slot can transitionComputing → Resolvedbetween theget()fast-path check (which releases the lock) and the re-lock. ObservingResolvedat the re-lock is therefore expected and the cached value is read directly — it is not an unreachable state. - Notifier dropped: the per-computation
watchsenders can all drop without a finalResolvedsend when an in-flight compute is superseded by a newer revision (the staleComputing → Computingtransition early-returns) or the slot is invalidated. Arecv.changed()error means “the world changed”, not a fatal error: the awaiter restarts the outer loop and re-resolves from current slot state (returning the now-published value, attaching to the new in-flight compute, or respawning).
Neither window is a data inconsistency — the published value is always correct;
the contract is that get_async never panics on these benign races. Covered by
async_context_concurrent_set_and_get_async_never_panics_k03k, which fails
deterministically against the prior assert-based implementation.
Deterministic window coverage. Unlike ThreadSafeContext, the async resolve
loop cannot be modeled with Loom: AsyncContext runs on tokio’s async
executor and tokio::sync::watch, while Loom only shims synchronous loom::sync
primitives and has no async runtime. Each window is instead pinned by a targeted
deterministic test in tests/async_resolve_loop.rs (async + instrumentation
features):
- Window 1 is forced via a one-shot
instrumentation-gated seam (AsyncContext::__install_window1_hook) that resolves the slot inside the synchronous gap between the fast-pathget()and the re-lock — the gap has no.await, so cooperative scheduling alone cannot reach it. The test asserts the reader returned through theResolved-after-re-lock arm via__window1_resolved_hits. The seam compiles out of default/release builds. - Window 2 is forced by gating an in-flight compute and superseding it with a
newer revision so the notifier drops without a final send, asserting the waiter
re-resolves to the latest value rather than panicking (mirrors the broader
async_stress.rs::get_async_waiter_cancellation_and_stale_completion_keep_latest).
Exhaustive interleaving exploration of the async path (beyond these two known
windows) would require a Shuttle model, which in turn requires making
async_context.rs generic over its concurrency primitives — a larger
architectural change tracked separately, not a Loom drop-in.
Async race stress coverage must exercise get_async waiter cancellation, stale
in-flight completion after dependency invalidation, dynamic dependency
replacement across awaited slot reads, and async effect cleanup-before-rerun
ordering. The harness lives in tests/async_stress.rs under the async feature
so make test-async and make check run it with the rest of AsyncContext
coverage.
AsyncContext dependency tracking
Async compute and effect callbacks do not use thread-local tracking stacks.
Instead, each callback receives an AsyncComputeContext:
#![allow(unused)]
fn main() {
impl<'a> AsyncComputeContext<'a> {
pub async fn get_async<T>(&self, handle: &AsyncComputed<T>) -> T;
pub fn get<T>(&self, handle: &AsyncSource<T>) -> T;
}
}
get_asyncon the compute context records the accessed slot as a dependency before awaiting its value.geton the compute context records the accessed cell as a dependency synchronously.- Async reads register the graph edge immediately, so source invalidation while the future is suspended can cancel or supersede the in-flight computation before it publishes stale data.
- Dependencies are collected into a
HashSet<SlotId>attached to the async node. On rerun, stale dependencies are removed and new dependencies are registered. - This design survives executor thread migration and suspension/resume across
.awaitpoints because the dependency set is carried by theAsyncComputeContext, not a thread-local.
AsyncContext async effects
#![allow(unused)]
fn main() {
fn effect_async<F, Fut, C, CleanupFut>(&self, effect: F) -> AsyncEffectHandle
where
F: Fn(AsyncComputeContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Option<C>> + Send + 'static,
C: FnOnce() -> CleanupFut + Send + 'static,
CleanupFut: Future<Output = ()> + Send + 'static;
}
- Serialized reruns: async effect reruns are serialized per effect. A rerun does not start until the previous cleanup future completes.
- Cleanup ordering: the cleanup future from the previous run completes before the next effect body starts. Disposal awaits the current cleanup before removing the effect node.
- Auto-tracking: the effect body receives an
AsyncComputeContextand tracks dependencies throughget_asyncandgetcalls. - Dependency invalidation schedules an async rerun after the current invalidation pass. The rerun is spawned on the runtime executor, not inline.
- Effect disposal: removes pending scheduled reruns, awaits the current cleanup future, and unsubscribes dependency edges.
AsyncContext batch support
ctx.batch()is a synchronous boundary. Cell updates queue invalidation roots.- At batch exit, queued roots trigger invalidation propagation. Async slots and effects are scheduled for rerun but do not execute inside the batch callback.
- Async reruns execute after the batch returns, on the runtime executor.
- Batching semantics remain synchronous at the graph mutation boundary: invalidations schedule async reruns only after the outermost batch exits.
AsyncContext feature flag
[features]
async = ["dep:tokio"]
The async feature depends on Tokio for spawn, JoinHandle, and runtime
primitives. It is separate from the tokio feature (which covers synchronous
ThreadSafeContext sharing inside Tokio tasks). The async feature implies
tokio.
Integration tests live in tests/async_integration.rs and are gated behind
#![cfg(feature = "async")]. Run with make test-async (or
cargo test --locked --features async). The make check target includes
test-async alongside test, test-tokio, and test-loom.
AsyncContext implementation notes
- Async graph locks must never be held while polling user futures or cleanup futures. Acquire the lock only to read/write graph state, then release before polling.
- Nested async slot reads register dependencies on the awaiting parent before awaiting the child result.
- The sync
tokiofeature is not enough to enable this API; true async support uses the separateasyncfeature flag. - The
Sendasync context requiresSend + Sync + 'staticvalues, callbacks, futures, and cleanup futures. A futureLocalAsyncContextmay support!Sendfutures ontokio::task::LocalSet, but handles must not be interchangeable with theSendasync context. - In-flight future deduplication: each async slot has one published cache and
at most one in-flight computation for the current slot revision. Concurrent
get_asynccallers await the same in-flight result instead of spawning duplicate futures. - Synchronous cached-read fast-path:
get()returns the cached value synchronously when the slot isResolved, avoiding async overhead.get_async()callsget()first; only unresolved or dirty slots enter the async spawn path.
Invalidation Semantics
ctx.set()→ if value changed (PartialEq) → mark all dependent slots dirtyslot.clear(&ctx)→ remove cached value → cascade clear to all dependentscell.clear_dependents(&ctx)→ clear all dependent slots without changing cell valuectx.batch()→ queue changed cells and explicit slot/cell clears, then flush queued roots when the outermost batch exits- Slot invalidation → preserve cached value as dirty → validate/recompute on next
ctx.get()access - Thread-safe slot invalidation walks an explicit coalesced frontier, so diamond paths mark each reachable slot at most once per invalidation pass unless a later direct changed-value path upgrades that slot to forced recompute
- If a dirty
ctx.memo()slot recomputes to an equal value, downstream dirty slots become fresh without recomputing - Slot clearing → remove cached value → hard-clear dependents recursively
- Effects rerun after the invalidation pass if any tracked dependency invalidated
- Effects scheduled only by dirty slot dependencies skip rerun if those slots validate unchanged
- Effect cleanup runs before rerun and on disposal
Design Goals
- Lazy evaluation: Values computed only when first accessed or when dirty caches are validated
- Ergonomic derived values:
ctx.computed()is the preferred spelling for ordinary derived slots - Fine-grained reactivity: Only affected dependents recompute
- Memoized invalidation: Equal intermediate
ctx.memo()recomputation suppresses downstream recomputation/effect reruns - Effects: Side effects are scheduled from the same dependency graph as slots
- Batching: Multiple writes can share one invalidation/effect flush boundary
- Zero mandatory runtime dependencies: The default library surface uses only the Rust standard library and
smallvec; Tokio is optional and Criterion is dev-only for benchmarks - Single-threaded fast path:
Contextuses a singleRefCell<ContextInner>with no mutex overhead and nounsafecode - Contiguous local storage: Both
ContextandThreadSafeContextindex nodes directly bySlotIdin aVec<Option<Node>>to avoid hash-map lookup and churn; slot IDs are reused via a free list to prevent unbounded growth from transient effects - Explicit thread-safe path:
ThreadSafeContextuses a context-level lock andSend + Syncbounds for shared reactive graphs - Performance tracking: Criterion benchmarks cover both
ContextandThreadSafeContextfor cached reads, cold first access, dependency fan-out, memo equality suppression, effect flushing, and batch storms;ThreadSafeContextalso tracks a 1/2/4/8/16-worker contention matrix that separates hot shared-slot writes, independent per-worker slots, read-mostly waiters, and batched write bursts. - Benchmark instrumentation: The optional
instrumentationfeature exposes lightweight counters for recompute starts, duplicate speculative thread-safe computes, dependency edge churn, effect queue depth, reactive node allocations, aggregateThreadSafeContextlock wait/hold timing, and per-operation thread-safe lock attribution.
Performance Benchmarks
The benchmark suite is a development-only surface under benches/context.rs.
It must compile with cargo bench --no-run and should remain focused on public
API behavior rather than private graph internals.
Required benchmark scenarios:
- Cached reads after the slot has already been computed
- Cold first
getincluding graph construction and initial dependency capture - Dependency fan-out invalidation followed by dependent reads
- Memo equality suppression where an equal intermediate value prevents downstream recomputation
- Effect flushing after dependency mutation
- Batch storms that coalesce many writes into one invalidation/effect flush boundary
ThreadSafeContextsetinvalidation isolation, split into:- high fan-out changed-cell invalidation without dependent reads
- same-root/same-slot write contention without dependent reads
- independent per-worker roots and slots without dependent reads
- batched write bursts over per-worker cell groups without dependent reads
ThreadSafeContextcontention at 1, 2, 4, 8, and 16 workers, split into:- same-root/same-slot write plus read contention
- independent per-worker roots and computed slots
- read-mostly waiters with one writer and many readers
- batched write bursts over per-worker cell groups
ThreadSafeContexteffect-heavy contention at 8 and 16 workers, split into:- effect queue coalescing across batched worker writes
- effect cleanup execution during concurrent cell updates
- nested batch flushes that schedule computed-effect dependencies
ThreadSafeContextsynchronization model checking with the optionalloomfeature
The optional instrumentation feature adds instrumentation_snapshot() and
reset_instrumentation() to both context types and exports
InstrumentationSnapshot. The snapshot records:
- Reactive node allocation events as a stable allocation proxy
- Slot recompute callback starts
- Duplicate speculative
ThreadSafeContextrecomputes that lose publication races; this should remain zero when in-flight deduplication is effective - Dependency edges added and removed
- Effect queue pushes and maximum pending queue depth
ThreadSafeContextlock/coordination acquisitions plus total wait and hold nanoseconds
ThreadSafeContext::lock_profile_snapshot() returns per-operation lock and
coordination counters for the thread-safe path. The buckets are intentionally
high-level: unattributed/other work, get refresh, dependency edge add/remove,
set invalidation, recompute publication, and in-flight recompute waiting.
For the per-slot sidecar recompute Condvars, the in-flight wait bucket records
the parked wait and reacquire boundary. The bucket acquisition counts must sum
to the aggregate lock_acquisitions counter so profile consumers can attribute
contention without losing the stable summary fields.
The instrumentation profile bench lives in benches/profile.rs and is gated
behind required-features = ["instrumentation"]; compile it with
cargo bench --features instrumentation --no-run.
The benchmark report harness lives at
scripts/update-benchmark-results.py. It runs
cargo bench --features instrumentation, reads Criterion estimate files from
target/criterion, captures examples/instrumentation_profile.rs counter
snapshots in target/lazily-instrumentation-profile.csv, and rewrites the
generated README section between
<!-- benchmark-results:start --> and <!-- benchmark-results:end -->.
The generated section must include the current Cargo package version, the
refresh command, the Criterion baseline comparison workflow, one timing row for
each required benchmark scenario above, p50/p95 Criterion sample latency rows
for the required 8/16-worker same-slot, independent-slot, read-mostly,
batched-write, and effect-heavy cases, and instrumentation rows covering
recomputes, duplicate speculative recomputes, dependency edge churn, effect
queue depth, node allocations, lock wait/hold time, and per-operation
ThreadSafeContext lock attribution for every 1/2/4/8/16-worker contention
matrix profile and every set invalidation isolation profile. The generated
section must also publish regression budgets for the slow contention profiles and
their lock-site acquisition totals. --check verifies that the README section is
already current without rewriting it and fails when required p50/p95 latency
rows are missing or any instrumentation profile exceeds its lock-acquisition
budget; --no-run reuses existing Criterion estimate files for a report-only
refresh after a manual baseline comparison run while refreshing the
instrumentation CSV unless it is also running in check mode.
Serialization (lazily-serde feature gate)
The serde feature is declared in Cargo.toml but currently has no
implementation. Its purpose is to produce a serializable snapshot of context
state so the planned lazily-ipc (snapshot + incremental update protocol) and
lazily-distributed (CRDT/Raft remote graphs) layers have a stable on-the-wire
representation to build on. This section fixes the design of that feature gate
before any code lands.
The type-erasure problem
Context and ThreadSafeContext store cached values as type-erased trait
objects (Rc<dyn Any> and Arc<dyn Any + Send + Sync>; see Typed cache
fast-path). serde::Serialize is not object-safe — it has a generic
serialize<S: Serializer> method — so a dyn Any cannot be serialized
directly, and the concrete type T is gone by the time a snapshot walks the
node Vec. Any serialization design must recover the ability to call a
monomorphized Serialize/Deserialize for each node’s erased T. Two
approaches were considered.
Approach A — trait bounds (erased-serde style)
Replace dyn Any cache storage with a serialize-aware trait object — a sealed
dyn ReactiveValue: Any whose vtable also carries an erased_serde-style
erased_serialize(&self, &mut dyn Serializer). Every signal-creation API
(slot, computed, memo, cell) gains a T: Serialize + DeserializeOwned
bound under #[cfg(feature = "serde")].
- Pros: serialization is total — every cached node is serializable with no per-node opt-in; one storage type, one source of truth.
- Cons:
- The
T: Serializebound is viral: it propagates through the whole public API and leaks ontoContextitself even for signals that are never serialized, forcing callers to satisfy it for purely local reactive state. - Requires either the
erased-serdecrate or a hand-rolled vtable equivalent — acceptable under a feature gate but still added surface. - Breaks the typed cache fast-path. That optimization recovers
&Tvia an unchecked pointer cast keyed on an inlineTypeId; swapping the trait object out from under it for a serialize-aware type would have to preserve the same unchecked-cast guarantee. Deserializeis the hard half: reconstruction needs the concrete type at the call site, so a type-tag → constructor registry is required anyway — Approach A does not avoid the registry, it only adds the viral bound on top of it.
- The
Approach B — type-erased closures captured at creation (recommended)
Keep dyn Any storage untouched. At signal creation under the serde feature,
capture a monomorphized serde vtable — a small &'static struct of
function pointers — alongside the TypeId the typed cache fast-path already
records:
#![allow(unused)]
fn main() {
#[cfg(feature = "serde")]
pub struct SerdeVTable {
pub type_tag: &'static str, // stable cross-process key
pub serialize: fn(&dyn Any, &mut dyn erased_serde::Serializer)
-> Result<(), erased_serde::Error>,
pub deserialize: fn(&mut dyn erased_serde::Deserializer)
-> Result<Rc<dyn Any>, erased_serde::Error>,
}
}
Each SlotNode/CellNode (and the ThreadSafe*FastPath mirrors) gains a
#[cfg(feature = "serde")] serde_vtable: Option<&'static SerdeVTable> field,
set once at slot() / computed() / memo() / cell() creation. The thunks
are monomorphized per T at the construction call site, so the concrete type
is captured exactly where it is still known; invoking serialize downcasts the
&dyn Any back to &T and calls the real T: Serialize.
- Pros:
- Composes with the typed cache fast-path — both capture per-
Tmetadata at creation; the read hot path is untouched and pays zero overhead (thunks run only at snapshot time). - Opt-in per node. A signal whose
T: !SerializestoresNoneand is emitted as anOpaqueplaceholder, matching howlazily-ipcwill snapshot only an explicitly shared subgraph rather than the whole context. No viral bound onContext. - The
deserializethunk doubles as the type-tag → constructor registry Approach A needs anyway; populating it at slot construction keeps tags and constructors in one place.
- Composes with the typed cache fast-path — both capture per-
- Cons:
- Per-node storage grows by one pointer (
Option<&'static SerdeVTable>, 8 bytes) — eliminated entirely when theserdefeature is off via#[cfg(feature = "serde")]on the field. - Without specialization on stable Rust, “serialize if
T: Serialize, elseNone” needs a sealedMaybeSerialize<T>autoref/marker helper or explicit*_serdeconstructor variants rather than a blanket impl.
- Per-node storage grows by one pointer (
Decision
Adopt Approach B. The lazily-ipc/lazily-distributed roadmap serializes
an explicit allowlisted RemoteOp set (#39c5), not arbitrary nodes, so the
totality Approach A buys is unneeded — and it costs a viral bound plus a
rework of the typed cache fast-path to buy it. Approach B keeps the default
build byte-for-byte identical (field compiled out), preserves the zero-overhead
read path, and reuses the TypeId-at-creation pattern already proven by the
typed cache.
Feature-gate shape
serde = ["dep:serde"](declared) gainsdep:erased-serdeunder the same gate; both stay optional, preserving the zero mandatory runtime dependency goal.- A sealed
MaybeSerialize<T>helper resolves the vtable toSomewhenT: Serialize + DeserializeOwnedandNoneotherwise, so existing constructor signatures are unchanged and non-serializable signals keep compiling. Context::snapshot()/ThreadSafeContext::snapshot()walk live nodes, invoke each present vtable, and emitContextSnapshot { nodes: Vec<NodeSnapshot { slot_id, type_tag, payload | Opaque }> }. Restoration readstype_tag, looks up thedeserializethunk, and rebuilds typed cache entries.- This snapshot type is the input to #ipc2 (snapshot + incremental update protocol) and the value substrate for #ipc3 (CRDT vs Raft).
IPC snapshot + incremental update protocol (lazily-ipc)
lazily-ipc transmits a reactive graph’s state to a remote observer and keeps
it in sync as the graph mutates. It builds directly on the ContextSnapshot
from the lazily-serde design and reuses the existing batch-flush and
cache-revision machinery as its consistency boundary rather than inventing a
new one.
Two message kinds
Snapshot— full graph state. Sent on connect and on resync.Delta— incremental change set. Sent once per outermost batch-flush invalidation pass — the single atomic graph-mutation boundary the thread-safe path already guarantees (see Threading and Concurrency Contract: “Batch exit … a single atomic graph mutation boundary and one coalesced effect flush per outermost invalidation pass”). Coalescing is therefore free: one delta per flush, never one perset.
Epoch / versioning
A context-level monotonic ipc_epoch: u64 advances once per outermost batch
flush, not per write. It is independent of the per-slot cache_revision
atomics (which remain the read-path dirty epoch) — ipc_epoch is the wire
sequence number.
Snapshotcarriesepoch.- Each
Deltacarries{ base_epoch, epoch }withepoch == base_epoch + 1. Deltas are strictly sequential, so a receiver detects any gap, reorder, or sender restart by checkingbase_epoch == last_epoch.
Payloads
Snapshot { epoch: u64, nodes: Vec<NodeSnapshot>, // NodeSnapshot from lazily-serde
edges: Vec<(SlotId /*dependent*/, SlotId /*dependency*/)>,
roots: Vec<SlotId> } // cells + source slots
Delta { base_epoch: u64, epoch: u64, ops: Vec<DeltaOp> }
DeltaOp =
| CellSet { slot_id, payload } // changed-value cell write (PartialEq-guarded)
| SlotValue { slot_id, payload } // a recompute published a new value
| Invalidate { slot_id } // dirtied, not yet recomputed (lazy)
| NodeAdd { slot_id, type_tag, payload | Opaque }
| NodeRemove { slot_id } // freed slot id (free-list reuse → Remove then Add)
| EdgeAdd { dependent, dependency }
| EdgeRemove { dependent, dependency }
Consistency invariants (inherited, not re-derived)
- PartialEq cell guard: an equal
setinvalidates nothing, so it emits noCellSetand no downstream ops — the wire is silent exactly when the graph is (SPEC Invalidation Semantics). - memo equality suppression: a dirty
memo()that recomputes to an equal value emits noSlotValueand no downstreamInvalidate, mirroring the local “downstream dirty slots become fresh without recomputing” rule. - Coalesced frontier: a dependent reached through many changed cells in one batch appears at most once per delta — the same once-per-pass guarantee the thread-safe frontier already enforces.
Lazy reconciliation
Because lazily-rs is lazy, a flush can invalidate a slot without producing a new value. Two receiver modes:
- Value-mirror (default for IPC): at flush the sender resolves each
invalidated allowlisted slot via
ctx.get()so the delta carries concreteSlotValues. The receiver stays a pure data mirror holding no compute closures. Trades local laziness for a value-complete wire image. - Mirror-lazy: the sender emits bare
Invalidateand the receiver keeps a stale marker, recomputing only on its own read. This requires the compute closures to be replicated too and is therefore deferred tolazily-distributed(#ipc3), notlazily-ipc.
Resync / gap handling
The receiver tracks last_epoch. On a Delta whose base_epoch != last_epoch
(gap, reorder, or sender restart) it discards the delta and requests a
Snapshot; the sender replies with a fresh Snapshot { epoch } and resumes
deltas from there. Messages are length-prefixed and tagged Snapshot / Delta
via serde/erased-serde; the protocol is transport-agnostic (unix socket,
pipe, WebSocket — the last feeds the #yxjw signaling server).
Permission boundary (forward link to #39c5)
Only nodes on the per-peer allowlist (#39c5 RemoteOp) are serialized into a
snapshot or delta; non-allowlisted nodes are omitted entirely — not even as
Opaque — so a peer cannot infer their existence. The allowlist is applied at
snapshot/delta construction, before serialization, so the filter is the same
on the full and incremental paths.
Implemented (#39c5)
The permission policy layer ships behind the distributed feature in
src/distributed.rs:
NodeId/PeerId— wire-stable identifiers (decoupled from the internalSlotId),serde-derived under theserdefeature.OpKind(Read/Write/TriggerEffect) andRemoteOp { kind, node }— the gated, serializable unit a peer requests; the three kinds are gated independently (a read grant never implies write or effect-trigger).PeerPermissions— default-deny per-peer allowlist withallow,allow_many,revoke(prunes empty peer entries),revoke_peer,is_allowed, and a fail-closedcheck→Result<(), PermissionDenied>.filter_readable(peer, nodes)enforces the omission invariant above: non-readable nodes are dropped from the result entirely, preserving input order, so it can be applied at snapshot/delta construction before serialization.
PeerPermissions is local server-side state and is intentionally not
serializable; only the wire-facing RemoteOp family is. Higher layers
(lazily-ipc snapshot/delta construction, the lazily-distributed CRDT cell
plane, and the single-writer effect authority) gate every remote request
through check and build observable subgraphs through filter_readable.
Feature gate
A new ipc = ["serde"] feature adds the pure-protocol Snapshot/Delta types
plus a transport-agnostic IpcSink / IpcSource trait pair. No transport
dependency enters the core crate. The Delta/ipc_epoch model is a
single-writer linear log; whether multi-writer needs CRDT merge or Raft
consensus on top of that log is exactly the #ipc3 question.
Implemented surface:
Snapshot { epoch, nodes, edges, roots },NodeSnapshot,NodeState, andEdgeSnapshotdefine the full graph image.Delta { base_epoch, epoch, ops }andDeltaOpdefine the one-flush incremental image.Delta::next(base_epoch, ops)enforcesepoch == base_epoch + 1;Delta::apply_status(last_epoch)returnsApplyorResyncRequired.Snapshot::filter_readableandDelta::filter_readableapplyPeerPermissionsbefore serialization. Non-readable nodes and operations are omitted entirely; edges are retained only when both endpoints are readable.IpcMessage,IpcSink, andIpcSourcekeep Unix sockets, pipes, WebSockets, and shared-memory ring buffers outside the core crate.ShmBlobArena,ShmBlobRef, andIpcValue::SharedBlobprovide the shared memory payload path. The arena writes a fixed header before each payload with generation, epoch, length, and checksum metadata; readers validate that header before accepting a descriptor.IpcMessagecontrol frames can carry aShmBlobRefinstead of embedding large bytes inline.- Cross-process zero-copy transport (
#lzzcpy): theBlobBackendtrait (src/transport.rs) is the pluggable-backend adapter seam. A producer callsspill_message(&mut msg, &mut backend, threshold)to replace largeInline/Payloadsites with aSharedBlobdescriptor; a receiver resolves via aBlobRouterthat routes by the descriptor’sbackenddiscriminator.InProcessBackendwrapsShmBlobArena(in-process / FFI host);ArrowBackendholds Arrow IPC stream bytes;ShmBackend(POSIXshm_open+mmap, behind theshmfeature) is the cross-process backend. TheShmBlobRefgained an optionalbackendfield (BlobBackendKind::Shm|Arrow|InProcess, defaultShm) so legacy descriptors validate unchanged. The formal laws (spill-then-resolve identity, backend isolation, ABA generation safety, checksum integrity) are proven for any backend inlazily-formal/LazilyFormal/ZeroCopyTransport.lean.
Formal companion: lazily-spec/formal/lean models the shared IPC
Snapshot/Delta state machine in Lean 4 and proves the epoch sequencing,
fail-closed resync, PartialEq/memo suppression, batch coalescing, and eager
Signal slot_value invariants. It is intentionally a spec-layer oracle; Rust
implementation behavior remains covered by the crate tests and conformance
fixtures.
Shared-memory IPC is therefore a supported transport direction, not a separate
reactive-graph mode: the shared memory segment carries large blob payloads, and
the ordinary control transport carries framed IpcMessages with blob
descriptors. Each process keeps its own local Context / ThreadSafeContext
and reconciles via snapshots and deltas. A live Context is not shared across
process address spaces.
Cross-language channel compatibility (FFI / IPC / WebSocket / WebRTC data)
Yes: lazily-rs has a viable FFI strategy, but the FFI layer should be an
adapter around the same transport-agnostic state plane used by IPC and
distributed peers. It should not expose the closure-based Rust Context,
ThreadSafeContext, Computed<T>, Source<T>, or &T cached values
directly across an ABI boundary.
Compatibility model
The cross-language lazily family has one canonical message plane:
IpcMessage::SnapshotandIpcMessage::Deltaare the graph-state payloads.NodeId,PeerId,RemoteOp,Snapshot,Delta, andDeltaOpare the wire-facing contract; internalSlotIdvalues and typed handles remain local implementation details.IpcPayloadis opaque serialized value bytes. The producing language owns type-aware encoding through stabletype_tags; the channel only moves bytes.ShmBlobRefis a descriptor carried by a control frame. Shared memory stores large payload bytes, but reconciliation still happens through ordinaryIpcMessages.
Every supported channel carries that same message plane:
| Channel | Compatibility strategy |
|---|---|
| FFI | C ABI exposes opaque context/session handles plus owned byte buffers for IpcMessage encode/decode, snapshot export, delta apply, and memory release. No Rust references, trait objects, closures, or typed handles cross the boundary. |
| IPC | Unix sockets, pipes, local TCP, or process channels carry length-prefixed serialized IpcMessages. Shared-memory IPC is an optimization for large IpcValue::SharedBlob payload bytes, not a separate graph-sharing mode. |
| WebSocket | One WebSocket frame carries one serialized IpcMessage or a negotiated fragment. The #yxjw signaling server may relay the frame as opaque payload and must not parse CRDT/IPC state. |
| WebRTC data | Reliable ordered data channels carry the same serialized IpcMessages after #yxjw peer discovery. Unordered or unreliable channels are only acceptable for optional lossy telemetry; Deltas need ordered reliable delivery or receiver-side gap detection and snapshot resync. |
FFI boundary shape
The Rust FFI surface is deliberately narrow:
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct LazilyFfiBytes {
pub ptr: *mut u8,
pub len: usize,
}
#[repr(C)]
pub enum LazilyFfiStatus {
Ok = 0,
Empty = 1,
NullPointer = 2,
InvalidMessage = 3,
EncodeFailed = 4,
Panic = 5,
}
#[repr(C)]
pub enum LazilyFfiMessageKind {
Unknown = 0,
Snapshot = 1,
Delta = 2,
}
}
The ffi feature exports extern "C" functions for creating/freeing an opaque
LazilyFfiChannel, validating/classifying encoded IpcMessage frames,
enqueueing accepted frames, receiving Rust-owned LazilyFfiBytes frames, and
freeing buffers allocated by Rust. All allocation ownership is explicit: the
caller owns input bytes, Rust owns output buffers until the paired free function
is called. Errors return LazilyFfiStatus; panics must be caught before crossing the C ABI.
The implemented channel is a local ABI adapter. It decodes each accepted frame
as IpcMessage, stores the decoded message, then re-encodes with the requested
receive/clone codec. That keeps FFI byte transport aligned with IPC, WebSocket,
and WebRTC data transport while leaving snapshot export, delta application to a
live graph, and richer typed convenience APIs as higher-level work on top of the
same message plane.
The FFI layer may expose convenience helpers for local cells, but those helpers
still encode/decode through the same type_tag + payload registry used by
lazily-serde. A foreign runtime can therefore choose either direct local FFI
calls or framed IPC/WebSocket/WebRTC transport without changing the graph-state
protocol.
Shared library build
The crate produces both an rlib (for Rust consumers) and a cdylib (for
FFI consumers) via crate-type = ["lib", "cdylib"] in Cargo.toml.
make build-ffibuilds the shared library with theffifeature enabled.make ffi-headersgenerates a C header file (target/lazily.h) viacbindgenusingcbindgen.toml.- Future options include
safer-ffi(safer FFI wrappers with auto-generated headers) anddiplomat(multi-language binding generation). The currentcbindgenapproach is sufficient for C ABI consumers.
Frame codecs (ffi / webrtc / ipc-msgpack / ipc-binary features)
IpcMessage frames have one semantic schema and several negotiated codecs:
IpcMessage::encode_json()/IpcMessage::decode_json()— JSON encode/decode (gated behindffiorwebrtc, which pull inserde_json).IpcMessage::encode_msgpack()/IpcMessage::decode_msgpack()— named MessagePack encode/decode viarmp-serde(ipc-msgpack). This is the preferred cross-language binary codec because field names survive the frame.IpcMessage::encode_binary()/IpcMessage::decode_binary()— postcard encode/decode (ipc-binary). This is compact but not self-describing.IpcCodec— negotiated codec token (json,msgpack,postcard) used by transports such as WebRTC.- FFI binary functions:
lazily_ffi_channel_send_binary,lazily_ffi_channel_recv_binary,lazily_ffi_ipc_message_validate_binary,lazily_ffi_ipc_message_kind_binary,lazily_ffi_ipc_message_clone_binary— mirror the JSON FFI functions but use the postcard codec. - FFI MessagePack functions:
lazily_ffi_channel_send_msgpack,lazily_ffi_channel_recv_msgpack,lazily_ffi_ipc_message_validate_msgpack,lazily_ffi_ipc_message_kind_msgpack,lazily_ffi_ipc_message_clone_msgpack— mirror the JSON FFI functions but use named MessagePack. EncodeError/DecodeError— codec-agnostic error types withJson,Msgpack, andBinaryvariants gated by their respective features.
JSON remains the canonical/debug/default form. MessagePack is the cross-language production binary form. Postcard remains for same-Rust or explicitly postcard-aware peers.
WebRTC data channel transport (webrtc-data feature)
WebRTC data channels carry IpcMessage frames peer-to-peer after the
signaling-client (#yxjw) completes SDP/ICE negotiation. This is the
internet-scale transport layer — no server relay needed for graph state
after the initial signaling handshake.
Feature gate
webrtc-data = ["ipc", "dep:str0m", "dep:tokio"]
Uses str0m for the WebRTC stack (pure Rust, no C dependencies) and
tokio for async runtime integration. Separate from signaling-client
so consumers can use signaling without incurring the full WebRTC
dependency.
Transport interface
#![allow(unused)]
fn main() {
#[cfg(feature = "webrtc-data")]
pub struct WebRtcDataChannel {
// str0m session wrapping a single data channel
}
#[cfg(feature = "webrtc-data")]
impl IpcSink for WebRtcDataChannel {
type Error = WebRtcDataError;
fn send(&mut self, message: &IpcMessage) -> Result<(), Self::Error>;
}
#[cfg(feature = "webrtc-data")]
impl IpcSource for WebRtcDataChannel {
type Error = WebRtcDataError;
fn recv(&mut self) -> Result<Option<IpcMessage>, Self::Error>;
}
}
Channel contract
- Ordered + reliable: data channels must be created with
ordered: true, maxRetransmits: NonesoDeltadelivery matches the single-writer epoch contract. Unordered/unreliable channels are only acceptable for optional lossy telemetry, never for graph state. - Framing: each
IpcMessageis length-prefixed (4-byte LE length + payload).json,msgpack, orpostcardcodec negotiated during capability handshake. - Back-pressure:
sendblocks or yields when the SCTP congestion window is full; the caller must not flood faster than the channel drains. - Reconnect: on channel close or SCTP failure, the transport signals
Errso the caller can re-signaling and re-establish a fresh channel. TheDeltaresync mechanism handles any gap.
Lifecycle
SignalingClientexchanges SDP offer/answer with peer via #yxjw- ICE candidates trickle through the signaling channel
- On ICE completion,
WebRtcDataChannel::from_sdp(local_sdp, remote_sdp)creates the str0m session and opens the data channel - Capability handshake on the data channel (protocol id, codec, features)
IpcMessageframes flow bidirectionally- On disconnect, re-signaling via
SignalingClientand repeat
Integration test surface
tests/webrtc_data.rs— gated behind#[cfg(feature = "webrtc-data")]- Loopback test: create two str0m sessions back-to-back, send
IpcMessage::SnapshotandIpcMessage::Delta, verify round-trip - Codec negotiation: JSON, MessagePack, and postcard on the same channel
- Ordered delivery: send 100 deltas, verify epoch order on recv
make test-webrtc-datatarget in Makefile
str0m backends: loopback vs networked (webrtc-str0m feature)
The webrtc-str0m feature ships two concrete DataChannel backends over the
same sans-IO str0m pump loop, differing only in transport and clock:
Str0mLoopback(src/str0m_backend.rs) — twoRtcinstances in one thread, connected by an in-memory packet route advanced on a synthetic clock. No sockets, no threads, no wall-clock dependency, so the full ICE/DTLS/SCTP handshake is deterministically testable in-process. This is the unit/CI substrate for theWebRtcSink/WebRtcSourcebridge.Str0mNet(src/str0m_net.rs) — oneRtcdriven over a real UDP socket by a background driver thread, with the SDP offer/answer and trickled ICE candidates exchanged by the caller (typically overSignalingClient, #yxjw). This is the real “beyond signaling” peer-to-peer path that can reach a peer on another host.
Str0mNet lifecycle:
Str0mNet::offer(bind)→ binds the UDP socket, opens thelazily-ipcchannel, returns the SDP offer string;Str0mNet::answer(bind, offer)returns the SDP answer string. The offerer applies the peer’s answer withaccept_answer.- Each peer exposes its host candidate via
local_candidate(); the caller trickles it to the remote, which feeds it toadd_remote_candidate(). - The driver thread pumps
poll_output→ UDPsend_to, and UDPrecv_from→handle_input, advancing real timers, until the SCTP data channel opens (wait_open). Inbound frames queue fortry_recv_frame; outbound frames requested before open are buffered and flushed on open. - On
ChannelClose, socket failure, or a deadRtc, the channel reports closed so the sync sink/source surfaceErrand the caller re-signals.
Because Str0mNet needs live two-peer connectivity it cannot use the synthetic
clock. tests/str0m_net.rs exercises a real two-socket round trip over
127.0.0.1 (real UDP/DTLS/SCTP/timers); a cross-host round trip through the live
signaling Worker is operator-gated.
Str0mNet outbound backpressure contract (#lzstr0mframe)
The Str0mNet driver’s outbound frame queue is bounded at
MAX_PENDING_FRAMES (1024). When a caller’s send_frame rate exceeds the SCTP
drain rate, the driver applies backpressure on two layers:
Channel::writebackpressure (Ok(false)) — str0m returnsOk(false)when the SCTP send buffer is full. The driver’s flush loop re-queues the frame and yields (rather than popping it), so the nextpoll_output/recv_fromcycle can drain the SCTP window and accept the frame on the following iteration. Pre-#lzstr0mframe this branch was a bareif ch.write(...).is_err() { break; }that ignored thebool, silently dropping every frame that hit theOk(false)path — violating the ordered/reliable DataChannel invariantWebRtcSink/WebRtcSourcerely on.- Queue cap (
Str0mNetError::Backpressure) — once the driver’sout_pendingVecDeque reachesMAX_PENDING_FRAMES,send_frameitself returnsErr(Str0mNetError::Backpressure)so the caller applies flow control (sleep / await / shed load) instead of growing memory without bound. The counter is decremented whenChannel::writeaccepts the frame (Ok(true)) and reset to zero on driver exit (the queue will never drain after close).
The regression test burst_of_frames_arrives_in_order_under_backpressure
exercises this contract by bursting 100 × 8 KiB frames through a single
Str0mNetChannel and asserting all 100 arrive in order at the remote peer,
retrying on Backpressure as needed.
Str0mNet driver I/O error handling (#lzstr0mpolldrive)
The driver’s UDP I/O surfaces failures instead of silently dropping packets:
socket.send_to— pre-fix this waslet _ = socket.send_to(...), which discarded every error:ENOBUFS(send-buffer pressure),ECONNREFUSED(ICMP port-unreachable, peer down),ENETUNREACH/EHOSTUNREACH(route flap),EBADF(socket closed). The corresponding ICE/DTLS/SCTP packet was silently lost and the handshake stalled without diagnostics. Post-fix,WouldBlock/Interruptedretryable errorscontinuethe drain loop (str0m re-emits theTransmiton a laterpoll_output); any other error breaks the driver ('outer), surfacingClosedso the caller re-signals.- Read-timeout cap as command-poll interval —
recv_fromwaits at mostCOMMAND_POLL_INTERVAL(15 ms) so control commands (Send/AcceptAnswer/AddRemoteCandidate/Shutdown) read fromcmd_rxat the top of each outer iteration stay bounded-latency. This is not a str0m timing parameter: str0m is fed an accurate time advance viaInput::Timeout(now)whenever the socket times out without data, and an “early”Input::Timeout(every 15 ms during idle) is harmless — str0m just re-emits its pending deadline if it isn’t time yet.
Signaling glue: Str0mNet over SignalingClient (#lzwebrtcwire)
Str0mNet exchanges its SDP offer/answer and trickled ICE candidates out of
band; SignalingClient (#yxjw, below) is the out-of-band channel. The
webrtc_signaling module (enabled when both signaling-client and
webrtc-str0m are on) is the wire between them — two async driver functions
that own the full handshake:
offer_to_peer(client, peer, bind, timeout)— binds the socket viaStr0mNet::offer, sends the SDPofferand the locallocal_candidate()topeerover the signaling client, then pumps incomingServerMessages (answer→accept_answer,ice→add_remote_candidate) until the data channel opens, returning the connectedStr0mNet.answer_next_offer(client, bind, timeout)— waits for the nextofferframe, produces the SDP answer viaStr0mNet::answer, returns the answer + local candidate over signaling, applies any ICE candidate that raced ahead of the offer, then pumps until open. Returns the offeringPeerIdand the connectedStr0mNet.
Both pump loops re-check Str0mNet::is_open() on a short poll tick as well as on
each signaling frame, because the channel opens on the backend’s driver thread,
off the signaling path. The caller is responsible for learning the target peer is
present (from the welcome roster or a peer-joined frame) before offering; an
offer to an absent peer is dropped by the relay and surfaces only as a timeout.
tests/webrtc_signaling.rs drives this end to end over a loopback signaling
relay: an in-process tokio-tungstenite server implementing the #yxjw roster +
from-stamped routing on 127.0.0.1, two real SignalingClient WebSocket
connections, and the real Str0mNet UDP/DTLS/SCTP transport — proving a
permission-filtered Snapshot crosses a data channel negotiated entirely through
SignalingClient. The only remaining slice is the live two-host / NAT run through
the deployed #yxjw Worker, which is operator-gated (#h6qb).
Capability negotiation
Each non-local session starts with a small compatibility handshake before graph state flows:
- protocol id:
lazily-ipc - protocol major version
- codec (
jsontoday; binary codecs can be transport crates as long as they encode the sameIpcMessageschema) - maximum frame size and fragmentation support
- ordered/reliable delivery guarantee
PeerIdand session/graph id- supported features such as
shared-blob,crdt-cell-plane, andsignaling-relay
If peers disagree on protocol major version, codec, ordering guarantees, or
required feature flags, they fail closed before applying any Snapshot or
Delta.
Cross-language family rules
- The shared semantics are lazy slots, mutable cells, dynamic dependency
tracking,
PartialEq/equality-guarded invalidation, memo equality suppression, batching, and permission-filtered snapshots/deltas. - Compute closures are language-local. Cross-language sync shares the cell state plane by default; derived slots converge remotely only when peers use a shared compiled graph or an explicit compute-descriptor system.
- JavaScript/TypeScript peers must keep
PeerIdvalues at or belowNumber.MAX_SAFE_INTEGER, matching the #s0fc signaling protocol. - Permission filtering happens before serialization on every channel. A
WebSocket relay, WebRTC data channel, or FFI caller must not receive nodes or
operations that
PeerPermissionswould omit. - Channel code must preserve back-pressure and resync behavior: if frame
delivery gaps, reorders, truncates, or exceeds negotiated size, the receiver
requests a fresh
Snapshotinstead of applying a partial delta.
This keeps FFI viable without making it a special semantic path. FFI, IPC,
WebSocket, and WebRTC data differ only in framing, ownership, and reliability;
the lazily family stays compatible because all channels carry the same
permission-filtered IpcMessage state plane.
Multi-writer coordination: CRDT vs Raft (lazily-distributed)
lazily-ipc (above) is a single-writer linear log: one authority mutates
the graph and streams Deltas stamped by a monotonic ipc_epoch.
lazily-distributed asks the harder question — when multiple peers may
write the same shared reactive graph, what coordination model orders those
writes: a CRDT (conflict-free replicated data types, eventual consistency) or
Raft (leader-ordered consensus, strong consistency)?
The reactive structure collapses most of the question
The key observation is that not all graph state is writable. lazily-rs has exactly two node kinds with respect to authorship:
- Cells / source slots — externally writable. These are the only state a peer can directly set.
- Derived slots (
computed/memo) — pure deterministic functions of their dependencies. They are never written; they recompute. Their values, and the dynamic dependency topology discovered during recompute, are a deterministic view of the cell state — provided every peer runs identical compute closures (the closure-replication prerequisite already flagged aslazily-ipc’s mirror-lazy mode).
So coordination is only needed on the small cell plane. The entire derived graph — typically the large majority of nodes, plus all edges and the effect schedule — converges automatically once the cells converge and recompute runs. This is the same property the local engine already relies on: derived state is a function, not a source of truth.
The two models on the cell plane
| Aspect | CRDT (cell-plane registers) | Raft (leader-ordered log) |
|---|---|---|
| Consistency | Eventual; peers converge after delivery | Strong; one total order, every peer identical |
| Availability | Local-first — peers read/write while partitioned | Minority partition cannot write; needs quorum |
| Write latency | Local (no round-trip) | Quorum round-trip to leader per write |
| Offline peers | Native (merge on reconnect) | Not supported (writes need quorum) |
| P2P / WAN fit | Direct (no leader); fits #yxjw signaling | Awkward — leader election over WAN, quorum cost |
| Conflict model | Per-cell merge (LWW / MV register) | None — serialized, last in order wins by fiat |
| Extends #ipc2 | Per-peer Deltas + causal stamps, merged | One global Raft-replicated Delta log |
| Cost | Every writable cell must be a CRDT | Election, log replication, quorum machinery |
Recommendation — CRDT cell plane (HLC-stamped registers), not Raft
Adopt a CRDT layer on the cell plane only, with derived slots recomputing deterministically on each peer. Concretely:
- Each replicated cell is a register CRDT keyed by a hybrid logical clock
(HLC) — wall-clock for human-meaningful ordering, logical counter for
causal tiebreak. Two flavors, chosen per cell via a trait:
- LWW-register (last-write-wins) — default; “current value” semantics that most reactive cells want. Silently drops the losing concurrent write.
- MV-register (multi-value) — surfaces concurrent writes as a set for the compute layer (or app) to resolve, when dropping a write is unacceptable.
- Additive cells can opt into a PN-counter instead of a register.
- The local PartialEq invalidation guard still applies — after merge: a
merge that yields an equal value invalidates nothing, exactly as a local equal
setdoes. memo equality suppression likewise holds post-merge, so convergent peers do the same downstream work. lazily-ipc’sDeltageneralizes from one monotonicipc_epochto per-peer causal stamps: each peer keeps its own sequence; cross-peer order comes from the HLC/dot metadata carried on eachCellSet. Delivery can be out-of-order; merge is commutative/associative/idempotent so gaps self-heal without the snapshot-resync that the single-writer log needed.
Raft is the wrong default because the lazily-distributed roadmap is
explicitly availability-first — P2P signaling (#yxjw) and offline peers —
and Raft trades exactly that away for a global total order that the reactive
model does not need: derived state is already deterministic, and the writable
surface is small.
The narrow exception — irreversible effects need an authority
CRDT convergence is correct for state. It is not sufficient for effects that perform irreversible external actions (send an email, charge a card, fire a webhook): convergence may run the same effect on every peer, or run it twice as merges arrive. Pure state can converge; an external side effect cannot be merged.
For that narrow class, gate the effect behind a single-writer effect
authority — a designated peer (or a small Raft group owning only the
effect-intent log, not the whole graph) decides when an irreversible effect
fires, at-most-once. This is a hybrid: CRDT for the state plane, a
single-writer/Raft authority for the irreversible-effect plane, with the
#39c5 RemoteOp allowlist already gating which remote writes and effects a peer
may trigger at all. The large reactive core stays leaderless and local-first;
only the small irreversible-effect tail pays for consensus.
Open prototype gates (deferred to implementation)
- HLC skew bounds and the LWW-vs-MV default per cell category.
- Whether closure replication (required for peers to recompute derived slots) is shipped as serialized compute descriptors or restricted to a shared compiled graph — this gates how much of the derived plane can live remotely at all.
- Delta-state vs op-based CRDT encoding on the wire, reusing
lazily-serde.
Move-aware sequence CRDT (SeqCrdt, #lzseqcrdt)
The register CRDTs above merge a cell’s value; a document tree also needs
mergeable sibling order. SeqCrdt<Id, V> is the order layer: a coordinator-
free ordered sequence of keyed elements, the concurrency substrate beneath keyed
reconciliation (#lzkeyrecon).
- Fractional-index positions. Each element holds an orderable byte key (tiebroken by the minting peer). Inserting between two neighbours generates a key strictly between theirs, so concurrent inserts into the same gap on different replicas both survive and converge to one deterministic order.
- Move-awareness (the requirement). A move MUST be a single LWW reassignment of the element’s position (highest HLC stamp wins) — not a delete + reinsert. So a reorder keeps the element’s identity and value, and two concurrent moves of the same element converge to the later one without duplicating it (the failure mode of naive RGA delete+reinsert moves).
- Independent registers. Value, position, and tombstone are separate LWW registers, so a concurrent move and value edit of one element do not conflict; both apply. Removal is an LWW tombstone, so it converges and a concurrent resurrection is decided by stamp order.
- Merge is per-element LWW of value/position/tombstone plus adoption of
unknown elements — commutative, associative, idempotent — and advances the
local HLC past observed stamps so later local writes still win. This keeps the
IPC
Snapshot/Deltasingle-producer mirror as-is; the sequence CRDT lives only at the multi-writer boundary (pairs with#lzcrdtplane). - Tombstone GC (
#lztombgc) —gc_with(is_stable)/gc(watermark)drop tombstoned entries the caller proves causally stable (observed by every replica; the version-vector frontier is#lzcrdtplane’s, not a single replica’s clock). Becauseorder/containsalready skip tombstones, dropping a stable one is observationally inert and convergent: an un-collected replica re-merges it as a tombstone (still skipped), and a genuine resurrection carries a newer stamp and wins by LWW regardless.
Distributed wire transport (CrdtSync, #lzcrdtplane5)
The plane rides the existing lazily-ipc transport. A third IpcMessage variant,
IpcMessage::CrdtSync(CrdtSync), carries multi-writer plane traffic beside the
single-producer Snapshot/Delta mirror (which is untouched):
WireStamp { wall_time, logical, peer }— a plain-integer wire mirror ofHlcStamp(same(wall, logical, peer)total order), so the wire format is codec-stable and usable whether or not a peer compiles thedistributedfeature. Thedistributed+ipcintegration owns the losslessHlcStamp ↔ WireStampconversion.CrdtOp { node, key: Option<NodeKey>, stamp: WireStamp, state: IpcValue }— one state-based (CvRDT) op: the converged register/sequence/textstatefor a node, tagged with the producing stamp and an optional wire-stableNodeKey(#lzwirekey) that survivesNodeIdchurn.CrdtSync { frontier: Vec<(peer, WireStamp)>, ops: Vec<CrdtOp> }— an anti-entropy frame: the sender’s per-peer stamp-frontier advertisement plus an op batch. This resolves the “delta-state vs op-based encoding” prototype gate above in favour of state-based sync (the registers and Seq/Text CRDTs all merge by state).
The frame round-trips through every codec (JSON / MessagePack / postcard) and is classified
by the FFI message kind (LazilyFfiMessageKind::CrdtSync). CrdtSync::filter_readable(peer)
drops ops for non-readable nodes entirely (omission, not redaction — like Delta), keeping
the frontier advertisement (metadata, not node content) so the receiver still computes a
sound watermark. Convergence (merge is a commutative/associative/idempotent semilattice)
and the watermark/GC safety contract (a collectable tombstone is observed by every replica)
are formally proven in ../lazily-spec/formal/lean/LazilyFormal/CRDT.lean and documented in
protocol.md §Distributed.
The wire format, codec round-trips, permission filtering, and point-to-point
IpcSink/IpcSource delivery land in #lzcrdtplane5a.
Runtime integration (#lzcrdtplane5b, FINAL — completes Phase 5). CrdtPlaneRuntime
(behind distributed + webrtc) is the live glue between the plane primitives and a
reactive graph’s merge: crdt root cells:
- Registry. It owns the session’s
ReplicatedCellroot cells, addressed byNodeIdwith an optional wire-stableNodeKey(producer projection,#lzwirekey) so a cell stays addressable acrossNodeIdchurn. - Local edit → op.
local_updateticks the planeHlc, mutates the typed cell, records the converged state in theOpLog, and returns theCrdtOpto broadcast (JSON-encodedIpcValuestate). - Remote op → reactive graph.
ingestfolds each not-yet-seenCrdtOpinto its target replica viaReplicatedCell::merge_remote— driving downstream derived slots — whileCrdtPlane::observe_remoteadvances the clock + stamp frontier so the causal-stability watermark and Seq/Text tombstone GC stay sound. Re-delivery is idempotent (theOpLogdedups by stamp). - Anti-entropy frames.
sync_frame/sync_frame_since/sync_replyadvertise the local stamp frontier and ship only the ops a peer is missing. BridgeHubfan-out.BridgeHub::pollnow fansCrdtSyncframes out to the other peers, re-filtered to each target’s read allowlist (ops a peer cannot read are omitted; the frontier advertisement is retained), beside the existing single-writerDeltarouting.
An end-to-end two-replica convergence test drives a full edit → CrdtSync → ingest cycle
over the real webrtc IpcSink/IpcSource transport (tests/crdt_plane.rs).
Internet-scale peer discovery: signaling server (#yxjw)
The CRDT cell plane (above) is leaderless and local-first, but peers still have
to find each other and open transport before any Delta can flow. On a LAN
that is mDNS or a known address; across the internet it needs a rendezvous
point. #yxjw is that rendezvous: a small Cloudflare Worker signaling
server that brokers peer discovery and relays the WebRTC SDP/ICE handshake so
peers can establish direct P2P data channels, falling back to server relay
of opaque payloads when a direct channel cannot be formed. It is strictly a
discovery + relay layer — it never parses or merges CRDT state, so it stays
trivially horizontally scalable and never becomes the consistency authority the
CRDT design deliberately avoids.
Why a Cloudflare Worker + Durable Objects
- One Durable Object per session id. A WebSocket upgrade to
GET /session/:idroutes to aSignalingRoomDO keyed byidFromName(sessionId). Cloudflare guarantees a single global instance per id, so each session gets a lock-free single-threaded coordination point for its roster with no external store. Scale is achieved by sharding sessions across DO instances, not by growing one server — which matches the availability-first, P2P posture of the CRDT recommendation (it “fits #yxjw signaling”). - Edge-local. Workers run close to peers worldwide, minimizing handshake latency; the DO migrates to wherever its session is most active.
Roles and protocol
The server tracks a per-session roster of connected peers and forwards three
classes of frame. PeerId is the same u64 as Rust PeerId (serialized by
serde as a bare JSON number; ids must stay ≤ Number.MAX_SAFE_INTEGER).
- Client → server:
join { peer, capabilities? },offer { to, sdp },answer { to, sdp },ice { to, candidate },relay { to, payload },leave. - Server → client:
welcome { peer, peers }(roster on join),peer-joined/peer-left, forwardedoffer/answer/ice/relaystamped with the realfrom, anderror { code, message }.
Anti-spoofing: the from on every forwarded frame is the sender
connection’s registered peer id, never a client-supplied field, so a peer
cannot impersonate another.
Permission boundary (reuses #39c5)
Admission and relay are gated by SignalingPermissions, the discovery-layer
mirror of lazily::distributed::PeerPermissions:
openmode — any peer may join and signal any other joined peer (trusted / LAN / common discovery case).allowlistmode — default-deny: a peer may join only when explicitly granted, and may send directed frames only to explicitly allowed targets, exactly as #39c5 gatesRemoteOp. This is the discovery-layer half of the same boundary; the Rust data plane still re-checks everyRemoteOplocally.
Reconnect / resync
The roster lives in the DO; it is authoritative. A peer that drops simply
re-joins and receives a fresh welcome roster — no snapshot epoch is needed
at this layer because signaling carries no CRDT state (the data plane’s
HLC/dot-stamped Deltas self-heal independently per the CRDT design).
Implemented (#yxjw)
Ships as a standalone TypeScript Worker under signaling/ (its own Node
toolchain; not part of the Rust crate build):
src/protocol.ts— wire types + untrusted-frame validation/codec.src/permissions.ts—SignalingPermissions(open/ default-denyallowlist).src/room-core.ts— transport-agnosticRoomCore: roster, routing, anti-spoofing, permission gating.src/room.ts—SignalingRoomDurable Object (thin WebSocket adapter).src/index.ts— Worker entry:/health+/session/:idrouting.test/— 24 vitest tests (protocol/permissions/room-core units plus an end-to-end Worker + DO + WebSocket test in the workerd runtime).
Open gates (deferred)
- TURN/relay fallback policy when both peers are behind symmetric NAT (today the server can relay payloads, but a dedicated TURN allocation is out of scope).
- Authenticated admission tokens feeding the
allowlistgrants from an external identity source rather than static configuration. - Capacity/back-pressure limits per session DO.
Consumable clients (#s0fc)
So a project can depend on the signaling endpoint for distributed peer discovery (the plan for agent-doc), the endpoint ships two clients that speak one shared wire protocol — this section is the normative source of truth both conform to.
Wire protocol (normative). All frames are JSON with a type tag. PeerId
is a u64 serialized as a bare JSON number (Rust PeerId(u64) ⇄ TS number;
keep ids ≤ 2^53).
- Client → server:
join {peer, capabilities?},offer {to, sdp},answer {to, sdp},ice {to, candidate},relay {to, payload},leave. - Server → client:
welcome {peer, peers},peer-joined {peer},peer-left {peer},offer/answer/ice/relay(each stampedfrom),error {code, message}.
Rust client (signaling-client feature, src/signaling_client.rs):
lazily::SignalingClient::connect(base_url, session, peer) opens a
tokio-tungstenite WebSocket to {base_url}/session/{session}, joins, and
exposes offer/answer/ice/relay/leave plus recv() for
ServerMessages. ClientMessage/ServerMessage are serde-tagged
(rename_all = "kebab-case") and reuse the #39c5 PeerId. Conformance tests
assert the exact JSON shapes above. The feature pulls tokio-tungstenite
(rustls) only when enabled; the default build is unaffected.
TypeScript client (@lazily/signaling package, signaling/src/client.ts):
SignalingClient.connect(baseUrl, session, peer) (or attach(socket, peer) for
a pre-opened socket) works against any WebSocket-like transport (browser,
Node ≥ 22, or injected), with onMessage + the same send helpers. The package
exports ./client and ./protocol. Unit tests plus an end-to-end test drive
the real Worker + Durable Object in workerd.
Both clients are covered in CI (cargo test --features signaling-client; the
Worker job’s npm run check). The Rust conformance tests and the TS protocol
share the byte-for-byte frame shapes defined above, so the two implementations
stay wire-compatible.
Differences from lazily-zig
| Aspect | lazily-zig | lazily-rs |
|---|---|---|
| Context | Explicit allocator | Owned allocations |
| Slot creation | comptime function pointers | Closures (Box<dyn Fn>) |
| Storage modes | .direct / .indirect | Unified via generics |
| FFI | Built-in StringView | Via #[no_mangle] + extern "C" |
| Thread safety | Mutex by default; -Dthread_safe=false removes locking | Context is single-threaded (RefCell); ThreadSafeContext uses a context-level lock |
Differences from lazily-py
| Aspect | lazily-py | lazily-rs |
|---|---|---|
| Context | Plain dict | Typed Context struct |
| Slot keys | Object identity | SlotId (u64) |
| Cell equality | != operator | PartialEq trait |
| Context resolvers | resolve_ctx functions | Direct context passing |
| Dependencies | Zero mandatory runtime crates by default; optional Tokio support and dev-only Criterion benchmarks | Zero (pure Rust) |
lazily Wire Protocol
Language-agnostic protocol reference for the lazily reactive-graph family (lazily-rs, lazily-py, lazily-zig). This document describes the wire format, message schemas, and transport contracts. Language-specific APIs live in each binding’s own documentation.
Message Plane
All channels (FFI, IPC, WebSocket, WebRTC data) carry the same two message kinds:
Snapshot— full graph image, sent on connect and on resyncDelta— incremental change set, sent once per outermost batch flush
These are tagged as IpcMessage:
{ "Snapshot": { ... } }
{ "Delta": { ... } }
Wire Types
NodeId
Stable wire identifier for a reactive node (cell or slot). Decoupled from language-internal allocation IDs.
{ "node": 1 }
Wire format: u64 wrapped in a "node" field.
NodeKey
Optional wire-stable keyed address for a collection entry (a SourceMap /
CellFamily entry). Unlike NodeId — the volatile internal handle — a
NodeKey is producer-defined and stable across NodeId churn: an entry that
is removed and later re-added is re-minted under a new NodeId but keeps the
same NodeKey, so a peer can subscribe to “entry scores/alice” without
maintaining an out-of-band key→NodeId map.
"scores/alice"
"sheet/A1"
"outer/k1/inner/k2"
Wire format: a /-joined path string. A multi-segment path addresses nested
collections (an entry of a SourceMap inside a SourceMap entry) with no extra
machinery — each / introduces a deeper segment.
Bounds (reject on construction and on the wire):
| Bound | Limit |
|---|---|
| Max path length | 1024 bytes |
| Max segment count | 32 |
| Empty path | rejected |
Empty segment (leading/trailing/double /) | rejected |
NodeKey is additive addressing — it never changes NodeId semantics. It
appears only as the optional key field on NodeSnapshot and the NodeAdd
delta op; absent ⇒ today’s opaque-NodeId-only behavior. Key uniqueness across
multiple producers (the multi-writer distributed boundary) is owned by the
distributed CRDT plane’s last-writer rule, not this protocol.
PeerId
Identifies a remote peer.
{ "peer": 42 }
Wire format: u64. JavaScript peers must keep this at or below
Number.MAX_SAFE_INTEGER.
OpKind
Access category for a remote operation.
| Value | Meaning |
|---|---|
"Read" | Read node value into snapshot/delta |
"Write" | Write new value to source cell |
"TriggerEffect" | Trigger effect on irreversible-effect plane |
RemoteOp
A single operation a remote peer may request.
{ "kind": "Read", "node": 1 }
IpcPayload
Opaque serialized value bytes. The producing language owns type-aware encoding
through type_tag; the channel only moves bytes.
Wire format: array of u8 (JSON array of integers).
NodeState
Serialization state for a node.
{ "Payload": [1, 2, 3, 4] }
"Opaque"
{ "SharedBlob": { "offset": 0, "len": 16, "generation": 1, "epoch": 9, "checksum": 123456789 } }
| Variant | Meaning |
|---|---|
{ "Payload": [...] } | Inline serialized value bytes |
"Opaque" | Known node whose value cannot be serialized |
{ "SharedBlob": { ... } } | Descriptor for bytes in shared memory |
ShmBlobRef
Descriptor for a payload stored in a shared-memory blob arena.
| Field | Type | Meaning |
|---|---|---|
offset | u64 | Byte offset from arena start |
len | u64 | Payload length in bytes |
generation | u64 | Per-write generation (stale rejection) |
epoch | u64 | IPC epoch of the publishing message |
checksum | u64 | FNV-1a payload checksum |
IpcValue
Value stored inline or by shared-memory blob reference.
{ "Inline": [10, 20, 30] }
{ "SharedBlob": { "offset": 40, "len": 17, "generation": 2, "epoch": 9, "checksum": 987654321 } }
Snapshot Message
Full graph image sent on connect or resync.
Schema
Snapshot {
epoch: u64,
nodes: Vec<NodeSnapshot>,
edges: Vec<EdgeSnapshot>,
roots: Vec<NodeId>
}
NodeSnapshot
NodeSnapshot {
node: NodeId,
type_tag: string,
state: NodeState,
key: NodeKey? // optional; absent in JSON/MessagePack when not set
}
The optional trailing key field carries a wire-stable NodeKey for
collection entries (see NodeKey). In the self-describing codecs
(JSON, MessagePack) it is omitted when absent, so pre-key encoders and
decoders round-trip unchanged. In Postcard (positional) the field is always
present as an optional discriminant for schema stability.
EdgeSnapshot
EdgeSnapshot {
dependent: NodeId,
dependency: NodeId
}
Example: Minimal snapshot
{
"Snapshot": {
"epoch": 1,
"nodes": [
{
"node": 1,
"type_tag": "i32",
"state": { "Payload": [1, 2, 3, 4] }
}
],
"edges": [],
"roots": [1]
}
}
Example: Multi-node snapshot with opaque node
{
"Snapshot": {
"epoch": 7,
"nodes": [
{ "node": 1, "type_tag": "i32", "state": { "Payload": [1, 2, 3] } },
{ "node": 2, "type_tag": "f64", "state": { "Payload": [0, 0, 0, 0, 0, 0, 240, 63] } },
{ "node": 3, "type_tag": "opaque-type", "state": "Opaque" }
],
"edges": [
{ "dependent": 2, "dependency": 1 },
{ "dependent": 3, "dependency": 1 }
],
"roots": [1, 2]
}
}
Example: Snapshot with shared-blob node
{
"Snapshot": {
"epoch": 9,
"nodes": [
{
"node": 7,
"type_tag": "text/plain",
"state": {
"SharedBlob": {
"offset": 0,
"len": 16,
"generation": 1,
"epoch": 9,
"checksum": 123456789
}
}
}
],
"edges": [],
"roots": [7]
}
}
Delta Message
Incremental change set emitted after one outermost batch flush.
Schema
Delta {
base_epoch: u64,
epoch: u64,
ops: Vec<DeltaOp>
}
Sequential deltas satisfy epoch == base_epoch + 1. A receiver detects gaps,
reorders, or sender restarts by checking base_epoch == last_epoch.
DeltaOp Variants
| Variant | Fields | Meaning |
|---|---|---|
CellSet | node, payload (IpcValue) | Source cell changed to new value |
SlotValue | node, payload (IpcValue) | Lazily recomputed slot published a value |
Invalidate | node | Node dirtied without a concrete value |
NodeAdd | node, type_tag, state (NodeState), key (NodeKey, optional) | New node became visible |
NodeRemove | node | Node was removed |
EdgeAdd | dependent, dependency | Dependency edge added |
EdgeRemove | dependent, dependency | Dependency edge removed |
Example: Sequential delta with all op variants
{
"Delta": {
"base_epoch": 40,
"epoch": 41,
"ops": [
{ "CellSet": { "node": 1, "payload": { "Inline": [10] } } },
{ "SlotValue": { "node": 2, "payload": { "Inline": [20] } } },
{ "Invalidate": { "node": 3 } },
{ "NodeAdd": { "node": 4, "type_tag": "u64", "state": { "Payload": [64] } } },
{ "NodeRemove": { "node": 5 } },
{ "EdgeAdd": { "dependent": 2, "dependency": 1 } },
{ "EdgeRemove": { "dependent": 3, "dependency": 1 } }
]
}
}
Example: Non-sequential delta (gap)
{
"Delta": {
"base_epoch": 12,
"epoch": 13,
"ops": []
}
}
When the receiver’s last_epoch is 10, this delta has a gap (expected 10→11,
got 12→13). The receiver must discard it and request a fresh Snapshot.
Example: Delta with shared-blob payload
{
"Delta": {
"base_epoch": 8,
"epoch": 9,
"ops": [
{
"SlotValue": {
"node": 7,
"payload": {
"SharedBlob": {
"offset": 40,
"len": 17,
"generation": 2,
"epoch": 9,
"checksum": 987654321
}
}
}
}
]
}
}
Epoch Contract
ipc_epochis a monotonicu64that advances once per outermost batch flush.Snapshotcarriesepoch.Deltacarries{ base_epoch, epoch }withepoch == base_epoch + 1.- On
Deltawherebase_epoch != last_epoch: discard the delta, request a freshSnapshot, resume from the snapshot’sepoch.
Consistency Invariants
- PartialEq cell guard: equal
CellSetproduces no wire ops. - Memo equality suppression: a dirty memo slot that recomputes to an equal
value emits no
SlotValueor downstreamInvalidate. - Coalesced frontier: a dependent reached through many changed cells in one batch appears at most once per delta.
- Eager Signal nodes always carry a value: an eager
Signal(see below) is recomputed during the invalidation flush, so when it changes it appears in the delta as a concreteSlotValue(never a bareInvalidate). A purely lazy slot that was not read before the flush may instead appear asInvalidatewith no value. Both are valid wire states for the sameSlotValue/Invalidateop set; the distinction is computation timing, not message format.
Eager Signal Nodes
A Signal is the eager derived value in the Slot -> Cell -> Signal family: it
recomputes the instant a dependency invalidates rather than on next read. It is
not a new wire type. A Signal is composed from a memoized backing slot plus a
local puller effect, and only the backing slot is graph state, so on the wire a
Signal node is an ordinary slot node:
- Snapshot: the backing slot appears as a
NodeSnapshotwith its materialized value inNodeState(Payload/SharedBlob), like any other readable slot. - Delta: a value change appears as
SlotValuefor the backing slot’sNodeId. Because the value is eagerly materialized at flush time it is always concrete; eager nodes do not emit bareInvalidate. - Memo guard still applies: an eager recompute that yields an equal value
(
PartialEq) suppresses theSlotValueand any downstreamInvalidate, exactly as forctx.memoslots. - The puller effect is local: it drives eager recomputation but is not
serialized as a node and produces no
TriggerEffectop. Eagerness is a producer-side scheduling property; remote peers receive the same permission-filteredSnapshot/Deltastate plane regardless of whether a node is lazy or eager.
Peers therefore need no protocol change to consume signals from an eager producer — a Signal is observed as a slot that is reliably present in every delta that changes it.
Permission Boundary
Only nodes on the per-peer allowlist are serialized. Non-allowlisted nodes are
omitted entirely (not even as Opaque) so a peer cannot infer their
existence. Edges are retained only when both endpoints are readable.
This filter is applied at snapshot/delta construction time, before serialization, on all channels without exception.
Serialization
JSON (canonical/default)
serde_json with derived Serialize/Deserialize. All examples above use
JSON. This is the canonical text form, the default transport codec, and the
fixture format that every language binding must be able to render for debugging
and agent inspection.
MessagePack (optional, cross-language binary)
Named MessagePack encoding via the ipc-msgpack feature. It preserves the same
serde field names as JSON while reducing frame size and parse cost for
production transports. Peers negotiate this as codec "msgpack" and must still
be able to render any frame back to canonical JSON for diagnostics.
MessagePack frames decode through IpcMessage::decode_msgpack(bytes) and encode
through IpcMessage::encode_msgpack().
Postcard (optional, Rust/same-schema binary)
postcard compact binary encoding via the ipc-binary feature. Smaller and
faster than JSON, but not self-describing — peers must agree on the schema.
For same-language Rust or postcard-aware transports only.
Binary frames decode through IpcMessage::decode_binary(bytes) and encode
through IpcMessage::encode_binary().
Transport Contracts
FFI (C ABI)
- Opaque channel handle + owned byte buffers
- Functions:
channel_new,channel_free,channel_send,channel_recv,ipc_message_validate,ipc_message_kind,ipc_message_clone,bytes_free - Binary variants: same functions with
_binarysuffix - Ownership: caller owns input bytes; Rust owns output buffers until the paired free function is called
- Errors return
LazilyFfiStatusenum; panics are caught before the C ABI
IPC (Unix socket / pipe / local TCP)
- Length-prefixed serialized
IpcMessageframes - Shared-memory optional for large
IpcValue::SharedBlobpayloads IpcSink/IpcSourcetrait interface
WebSocket
- One WebSocket text/binary frame carries one serialized
IpcMessage - Signaling server (#yxjw) relays frames as opaque payload
- Server must not parse CRDT/IPC state
WebRTC Data Channel
- Reliable ordered data channels only (for graph state)
- Length-prefixed framing: 4-byte LE length + payload
- JSON or binary codec negotiated during capability handshake
- On channel failure: re-signaling via
SignalingClient, delta resync covers gaps - Unordered/unreliable channels only for optional lossy telemetry
Capability Negotiation
Each non-local session starts with a handshake:
| Field | Description |
|---|---|
| Protocol id | "lazily-ipc" |
| Protocol major version | 1 |
| Codec | "json", "msgpack", or "postcard" |
| Maximum frame size | Negotiated maximum |
| Ordered/reliable | Required for graph state |
| PeerId | Session participant |
| Supported features | shared-blob, crdt-cell-plane, etc. |
If peers disagree on protocol major version, codec, or ordering guarantees,
they fail closed before applying any Snapshot or Delta.
Cross-Language Family Rules
- Compute closures are language-local. Cross-language sync shares the cell state plane; derived slots converge remotely only when peers use a shared compiled graph or explicit compute descriptors.
- Permission filtering happens before serialization on every channel.
- Channel code must preserve back-pressure and resync behavior.
- All channels carry the same permission-filtered
IpcMessagestate plane.
Conformance Test Vectors
Canonical JSON fixtures in tests/conformance/ validate wire-format agreement
across all language bindings:
| Fixture | Coverage |
|---|---|
snapshot_minimal.json | Single payload node, no edges |
snapshot_multi_node.json | Multiple nodes, opaque state, edges |
snapshot_shared_blob.json | Shared-memory blob reference |
delta_sequential.json | All 7 DeltaOp variants |
delta_non_sequential.json | Gap requiring resync |
delta_shared_blob.json | Delta with shared-blob payload |
Each fixture contains:
{
"description": "...",
"protocol_version": 1,
"kind": "Snapshot" | "Delta",
"assertions": { ... },
"wire": { <IpcMessage> }
}
Language bindings should:
- Parse
wireinto native types - Validate
assertions(field values, counts, state kinds) - Re-serialize and verify byte-exact match
lazily Benchmark Results
Generated benchmark data for the lazily reactive primitives library.
Benchmark Results
Generated for package lazily version 0.56.0.
Environment: rustc 1.97.0 (2d8144b78 2026-07-07) on x86_64-unknown-linux-gnu.
Refresh command:
python3 scripts/update-benchmark-results.py
Regression workflow:
cargo bench --features instrumentation,thread-safe -- --save-baseline before
# apply the performance patch
cargo bench --features instrumentation,thread-safe -- --baseline before
python3 scripts/update-benchmark-results.py --no-run
Regression budgets enforced by python3 scripts/update-benchmark-results.py --check:
Every ceiling is DERIVED from the recorded spread, never hand-typed; refresh
the spreads with python3 scripts/update-benchmark-results.py --measure-budget-spread N.
A counter with zero spread across idle, loaded and 2-core-pinned runs measures
work items rather than interleaving, so it is enforced EXACTLY. A counter whose
spread is under half its maximum gets headroom of one full observed range above
the observed maximum. A counter whose spread exceeds half its maximum is measuring
the scheduler, not the code: it is recorded as an observation and NOT enforced,
because a gate that reddens on noise trains everyone to ignore it.
| Profile | Counter | Observed range | Samples | Classification | Enforced ceiling |
|---|---|---|---|---|---|
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | lock_acquisitions | 654-893 | 750 | scheduling_sensitive | 1132 |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | set_cell_invalidation | 255-255 | 750 | deterministic | 255 |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | dependency_edge | 16-16 | 750 | deterministic | 16 |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | get_refresh | 32-32 | 750 | deterministic | 32 |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | publish | 16-16 | 750 | deterministic | 16 |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | lock_acquisitions | 712-1477 | 750 | scheduling_dominated | not enforced |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | other | 644-1154 | 750 | scheduling_sensitive | 1664 |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | set_cell_invalidation | 1-256 | 750 | scheduling_dominated | not enforced |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | dependency_edge | 64-64 | 750 | deterministic | 64 |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | get_refresh | 2-2 | 750 | deterministic | 2 |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | publish | 1-1 | 750 | deterministic | 1 |
| thread_safe_contention_same_slot_write_read_16 | lock_acquisitions | 876-1420 | 750 | scheduling_sensitive | 1964 |
| thread_safe_contention_same_slot_write_read_16 | get_refresh | 2-125 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_same_slot_write_read_16 | publish | 186-257 | 750 | scheduling_sensitive | 328 |
| thread_safe_contention_same_slot_write_read_16 | in_flight_wait | 0-367 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_same_slot_write_read_16 | set_cell_invalidation | 256-256 | 750 | deterministic | 256 |
| thread_safe_contention_independent_slots_16 | lock_acquisitions | 924-1148 | 750 | scheduling_sensitive | 1372 |
| thread_safe_contention_independent_slots_16 | other | 350-574 | 750 | scheduling_sensitive | 798 |
| thread_safe_contention_independent_slots_16 | get_refresh | 32-32 | 750 | deterministic | 32 |
| thread_safe_contention_independent_slots_16 | publish | 271-271 | 750 | deterministic | 271 |
| thread_safe_contention_independent_slots_16 | dependency_edge | 16-16 | 750 | deterministic | 16 |
| thread_safe_contention_independent_slots_16 | set_cell_invalidation | 255-255 | 750 | deterministic | 255 |
| thread_safe_contention_read_mostly_waiters_16 | lock_acquisitions | 72-144 | 750 | scheduling_sensitive | 216 |
| thread_safe_contention_read_mostly_waiters_16 | get_refresh | 2-32 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_read_mostly_waiters_16 | publish | 17-21 | 750 | scheduling_sensitive | 25 |
| thread_safe_contention_read_mostly_waiters_16 | in_flight_wait | 0-54 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_batched_write_bursts_16 | lock_acquisitions | 713-1915 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_batched_write_bursts_16 | other | 644-1154 | 750 | scheduling_sensitive | 1664 |
| thread_safe_contention_batched_write_bursts_16 | get_refresh | 2-38 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_batched_write_bursts_16 | dependency_edge | 64-64 | 750 | deterministic | 64 |
| thread_safe_contention_batched_write_bursts_16 | set_cell_invalidation | 1-256 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_batched_write_bursts_16 | publish | 2-256 | 750 | scheduling_dominated | not enforced |
| thread_safe_contention_batched_write_bursts_16 | in_flight_wait | 0-250 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_queue_coalescing_16 | lock_acquisitions | 720-2025 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_queue_coalescing_16 | other | 655-1705 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_queue_coalescing_16 | dependency_edge | 64-64 | 750 | deterministic | 64 |
| thread_safe_effect_contention_queue_coalescing_16 | set_cell_invalidation | 1-256 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_queue_coalescing_16 | get_refresh | 0-0 | 750 | deterministic | 0 |
| thread_safe_effect_contention_queue_coalescing_16 | publish | 0-0 | 750 | deterministic | 0 |
| thread_safe_effect_contention_cleanup_execution_16 | lock_acquisitions | 619-1859 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_cleanup_execution_16 | other | 332-1572 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_cleanup_execution_16 | dependency_edge | 32-32 | 750 | deterministic | 32 |
| thread_safe_effect_contention_cleanup_execution_16 | set_cell_invalidation | 255-255 | 750 | deterministic | 255 |
| thread_safe_effect_contention_cleanup_execution_16 | get_refresh | 0-0 | 750 | deterministic | 0 |
| thread_safe_effect_contention_cleanup_execution_16 | publish | 0-0 | 750 | deterministic | 0 |
| thread_safe_effect_contention_batch_flush_16 | lock_acquisitions | 1239-2649 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_batch_flush_16 | other | 1169-2199 | 750 | scheduling_sensitive | 3229 |
| thread_safe_effect_contention_batch_flush_16 | get_refresh | 2-2 | 750 | deterministic | 2 |
| thread_safe_effect_contention_batch_flush_16 | dependency_edge | 65-65 | 750 | deterministic | 65 |
| thread_safe_effect_contention_batch_flush_16 | set_cell_invalidation | 1-256 | 750 | scheduling_dominated | not enforced |
| thread_safe_effect_contention_batch_flush_16 | publish | 2-177 | 750 | scheduling_dominated | not enforced |
Budgets use lock acquisition counts instead of elapsed wait/hold time. Those counts are only deterministic for the 22 counters classified as such above; 19 of 51 gated counters are scheduling-dominated and carry no regression signal at all.
Synchronization strategy adoption gate:
| Strategy | Status | Required throughput evidence | Required p50/p95 latency evidence | Lock-site and safety gate |
|---|---|---|---|---|
| current_std_mutex_condvar | baseline | thread_safe_contention and thread_safe_effect_contention at 8/16 workers | p50/p95 latency for same-slot, read-mostly, batch, and effect-heavy cases | must stay within current lock-site budgets and Loom safety coverage |
| narrower_condvar_wakeups | adopted for per-slot recompute waiters | same-slot write/read and read-mostly waiter throughput at 8/16 workers | p50/p95 latency for waiter wakeup handoff and stale-completion retry | must not regress effect queue, cleanup, or batch flush budgets |
| parking_lot_style_parking | candidate only | same contention matrix measured against current_std_mutex_condvar | p50/p95 latency for parking/unparking under 8/16 workers | requires no worse lock-site budgets plus a deadlock/starvation model |
| targeted_cas | candidate only | fresh cached reads and independent-slot throughput at 8/16 workers | p50/p95 latency for revision validation fallback and publish races | requires unchanged effect/batch/disposal budgets plus Loom/Shuttle proof |
Candidates do not replace the current strategy before the same run reports throughput, p50/p95 latency, and lock-site budgets for the required 8/16-worker cases.
Required latency evidence uses Criterion sample per-iteration timing.
Watch-item A/B follow-up:
| Watch item | Baseline/current refs | Focused command | Controlled rerun result | Decision |
|---|---|---|---|---|
| cached ThreadSafeContext read latency | a8b6fc3 vs c917401 | cargo bench --features instrumentation,thread-safe --bench context -- cached_reads/thread_safe_context | 73.48 ns baseline vs 73.20 ns current on warm-cache repeat | no tuning; the archived 56.5 ns row did not reproduce under controlled A/B |
| effect cleanup contention at 16 workers | a8b6fc3 vs c917401 | cargo bench --features instrumentation,thread-safe --bench context -- thread_safe_effect_contention/cleanup_execution/16 | 2.31 ms baseline vs 2.43 ms current on warm-cache repeat with overlapping CIs | keep watching; Criterion reported no statistically significant change |
| invalidation-frontier fast-path Arc cache (#lzfrontierarc) | 15d4206 vs this change (controlled –save-baseline before_opt A/B, same session) | cargo bench --features instrumentation,thread-safe --bench context -- --baseline before_opt | fan_out_lazy_dirty_epochs/16 -46.8% (p=0.00), fan_in_lazy_dirty_epochs/16 -22.6% (p=0.00), independent_slot_contention/16 -17.3% (p=0.00), independent_slots/16 -5.3% (p=0.37 n.s.) | adopted; the cached Arc reuses the BFS-time fast path in the marking pass, halving uninstrumented slot_fast_paths RwLock read acquisitions whose reader-count atomics dominate under 16-way contention. Deterministic state-mutex acquisition counts (the budget metric) are unchanged because slot_fast_paths is a separate uninstrumented lock; the evidence is the controlled wall-clock A/B. Microbench cases (cached_reads) correctly show no change as they do not touch the invalidation frontier. |
| Context slot clean-cache-hit fast path (#lzslotfastpath) | 8c64f33 vs this change (controlled –save-baseline before_slot A/B, same session) | `cargo bench –features instrumentation,thread-safe –bench context – –baseline before_slot ’cached_reads | typed_cache_reads’` | typed_cache_reads/context_slot -58.9% (p=0.00), cached_reads/context -51.6% (p=0.00), typed_cache_reads/context_cell -2.1% (p=0.76 n.s.) |
| Group | Case | p50 | p95 | Samples |
|---|---|---|---|---|
| thread_safe_contention | same_slot_write_read / 8 | 2.812 ms | 3.329 ms | 10 |
| thread_safe_contention | same_slot_write_read / 16 | 6.853 ms | 7.923 ms | 10 |
| thread_safe_contention | independent_slots / 8 | 2.461 ms | 2.849 ms | 10 |
| thread_safe_contention | independent_slots / 16 | 5.549 ms | 6.543 ms | 10 |
| thread_safe_contention | read_mostly_waiters / 8 | 603.801 us | 718.127 us | 10 |
| thread_safe_contention | read_mostly_waiters / 16 | 1.465 ms | 1.503 ms | 10 |
| thread_safe_contention | batched_write_bursts / 8 | 2.430 ms | 2.558 ms | 10 |
| thread_safe_contention | batched_write_bursts / 16 | 3.932 ms | 4.392 ms | 10 |
| thread_safe_effect_contention | queue_coalescing / 8 | 1.159 ms | 1.284 ms | 10 |
| thread_safe_effect_contention | queue_coalescing / 16 | 3.178 ms | 3.660 ms | 10 |
| thread_safe_effect_contention | cleanup_execution / 8 | 1.277 ms | 1.423 ms | 10 |
| thread_safe_effect_contention | cleanup_execution / 16 | 2.975 ms | 4.020 ms | 10 |
| thread_safe_effect_contention | batch_flush / 8 | 2.092 ms | 2.881 ms | 10 |
| thread_safe_effect_contention | batch_flush / 16 | 4.342 ms | 6.935 ms | 10 |
| thread_safe_graph_propagation | fan_out_eager_validation / 8 | 3.012 ms | 3.124 ms | 10 |
| thread_safe_graph_propagation | fan_out_eager_validation / 16 | 4.872 ms | 5.322 ms | 10 |
| thread_safe_graph_propagation | fan_out_lazy_dirty_epochs / 8 | 1.741 ms | 1.861 ms | 10 |
| thread_safe_graph_propagation | fan_out_lazy_dirty_epochs / 16 | 3.540 ms | 3.902 ms | 10 |
| thread_safe_graph_propagation | fan_in_lazy_dirty_epochs / 8 | 2.888 ms | 4.082 ms | 10 |
| thread_safe_graph_propagation | fan_in_lazy_dirty_epochs / 16 | 7.587 ms | 8.237 ms | 10 |
| thread_safe_graph_propagation | fan_in_batched_flush / 8 | 1.030 ms | 1.153 ms | 10 |
| thread_safe_graph_propagation | fan_in_batched_flush / 16 | 1.779 ms | 2.123 ms | 10 |
Criterion estimates are local mean wall-clock time per iteration.
| Group | Case | Mean | 95% CI |
|---|---|---|---|
| cached_reads | context | 2.336 ns | 2.313 ns - 2.363 ns |
| cached_reads | thread_safe_context | 58.633 ns | 57.688 ns - 59.689 ns |
| cold_first_get | context | 102.567 ns | 94.254 ns - 110.452 ns |
| cold_first_get | thread_safe_context | 1.105 us | 1.053 us - 1.163 us |
| dependency_fan_out | context / 32 | 2.290 us | 2.140 us - 2.445 us |
| dependency_fan_out | context / 256 | 17.442 us | 16.531 us - 18.338 us |
| dependency_fan_out | thread_safe_context / 32 | 19.216 us | 18.934 us - 19.585 us |
| dependency_fan_out | thread_safe_context / 256 | 149.046 us | 147.325 us - 150.915 us |
| set_cell_invalidation | high_fan_out / 512 | 104.624 us | 95.189 us - 113.806 us |
| set_cell_invalidation | same_slot_contention / 1 | 78.065 us | 75.748 us - 80.340 us |
| set_cell_invalidation | same_slot_contention / 2 | 165.389 us | 162.757 us - 168.326 us |
| set_cell_invalidation | same_slot_contention / 4 | 472.810 us | 460.139 us - 485.050 us |
| set_cell_invalidation | same_slot_contention / 8 | 1.266 ms | 1.187 ms - 1.340 ms |
| set_cell_invalidation | same_slot_contention / 16 | 2.752 ms | 2.628 ms - 2.884 ms |
| set_cell_invalidation | independent_slot_contention / 1 | 77.279 us | 76.071 us - 78.495 us |
| set_cell_invalidation | independent_slot_contention / 2 | 156.226 us | 152.965 us - 159.624 us |
| set_cell_invalidation | independent_slot_contention / 4 | 448.904 us | 433.647 us - 465.555 us |
| set_cell_invalidation | independent_slot_contention / 8 | 1.365 ms | 1.256 ms - 1.485 ms |
| set_cell_invalidation | independent_slot_contention / 16 | 2.732 ms | 2.488 ms - 2.991 ms |
| set_cell_invalidation | batched_write_bursts / 1 | 142.353 us | 141.151 us - 143.418 us |
| set_cell_invalidation | batched_write_bursts / 2 | 203.977 us | 201.505 us - 206.645 us |
| set_cell_invalidation | batched_write_bursts / 4 | 491.988 us | 482.168 us - 501.495 us |
| set_cell_invalidation | batched_write_bursts / 8 | 1.201 ms | 1.149 ms - 1.249 ms |
| set_cell_invalidation | batched_write_bursts / 16 | 3.145 ms | 3.021 ms - 3.292 ms |
| memo_equality_suppression | context | 1.269 us | 1.166 us - 1.368 us |
| memo_equality_suppression | thread_safe_context | 25.558 us | 24.982 us - 26.338 us |
| effect_flushing | context | 31.760 ns | 31.619 ns - 31.929 ns |
| effect_flushing | thread_safe_context | 912.667 ns | 901.415 ns - 924.679 ns |
| batch_storms | context / 64 | 1.999 us | 1.982 us - 2.021 us |
| batch_storms | thread_safe_context / 64 | 7.316 us | 7.277 us - 7.360 us |
| thread_safe_contention | same_slot_write_read / 1 | 130.742 us | 128.739 us - 132.731 us |
| thread_safe_contention | same_slot_write_read / 2 | 396.473 us | 383.073 us - 409.953 us |
| thread_safe_contention | same_slot_write_read / 4 | 971.636 us | 909.543 us - 1.031 ms |
| thread_safe_contention | same_slot_write_read / 8 | 2.714 ms | 2.462 ms - 2.950 ms |
| thread_safe_contention | same_slot_write_read / 16 | 7.027 ms | 6.709 ms - 7.353 ms |
| thread_safe_contention | independent_slots / 1 | 130.414 us | 127.419 us - 133.172 us |
| thread_safe_contention | independent_slots / 2 | 260.812 us | 254.009 us - 268.127 us |
| thread_safe_contention | independent_slots / 4 | 700.189 us | 668.411 us - 727.576 us |
| thread_safe_contention | independent_slots / 8 | 2.451 ms | 2.293 ms - 2.606 ms |
| thread_safe_contention | independent_slots / 16 | 5.523 ms | 5.050 ms - 5.960 ms |
| thread_safe_contention | read_mostly_waiters / 1 | 130.430 us | 128.775 us - 132.244 us |
| thread_safe_contention | read_mostly_waiters / 2 | 157.747 us | 154.031 us - 161.903 us |
| thread_safe_contention | read_mostly_waiters / 4 | 231.663 us | 230.369 us - 233.106 us |
| thread_safe_contention | read_mostly_waiters / 8 | 627.512 us | 586.493 us - 668.411 us |
| thread_safe_contention | read_mostly_waiters / 16 | 1.388 ms | 1.298 ms - 1.462 ms |
| thread_safe_contention | batched_write_bursts / 1 | 206.661 us | 205.032 us - 208.194 us |
| thread_safe_contention | batched_write_bursts / 2 | 545.496 us | 523.208 us - 570.543 us |
| thread_safe_contention | batched_write_bursts / 4 | 1.411 ms | 1.401 ms - 1.421 ms |
| thread_safe_contention | batched_write_bursts / 8 | 2.397 ms | 2.301 ms - 2.478 ms |
| thread_safe_contention | batched_write_bursts / 16 | 3.960 ms | 3.759 ms - 4.151 ms |
| thread_safe_effect_contention | queue_coalescing / 8 | 1.159 ms | 1.094 ms - 1.217 ms |
| thread_safe_effect_contention | queue_coalescing / 16 | 3.124 ms | 2.887 ms - 3.345 ms |
| thread_safe_effect_contention | cleanup_execution / 8 | 1.278 ms | 1.209 ms - 1.343 ms |
| thread_safe_effect_contention | cleanup_execution / 16 | 3.200 ms | 2.932 ms - 3.478 ms |
| thread_safe_effect_contention | batch_flush / 8 | 2.280 ms | 2.081 ms - 2.495 ms |
| thread_safe_effect_contention | batch_flush / 16 | 5.026 ms | 4.398 ms - 5.718 ms |
| thread_safe_graph_propagation | fan_out_eager_validation / 8 | 3.025 ms | 2.996 ms - 3.056 ms |
| thread_safe_graph_propagation | fan_out_eager_validation / 16 | 4.953 ms | 4.867 ms - 5.060 ms |
| thread_safe_graph_propagation | fan_out_lazy_dirty_epochs / 8 | 1.753 ms | 1.726 ms - 1.784 ms |
| thread_safe_graph_propagation | fan_out_lazy_dirty_epochs / 16 | 3.567 ms | 3.476 ms - 3.666 ms |
| thread_safe_graph_propagation | fan_in_lazy_dirty_epochs / 8 | 3.134 ms | 2.728 ms - 3.538 ms |
| thread_safe_graph_propagation | fan_in_lazy_dirty_epochs / 16 | 7.596 ms | 7.221 ms - 7.941 ms |
| thread_safe_graph_propagation | fan_in_batched_flush / 8 | 1.058 ms | 1.016 ms - 1.099 ms |
| thread_safe_graph_propagation | fan_in_batched_flush / 16 | 1.827 ms | 1.750 ms - 1.915 ms |
| profile_instrumentation | context_snapshot | 235.293 ns | 234.448 ns - 236.245 ns |
| profile_instrumentation | thread_safe_snapshot | 293.183 us | 291.255 us - 294.845 us |
| async_cached_resolve | async_context | 4.722 us | 4.423 us - 5.046 us |
| async_cached_resolve | sync_context_baseline | 68.269 ns | 65.310 ns - 71.694 ns |
| async_cached_resolve | sync_get | 12.818 ns | 12.575 ns - 13.066 ns |
| async_cached_resolve | thread_safe_context_baseline | 1.378 us | 1.354 us - 1.405 us |
| async_cold_resolve | async_context | 4.005 us | 3.834 us - 4.179 us |
| async_cold_resolve | sync_context_baseline | 100.095 ns | 93.421 ns - 105.453 ns |
| async_cold_resolve | thread_safe_context_baseline | 933.131 ns | 923.714 ns - 944.643 ns |
| async_invalidation_throughput | async_context | 276.614 us | 253.909 us - 303.372 us |
| async_invalidation_throughput | sync_context_baseline | 2.452 us | 2.444 us - 2.464 us |
| async_invalidation_throughput | thread_safe_context_baseline | 53.932 us | 53.844 us - 54.032 us |
| async_cancellation_throughput | async_invalidate_in_flight | 67.768 us | 54.110 us - 81.021 us |
| async_concurrent_contention | async_context / 1 | 71.438 us | 70.571 us - 72.275 us |
| async_concurrent_contention | async_context / 4 | 337.954 us | 299.588 us - 367.654 us |
| async_concurrent_contention | async_context / 16 | 1.942 ms | 1.793 ms - 2.100 ms |
| async_concurrent_contention | thread_safe_context_baseline / 1 | 79.512 us | 78.198 us - 80.677 us |
| async_concurrent_contention | thread_safe_context_baseline / 4 | 662.336 us | 651.978 us - 670.977 us |
| async_concurrent_contention | thread_safe_context_baseline / 16 | 3.675 ms | 3.623 ms - 3.710 ms |
| async_effect_throughput | async_context | 188.151 ms | 188.039 ms - 188.238 ms |
| async_batch_throughput | async_context | 71.850 us | 67.474 us - 76.897 us |
| async_batch_throughput | sync_context_baseline | 9.448 us | 8.598 us - 10.390 us |
| tokio_sync_cached_read | single_task | 1.433 us | 1.427 us - 1.438 us |
| tokio_sync_cached_read | spawn_read | 5.018 us | 4.698 us - 5.436 us |
| tokio_sync_cold_first_get | single_task | 1.421 us | 1.393 us - 1.455 us |
| tokio_sync_cold_first_get | spawn_compute | 5.195 us | 4.890 us - 5.505 us |
| tokio_sync_invalidation | single_task | 55.059 us | 54.737 us - 55.394 us |
| tokio_sync_concurrent_contention | same_slot_write_read / 1 | 60.281 us | 59.482 us - 61.148 us |
| tokio_sync_concurrent_contention | same_slot_write_read / 4 | 447.492 us | 414.606 us - 485.461 us |
| tokio_sync_concurrent_contention | same_slot_write_read / 16 | 4.202 ms | 4.075 ms - 4.332 ms |
| tokio_sync_concurrent_contention | independent_slots / 1 | 59.662 us | 59.075 us - 60.264 us |
| tokio_sync_concurrent_contention | independent_slots / 4 | 394.242 us | 363.367 us - 427.120 us |
| tokio_sync_concurrent_contention | independent_slots / 16 | 3.263 ms | 3.166 ms - 3.350 ms |
| tokio_sync_batch | spawn_batch | 46.970 us | 46.860 us - 47.088 us |
| tokio_sync_effect | single_task | 10.091 ms | 10.088 ms - 10.094 ms |
| scale | build | 65.284 ms | 64.821 ms - 65.809 ms |
| scale | cold_full_recalc | 43.326 ms | 43.255 ms - 43.391 ms |
| scale | full_recalc_invalidate_all | 54.419 ms | 53.734 ms - 55.098 ms |
| scale | viewport_recalc | 2.297 us | 2.260 us - 2.347 us |
| queue_reactive_shell_overhead | raw_vecdeque_push_pop | 1.264 ns | 1.215 ns - 1.322 ns |
| queue_reactive_shell_overhead | subscribed_len_push_pop | 93.316 ns | 89.570 ns - 99.791 ns |
| queue_reactive_shell_overhead | unsubscribed_push_pop | 16.785 ns | 16.715 ns - 16.865 ns |
| revision_write_cost | push / 1 | 212.485 ns | 208.751 ns - 216.685 ns |
| revision_write_cost | push / 16 | 1.051 us | 1.049 us - 1.054 us |
| revision_write_cost | push / 128 | 9.966 us | 9.913 us - 10.034 us |
| revision_write_cost | push / 1024 | 97.254 us | 94.787 us - 99.737 us |
| revision_write_cost | revision / 1 | 122.388 ns | 122.202 ns - 122.593 ns |
| revision_write_cost | revision / 16 | 788.027 ns | 786.220 ns - 789.847 ns |
| revision_write_cost | revision / 128 | 8.254 us | 8.228 us - 8.282 us |
| revision_write_cost | revision / 1024 | 72.269 us | 70.616 us - 74.098 us |
| revision_write_then_read | push / 1 | 105.101 ns | 104.658 ns - 105.598 ns |
| revision_write_then_read | push / 16 | 1.292 us | 1.289 us - 1.296 us |
| revision_write_then_read | push / 128 | 13.515 us | 13.465 us - 13.592 us |
| revision_write_then_read | push / 1024 | 109.586 us | 108.925 us - 110.419 us |
| revision_write_then_read | revision / 1 | 92.298 ns | 91.863 ns - 92.979 ns |
| revision_write_then_read | revision / 16 | 1.214 us | 1.211 us - 1.216 us |
| revision_write_then_read | revision / 128 | 12.944 us | 12.900 us - 12.997 us |
| revision_write_then_read | revision / 1024 | 106.959 us | 106.744 us - 107.188 us |
| typed_cache_reads | context_cell | 0.737 ns | 0.735 ns - 0.738 ns |
| typed_cache_reads | context_rc_cell | 4.884 ns | 4.872 ns - 4.895 ns |
| typed_cache_reads | context_rc_slot | 7.498 ns | 7.210 ns - 7.861 ns |
| typed_cache_reads | context_slot | 2.271 ns | 2.263 ns - 2.280 ns |
| typed_cache_reads | thread_safe_arc_slot | 64.431 ns | 64.001 ns - 65.137 ns |
| typed_cache_reads | thread_safe_arc_string_slot | 64.132 ns | 63.956 ns - 64.334 ns |
| typed_cache_reads | thread_safe_cell | 24.342 ns | 24.238 ns - 24.465 ns |
| typed_cache_reads | thread_safe_slot | 57.543 ns | 57.000 ns - 58.113 ns |
| typed_cache_reads | thread_safe_string_slot | 69.988 ns | 69.815 ns - 70.211 ns |
Instrumentation snapshots are single local profile runs captured by
examples/instrumentation_profile.rs.
| Profile | Alloc | Recomputes | Duplicate recomputes | Edges + | Edges - | Effect pushes | Max queue | Lock acquisitions | Lock wait | Lock hold | Sidecar frontiers | Sidecar dirty marks | Sidecar fallbacks | Dirty epochs |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| context_memo_effect | 4 | 3 | 0 | 4 | 1 | 2 | 1 | 0 | 0.000 ns | 0.000 ns | 0 | 0 | 0 | 0 |
| context_fan_out_32 | 33 | 64 | 0 | 64 | 32 | 0 | 0 | 0 | 0.000 ns | 0.000 ns | 0 | 0 | 0 | 0 |
| context_batch_storm_64 | 65 | 0 | 0 | 128 | 64 | 2 | 1 | 0 | 0.000 ns | 0.000 ns | 0 | 0 | 0 | 0 |
| thread_safe_first_get_2 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 11 | 4.660 us | 15.940 us | 0 | 0 | 0 | 0 |
| thread_safe_set_cell_invalidation_high_fan_out_512 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 70.000 ns | 508.394 us | 0 | 0 | 0 | 512 |
| thread_safe_set_cell_invalidation_same_slot_contention_1 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 56 | 1.440 us | 18.830 us | 0 | 0 | 0 | 16 |
| thread_safe_set_cell_invalidation_same_slot_contention_2 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 96 | 114.761 us | 52.190 us | 0 | 0 | 0 | 32 |
| thread_safe_set_cell_invalidation_same_slot_contention_4 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 172 | 560.403 us | 76.491 us | 0 | 0 | 0 | 64 |
| thread_safe_set_cell_invalidation_same_slot_contention_8 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 300 | 2.687 ms | 177.351 us | 0 | 0 | 0 | 128 |
| thread_safe_set_cell_invalidation_same_slot_contention_16 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 549 | 11.390 ms | 320.041 us | 0 | 0 | 0 | 256 |
| thread_safe_set_cell_invalidation_independent_slot_contention_1 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 53 | 1.450 us | 12.220 us | 0 | 0 | 0 | 15 |
| thread_safe_set_cell_invalidation_independent_slot_contention_2 | 4 | 2 | 0 | 2 | 0 | 0 | 0 | 107 | 39.190 us | 23.890 us | 0 | 0 | 0 | 31 |
| thread_safe_set_cell_invalidation_independent_slot_contention_4 | 8 | 4 | 0 | 4 | 0 | 0 | 0 | 178 | 290.324 us | 50.760 us | 0 | 0 | 0 | 63 |
| thread_safe_set_cell_invalidation_independent_slot_contention_8 | 16 | 8 | 0 | 8 | 0 | 0 | 0 | 351 | 2.219 ms | 129.431 us | 0 | 0 | 0 | 127 |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | 32 | 16 | 0 | 16 | 0 | 0 | 0 | 678 | 9.647 ms | 269.073 us | 0 | 0 | 0 | 255 |
| thread_safe_set_cell_invalidation_batched_write_bursts_1 | 5 | 1 | 0 | 4 | 0 | 0 | 0 | 97 | 2.680 us | 47.010 us | 0 | 0 | 0 | 15 |
| thread_safe_set_cell_invalidation_batched_write_bursts_2 | 9 | 1 | 0 | 8 | 0 | 0 | 0 | 126 | 80.661 us | 71.180 us | 0 | 0 | 0 | 11 |
| thread_safe_set_cell_invalidation_batched_write_bursts_4 | 17 | 1 | 0 | 16 | 0 | 0 | 0 | 196 | 536.435 us | 129.921 us | 0 | 0 | 0 | 5 |
| thread_safe_set_cell_invalidation_batched_write_bursts_8 | 33 | 1 | 0 | 32 | 0 | 0 | 0 | 372 | 2.020 ms | 218.222 us | 0 | 0 | 0 | 5 |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | 65 | 1 | 0 | 64 | 0 | 0 | 0 | 712 | 8.814 ms | 429.075 us | 0 | 0 | 0 | 1 |
| thread_safe_contention_same_slot_write_read_1 | 2 | 17 | 0 | 1 | 0 | 0 | 0 | 72 | 1.920 us | 28.850 us | 0 | 0 | 0 | 16 |
| thread_safe_contention_same_slot_write_read_2 | 2 | 21 | 0 | 1 | 0 | 0 | 0 | 138 | 31.710 us | 52.670 us | 0 | 0 | 0 | 32 |
| thread_safe_contention_same_slot_write_read_4 | 2 | 51 | 0 | 1 | 0 | 0 | 0 | 336 | 148.824 us | 114.141 us | 0 | 0 | 0 | 64 |
| thread_safe_contention_same_slot_write_read_8 | 2 | 116 | 0 | 1 | 0 | 0 | 0 | 659 | 233.610 us | 369.544 us | 0 | 0 | 0 | 128 |
| thread_safe_contention_same_slot_write_read_16 | 2 | 226 | 0 | 1 | 0 | 0 | 0 | 1301 | 1.289 ms | 591.265 us | 0 | 0 | 0 | 256 |
| thread_safe_contention_independent_slots_1 | 2 | 16 | 0 | 1 | 0 | 0 | 0 | 68 | 1.760 us | 22.791 us | 0 | 0 | 0 | 15 |
| thread_safe_contention_independent_slots_2 | 4 | 33 | 0 | 2 | 0 | 0 | 0 | 139 | 34.880 us | 46.740 us | 0 | 0 | 0 | 31 |
| thread_safe_contention_independent_slots_4 | 8 | 67 | 0 | 4 | 0 | 0 | 0 | 254 | 488.475 us | 100.050 us | 0 | 0 | 0 | 63 |
| thread_safe_contention_independent_slots_8 | 16 | 135 | 0 | 8 | 0 | 0 | 0 | 487 | 3.782 ms | 253.891 us | 0 | 0 | 0 | 127 |
| thread_safe_contention_independent_slots_16 | 32 | 271 | 0 | 16 | 0 | 0 | 0 | 942 | 18.712 ms | 548.815 us | 0 | 0 | 0 | 255 |
| thread_safe_contention_read_mostly_waiters_1 | 2 | 17 | 0 | 1 | 0 | 0 | 0 | 72 | 1.930 us | 25.910 us | 0 | 0 | 0 | 16 |
| thread_safe_contention_read_mostly_waiters_2 | 2 | 17 | 0 | 1 | 0 | 0 | 0 | 75 | 3.570 us | 26.600 us | 0 | 0 | 0 | 16 |
| thread_safe_contention_read_mostly_waiters_4 | 2 | 17 | 0 | 1 | 0 | 0 | 0 | 85 | 23.500 us | 35.450 us | 0 | 0 | 0 | 16 |
| thread_safe_contention_read_mostly_waiters_8 | 2 | 18 | 0 | 1 | 0 | 0 | 0 | 110 | 40.411 us | 51.520 us | 0 | 0 | 0 | 16 |
| thread_safe_contention_read_mostly_waiters_16 | 2 | 18 | 0 | 1 | 0 | 0 | 0 | 141 | 181.890 us | 71.861 us | 0 | 0 | 0 | 16 |
| thread_safe_contention_batched_write_bursts_1 | 5 | 16 | 0 | 4 | 0 | 0 | 0 | 112 | 3.110 us | 57.260 us | 0 | 0 | 0 | 15 |
| thread_safe_contention_batched_write_bursts_2 | 9 | 22 | 0 | 8 | 0 | 0 | 0 | 193 | 61.892 us | 93.041 us | 0 | 0 | 0 | 21 |
| thread_safe_contention_batched_write_bursts_4 | 17 | 40 | 0 | 16 | 0 | 0 | 0 | 392 | 347.523 us | 248.591 us | 0 | 0 | 0 | 39 |
| thread_safe_contention_batched_write_bursts_8 | 33 | 6 | 0 | 32 | 0 | 0 | 0 | 384 | 2.396 ms | 236.474 us | 0 | 0 | 0 | 5 |
| thread_safe_contention_batched_write_bursts_16 | 65 | 12 | 0 | 64 | 0 | 0 | 0 | 761 | 8.077 ms | 451.265 us | 0 | 0 | 0 | 11 |
| thread_safe_effect_contention_queue_coalescing_8 | 33 | 0 | 0 | 32 | 0 | 3 | 1 | 375 | 1.655 ms | 214.062 us | 0 | 0 | 0 | 0 |
| thread_safe_effect_contention_queue_coalescing_16 | 65 | 0 | 0 | 64 | 0 | 5 | 1 | 741 | 7.927 ms | 415.253 us | 0 | 0 | 0 | 0 |
| thread_safe_effect_contention_cleanup_execution_8 | 9 | 0 | 0 | 8 | 8 | 32 | 1 | 408 | 2.219 ms | 168.331 us | 0 | 0 | 0 | 0 |
| thread_safe_effect_contention_cleanup_execution_16 | 17 | 0 | 0 | 16 | 16 | 36 | 1 | 704 | 10.265 ms | 317.073 us | 0 | 0 | 0 | 0 |
| thread_safe_effect_contention_batch_flush_8 | 34 | 4 | 0 | 33 | 0 | 5 | 1 | 642 | 4.330 ms | 311.102 us | 0 | 0 | 0 | 3 |
| thread_safe_effect_contention_batch_flush_16 | 66 | 5 | 0 | 65 | 0 | 9 | 1 | 1263 | 13.865 ms | 560.432 us | 0 | 0 | 0 | 4 |
| thread_safe_graph_propagation_fan_out_eager_validation_8 | 34 | 560 | 0 | 64 | 0 | 50 | 1 | 1167 | 17.536 ms | 3.801 ms | 0 | 0 | 0 | 4096 |
| thread_safe_graph_propagation_fan_out_eager_validation_16 | 34 | 561 | 0 | 64 | 0 | 50 | 1 | 1424 | 72.138 ms | 6.855 ms | 0 | 0 | 0 | 8192 |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_8 | 33 | 64 | 0 | 32 | 0 | 0 | 0 | 498 | 16.653 ms | 3.050 ms | 0 | 0 | 0 | 4096 |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_16 | 33 | 64 | 0 | 32 | 0 | 0 | 0 | 767 | 73.114 ms | 6.013 ms | 0 | 0 | 0 | 8192 |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_8 | 65 | 66 | 0 | 64 | 0 | 0 | 0 | 1445 | 6.660 ms | 513.015 us | 0 | 0 | 0 | 572 |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_16 | 129 | 130 | 0 | 128 | 0 | 0 | 0 | 2789 | 30.138 ms | 1.018 ms | 0 | 0 | 0 | 1148 |
| thread_safe_graph_propagation_fan_in_batched_flush_8 | 66 | 151 | 0 | 65 | 0 | 25 | 1 | 1409 | 2.606 ms | 578.384 us | 0 | 0 | 0 | 258 |
| thread_safe_graph_propagation_fan_in_batched_flush_16 | 130 | 130 | 0 | 129 | 0 | 3 | 1 | 1183 | 5.961 ms | 570.577 us | 0 | 0 | 0 | 141 |
ThreadSafe lock attribution for contention profiles:
| Profile | Site | Lock acquisitions | Lock wait | Lock hold |
|---|---|---|---|---|
| thread_safe_set_cell_invalidation_high_fan_out_512 | other | 2 | 40.000 ns | 370.000 ns |
| thread_safe_set_cell_invalidation_high_fan_out_512 | set_cell_invalidation | 1 | 30.000 ns | 508.024 us |
| thread_safe_set_cell_invalidation_same_slot_contention_1 | other | 36 | 910.000 ns | 1.660 us |
| thread_safe_set_cell_invalidation_same_slot_contention_1 | get_refresh | 2 | 60.000 ns | 170.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_1 | dependency_edge | 1 | 30.000 ns | 440.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_1 | set_cell_invalidation | 16 | 420.000 ns | 16.270 us |
| thread_safe_set_cell_invalidation_same_slot_contention_1 | publish | 1 | 20.000 ns | 290.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_2 | other | 60 | 57.910 us | 3.380 us |
| thread_safe_set_cell_invalidation_same_slot_contention_2 | get_refresh | 2 | 60.000 ns | 150.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_2 | dependency_edge | 1 | 30.000 ns | 350.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_2 | set_cell_invalidation | 32 | 56.731 us | 48.030 us |
| thread_safe_set_cell_invalidation_same_slot_contention_2 | publish | 1 | 30.000 ns | 280.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_4 | other | 104 | 276.211 us | 5.560 us |
| thread_safe_set_cell_invalidation_same_slot_contention_4 | get_refresh | 2 | 60.000 ns | 140.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_4 | dependency_edge | 1 | 30.000 ns | 290.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_4 | set_cell_invalidation | 64 | 284.082 us | 70.231 us |
| thread_safe_set_cell_invalidation_same_slot_contention_4 | publish | 1 | 20.000 ns | 270.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_8 | other | 168 | 1.352 ms | 11.900 us |
| thread_safe_set_cell_invalidation_same_slot_contention_8 | get_refresh | 2 | 260.000 ns | 2.700 us |
| thread_safe_set_cell_invalidation_same_slot_contention_8 | dependency_edge | 1 | 60.000 ns | 2.630 us |
| thread_safe_set_cell_invalidation_same_slot_contention_8 | set_cell_invalidation | 128 | 1.334 ms | 158.011 us |
| thread_safe_set_cell_invalidation_same_slot_contention_8 | publish | 1 | 50.000 ns | 2.110 us |
| thread_safe_set_cell_invalidation_same_slot_contention_16 | other | 289 | 5.344 ms | 16.970 us |
| thread_safe_set_cell_invalidation_same_slot_contention_16 | get_refresh | 2 | 70.000 ns | 470.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_16 | dependency_edge | 1 | 30.000 ns | 630.000 ns |
| thread_safe_set_cell_invalidation_same_slot_contention_16 | set_cell_invalidation | 256 | 6.046 ms | 301.401 us |
| thread_safe_set_cell_invalidation_same_slot_contention_16 | publish | 1 | 20.000 ns | 570.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_1 | other | 34 | 900.000 ns | 1.310 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_1 | get_refresh | 2 | 70.000 ns | 440.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_1 | dependency_edge | 1 | 30.000 ns | 630.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_1 | set_cell_invalidation | 15 | 420.000 ns | 9.400 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_1 | publish | 1 | 30.000 ns | 440.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_2 | other | 68 | 22.950 us | 2.590 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_2 | get_refresh | 4 | 100.000 ns | 310.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_2 | dependency_edge | 2 | 60.000 ns | 560.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_2 | set_cell_invalidation | 31 | 16.040 us | 19.910 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_2 | publish | 2 | 40.000 ns | 520.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_4 | other | 99 | 152.892 us | 4.060 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_4 | get_refresh | 8 | 211.000 ns | 620.000 ns |
| thread_safe_set_cell_invalidation_independent_slot_contention_4 | dependency_edge | 4 | 100.000 ns | 1.110 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_4 | set_cell_invalidation | 63 | 137.011 us | 43.930 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_4 | publish | 4 | 110.000 ns | 1.040 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_8 | other | 192 | 1.137 ms | 10.890 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_8 | get_refresh | 16 | 540.000 ns | 2.000 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_8 | dependency_edge | 8 | 220.000 ns | 4.090 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_8 | set_cell_invalidation | 127 | 1.081 ms | 109.581 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_8 | publish | 8 | 220.000 ns | 2.870 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | other | 359 | 4.260 ms | 20.650 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | get_refresh | 32 | 990.000 ns | 3.380 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | dependency_edge | 16 | 420.000 ns | 6.900 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | set_cell_invalidation | 255 | 5.386 ms | 233.053 us |
| thread_safe_set_cell_invalidation_independent_slot_contention_16 | publish | 16 | 410.000 ns | 5.090 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_1 | other | 74 | 2.040 us | 14.690 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_1 | get_refresh | 2 | 130.000 ns | 990.000 ns |
| thread_safe_set_cell_invalidation_batched_write_bursts_1 | dependency_edge | 4 | 100.000 ns | 2.240 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_1 | set_cell_invalidation | 16 | 390.000 ns | 25.050 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_1 | publish | 1 | 20.000 ns | 4.040 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_2 | other | 104 | 79.961 us | 34.330 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_2 | get_refresh | 2 | 130.000 ns | 980.000 ns |
| thread_safe_set_cell_invalidation_batched_write_bursts_2 | dependency_edge | 8 | 210.000 ns | 3.760 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_2 | set_cell_invalidation | 11 | 330.000 ns | 31.060 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_2 | publish | 1 | 30.000 ns | 1.050 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_4 | other | 172 | 535.855 us | 101.611 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_4 | get_refresh | 2 | 40.000 ns | 140.000 ns |
| thread_safe_set_cell_invalidation_batched_write_bursts_4 | dependency_edge | 16 | 380.000 ns | 5.420 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_4 | set_cell_invalidation | 5 | 140.000 ns | 22.480 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_4 | publish | 1 | 20.000 ns | 270.000 ns |
| thread_safe_set_cell_invalidation_batched_write_bursts_8 | other | 332 | 2.019 ms | 185.041 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_8 | get_refresh | 2 | 50.000 ns | 300.000 ns |
| thread_safe_set_cell_invalidation_batched_write_bursts_8 | dependency_edge | 32 | 780.000 ns | 12.600 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_8 | set_cell_invalidation | 5 | 160.000 ns | 19.811 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_8 | publish | 1 | 20.000 ns | 470.000 ns |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | other | 644 | 8.812 ms | 384.343 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | get_refresh | 2 | 50.000 ns | 350.000 ns |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | dependency_edge | 64 | 1.680 us | 29.101 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | set_cell_invalidation | 1 | 20.000 ns | 14.931 us |
| thread_safe_set_cell_invalidation_batched_write_bursts_16 | publish | 1 | 20.000 ns | 350.000 ns |
| thread_safe_contention_same_slot_write_read_1 | other | 36 | 910.000 ns | 1.470 us |
| thread_safe_contention_same_slot_write_read_1 | get_refresh | 2 | 60.000 ns | 320.000 ns |
| thread_safe_contention_same_slot_write_read_1 | dependency_edge | 1 | 30.000 ns | 460.000 ns |
| thread_safe_contention_same_slot_write_read_1 | set_cell_invalidation | 16 | 470.000 ns | 13.770 us |
| thread_safe_contention_same_slot_write_read_1 | publish | 17 | 450.000 ns | 12.830 us |
| thread_safe_contention_same_slot_write_read_2 | other | 66 | 21.090 us | 2.480 us |
| thread_safe_contention_same_slot_write_read_2 | get_refresh | 2 | 60.000 ns | 150.000 ns |
| thread_safe_contention_same_slot_write_read_2 | dependency_edge | 1 | 30.000 ns | 320.000 ns |
| thread_safe_contention_same_slot_write_read_2 | set_cell_invalidation | 32 | 9.740 us | 24.360 us |
| thread_safe_contention_same_slot_write_read_2 | publish | 21 | 790.000 ns | 25.360 us |
| thread_safe_contention_same_slot_write_read_2 | in_flight_wait | 16 | 0.000 ns | 0.000 ns |
| thread_safe_contention_same_slot_write_read_4 | other | 122 | 78.313 us | 4.630 us |
| thread_safe_contention_same_slot_write_read_4 | get_refresh | 21 | 12.240 us | 5.220 us |
| thread_safe_contention_same_slot_write_read_4 | dependency_edge | 1 | 30.000 ns | 270.000 ns |
| thread_safe_contention_same_slot_write_read_4 | set_cell_invalidation | 64 | 51.531 us | 50.551 us |
| thread_safe_contention_same_slot_write_read_4 | publish | 51 | 6.710 us | 53.470 us |
| thread_safe_contention_same_slot_write_read_4 | in_flight_wait | 77 | 0.000 ns | 0.000 ns |
| thread_safe_contention_same_slot_write_read_8 | other | 251 | 99.210 us | 8.990 us |
| thread_safe_contention_same_slot_write_read_8 | get_refresh | 6 | 1.060 us | 800.000 ns |
| thread_safe_contention_same_slot_write_read_8 | dependency_edge | 1 | 20.000 ns | 370.000 ns |
| thread_safe_contention_same_slot_write_read_8 | set_cell_invalidation | 128 | 89.220 us | 111.221 us |
| thread_safe_contention_same_slot_write_read_8 | publish | 116 | 44.100 us | 248.163 us |
| thread_safe_contention_same_slot_write_read_8 | in_flight_wait | 157 | 0.000 ns | 0.000 ns |
| thread_safe_contention_same_slot_write_read_16 | other | 488 | 478.632 us | 18.520 us |
| thread_safe_contention_same_slot_write_read_16 | get_refresh | 42 | 15.450 us | 8.500 us |
| thread_safe_contention_same_slot_write_read_16 | dependency_edge | 1 | 20.000 ns | 410.000 ns |
| thread_safe_contention_same_slot_write_read_16 | set_cell_invalidation | 256 | 676.484 us | 223.400 us |
| thread_safe_contention_same_slot_write_read_16 | publish | 226 | 118.911 us | 340.435 us |
| thread_safe_contention_same_slot_write_read_16 | in_flight_wait | 288 | 0.000 ns | 0.000 ns |
| thread_safe_contention_independent_slots_1 | other | 34 | 830.000 ns | 1.550 us |
| thread_safe_contention_independent_slots_1 | get_refresh | 2 | 60.000 ns | 270.000 ns |
| thread_safe_contention_independent_slots_1 | dependency_edge | 1 | 30.000 ns | 820.000 ns |
| thread_safe_contention_independent_slots_1 | set_cell_invalidation | 15 | 410.000 ns | 9.651 us |
| thread_safe_contention_independent_slots_1 | publish | 16 | 430.000 ns | 10.500 us |
| thread_safe_contention_independent_slots_2 | other | 69 | 19.530 us | 2.600 us |
| thread_safe_contention_independent_slots_2 | get_refresh | 4 | 120.000 ns | 290.000 ns |
| thread_safe_contention_independent_slots_2 | dependency_edge | 2 | 60.000 ns | 940.000 ns |
| thread_safe_contention_independent_slots_2 | set_cell_invalidation | 31 | 7.010 us | 21.110 us |
| thread_safe_contention_independent_slots_2 | publish | 33 | 8.160 us | 21.800 us |
| thread_safe_contention_independent_slots_4 | other | 112 | 197.463 us | 4.860 us |
| thread_safe_contention_independent_slots_4 | get_refresh | 8 | 220.000 ns | 690.000 ns |
| thread_safe_contention_independent_slots_4 | dependency_edge | 4 | 90.000 ns | 1.710 us |
| thread_safe_contention_independent_slots_4 | set_cell_invalidation | 63 | 183.671 us | 45.110 us |
| thread_safe_contention_independent_slots_4 | publish | 67 | 107.031 us | 47.680 us |
| thread_safe_contention_independent_slots_8 | other | 201 | 1.179 ms | 10.150 us |
| thread_safe_contention_independent_slots_8 | get_refresh | 16 | 450.000 ns | 1.570 us |
| thread_safe_contention_independent_slots_8 | dependency_edge | 8 | 220.000 ns | 3.260 us |
| thread_safe_contention_independent_slots_8 | set_cell_invalidation | 127 | 1.466 ms | 115.011 us |
| thread_safe_contention_independent_slots_8 | publish | 135 | 1.137 ms | 123.900 us |
| thread_safe_contention_independent_slots_16 | other | 368 | 6.186 ms | 21.270 us |
| thread_safe_contention_independent_slots_16 | get_refresh | 32 | 910.000 ns | 2.350 us |
| thread_safe_contention_independent_slots_16 | dependency_edge | 16 | 430.000 ns | 6.060 us |
| thread_safe_contention_independent_slots_16 | set_cell_invalidation | 255 | 6.200 ms | 253.442 us |
| thread_safe_contention_independent_slots_16 | publish | 271 | 6.324 ms | 265.693 us |
| thread_safe_contention_read_mostly_waiters_1 | other | 36 | 950.000 ns | 1.520 us |
| thread_safe_contention_read_mostly_waiters_1 | get_refresh | 2 | 50.000 ns | 300.000 ns |
| thread_safe_contention_read_mostly_waiters_1 | dependency_edge | 1 | 30.000 ns | 500.000 ns |
| thread_safe_contention_read_mostly_waiters_1 | set_cell_invalidation | 16 | 410.000 ns | 10.580 us |
| thread_safe_contention_read_mostly_waiters_1 | publish | 17 | 490.000 ns | 13.010 us |
| thread_safe_contention_read_mostly_waiters_2 | other | 36 | 980.000 ns | 1.130 us |
| thread_safe_contention_read_mostly_waiters_2 | get_refresh | 4 | 1.590 us | 1.410 us |
| thread_safe_contention_read_mostly_waiters_2 | dependency_edge | 1 | 20.000 ns | 320.000 ns |
| thread_safe_contention_read_mostly_waiters_2 | set_cell_invalidation | 16 | 480.000 ns | 11.060 us |
| thread_safe_contention_read_mostly_waiters_2 | publish | 17 | 500.000 ns | 12.680 us |
| thread_safe_contention_read_mostly_waiters_2 | in_flight_wait | 1 | 0.000 ns | 0.000 ns |
| thread_safe_contention_read_mostly_waiters_4 | other | 36 | 6.450 us | 1.390 us |
| thread_safe_contention_read_mostly_waiters_4 | get_refresh | 6 | 10.000 us | 2.260 us |
| thread_safe_contention_read_mostly_waiters_4 | dependency_edge | 1 | 20.000 ns | 540.000 ns |
| thread_safe_contention_read_mostly_waiters_4 | set_cell_invalidation | 16 | 470.000 ns | 11.540 us |
| thread_safe_contention_read_mostly_waiters_4 | publish | 17 | 6.560 us | 19.720 us |
| thread_safe_contention_read_mostly_waiters_4 | in_flight_wait | 9 | 0.000 ns | 0.000 ns |
| thread_safe_contention_read_mostly_waiters_8 | other | 36 | 5.240 us | 1.360 us |
| thread_safe_contention_read_mostly_waiters_8 | get_refresh | 17 | 33.370 us | 3.980 us |
| thread_safe_contention_read_mostly_waiters_8 | dependency_edge | 1 | 20.000 ns | 330.000 ns |
| thread_safe_contention_read_mostly_waiters_8 | set_cell_invalidation | 16 | 1.110 us | 12.460 us |
| thread_safe_contention_read_mostly_waiters_8 | publish | 18 | 671.000 ns | 33.390 us |
| thread_safe_contention_read_mostly_waiters_8 | in_flight_wait | 22 | 0.000 ns | 0.000 ns |
| thread_safe_contention_read_mostly_waiters_16 | other | 36 | 28.800 us | 1.681 us |
| thread_safe_contention_read_mostly_waiters_16 | get_refresh | 28 | 133.230 us | 15.500 us |
| thread_safe_contention_read_mostly_waiters_16 | dependency_edge | 1 | 30.000 ns | 320.000 ns |
| thread_safe_contention_read_mostly_waiters_16 | set_cell_invalidation | 16 | 1.410 us | 14.150 us |
| thread_safe_contention_read_mostly_waiters_16 | publish | 18 | 18.420 us | 40.210 us |
| thread_safe_contention_read_mostly_waiters_16 | in_flight_wait | 42 | 0.000 ns | 0.000 ns |
| thread_safe_contention_batched_write_bursts_1 | other | 74 | 2.120 us | 15.370 us |
| thread_safe_contention_batched_write_bursts_1 | get_refresh | 2 | 60.000 ns | 180.000 ns |
| thread_safe_contention_batched_write_bursts_1 | dependency_edge | 4 | 100.000 ns | 1.520 us |
| thread_safe_contention_batched_write_bursts_1 | set_cell_invalidation | 16 | 450.000 ns | 27.270 us |
| thread_safe_contention_batched_write_bursts_1 | publish | 16 | 380.000 ns | 12.920 us |
| thread_safe_contention_batched_write_bursts_2 | other | 124 | 52.151 us | 27.750 us |
| thread_safe_contention_batched_write_bursts_2 | get_refresh | 2 | 60.000 ns | 140.000 ns |
| thread_safe_contention_batched_write_bursts_2 | dependency_edge | 8 | 190.000 ns | 2.820 us |
| thread_safe_contention_batched_write_bursts_2 | set_cell_invalidation | 21 | 1.580 us | 43.191 us |
| thread_safe_contention_batched_write_bursts_2 | publish | 22 | 7.911 us | 19.140 us |
| thread_safe_contention_batched_write_bursts_2 | in_flight_wait | 16 | 0.000 ns | 0.000 ns |
| thread_safe_contention_batched_write_bursts_4 | other | 241 | 291.443 us | 68.700 us |
| thread_safe_contention_batched_write_bursts_4 | get_refresh | 4 | 8.160 us | 1.840 us |
| thread_safe_contention_batched_write_bursts_4 | dependency_edge | 16 | 420.000 ns | 6.320 us |
| thread_safe_contention_batched_write_bursts_4 | set_cell_invalidation | 40 | 14.540 us | 95.211 us |
| thread_safe_contention_batched_write_bursts_4 | publish | 40 | 32.960 us | 76.520 us |
| thread_safe_contention_batched_write_bursts_4 | in_flight_wait | 51 | 0.000 ns | 0.000 ns |
| thread_safe_contention_batched_write_bursts_8 | other | 332 | 2.394 ms | 186.453 us |
| thread_safe_contention_batched_write_bursts_8 | get_refresh | 2 | 50.000 ns | 240.000 ns |
| thread_safe_contention_batched_write_bursts_8 | dependency_edge | 32 | 880.000 ns | 13.770 us |
| thread_safe_contention_batched_write_bursts_8 | set_cell_invalidation | 5 | 140.000 ns | 20.790 us |
| thread_safe_contention_batched_write_bursts_8 | publish | 6 | 190.000 ns | 15.221 us |
| thread_safe_contention_batched_write_bursts_8 | in_flight_wait | 7 | 0.000 ns | 0.000 ns |
| thread_safe_contention_batched_write_bursts_16 | other | 664 | 8.074 ms | 337.402 us |
| thread_safe_contention_batched_write_bursts_16 | get_refresh | 2 | 60.000 ns | 350.000 ns |
| thread_safe_contention_batched_write_bursts_16 | dependency_edge | 64 | 1.670 us | 31.071 us |
| thread_safe_contention_batched_write_bursts_16 | set_cell_invalidation | 11 | 340.000 ns | 41.181 us |
| thread_safe_contention_batched_write_bursts_16 | publish | 12 | 400.000 ns | 41.261 us |
| thread_safe_contention_batched_write_bursts_16 | in_flight_wait | 8 | 0.000 ns | 0.000 ns |
| thread_safe_effect_contention_queue_coalescing_8 | other | 341 | 1.654 ms | 187.412 us |
| thread_safe_effect_contention_queue_coalescing_8 | dependency_edge | 32 | 810.000 ns | 10.710 us |
| thread_safe_effect_contention_queue_coalescing_8 | set_cell_invalidation | 2 | 50.000 ns | 15.940 us |
| thread_safe_effect_contention_queue_coalescing_16 | other | 673 | 7.925 ms | 377.592 us |
| thread_safe_effect_contention_queue_coalescing_16 | dependency_edge | 64 | 1.660 us | 20.880 us |
| thread_safe_effect_contention_queue_coalescing_16 | set_cell_invalidation | 4 | 110.000 ns | 16.781 us |
| thread_safe_effect_contention_cleanup_execution_8 | other | 265 | 1.236 ms | 45.260 us |
| thread_safe_effect_contention_cleanup_execution_8 | dependency_edge | 16 | 440.000 ns | 7.530 us |
| thread_safe_effect_contention_cleanup_execution_8 | set_cell_invalidation | 127 | 983.136 us | 115.541 us |
| thread_safe_effect_contention_cleanup_execution_16 | other | 417 | 4.902 ms | 70.260 us |
| thread_safe_effect_contention_cleanup_execution_16 | dependency_edge | 32 | 810.000 ns | 10.690 us |
| thread_safe_effect_contention_cleanup_execution_16 | set_cell_invalidation | 255 | 5.361 ms | 236.123 us |
| thread_safe_effect_contention_batch_flush_8 | other | 600 | 4.329 ms | 271.202 us |
| thread_safe_effect_contention_batch_flush_8 | get_refresh | 2 | 60.000 ns | 460.000 ns |
| thread_safe_effect_contention_batch_flush_8 | dependency_edge | 33 | 890.000 ns | 14.010 us |
| thread_safe_effect_contention_batch_flush_8 | set_cell_invalidation | 3 | 90.000 ns | 17.960 us |
| thread_safe_effect_contention_batch_flush_8 | publish | 4 | 170.000 ns | 7.470 us |
| thread_safe_effect_contention_batch_flush_16 | other | 1187 | 13.863 ms | 487.692 us |
| thread_safe_effect_contention_batch_flush_16 | get_refresh | 2 | 60.000 ns | 450.000 ns |
| thread_safe_effect_contention_batch_flush_16 | dependency_edge | 65 | 1.700 us | 35.350 us |
| thread_safe_effect_contention_batch_flush_16 | set_cell_invalidation | 4 | 110.000 ns | 19.450 us |
| thread_safe_effect_contention_batch_flush_16 | publish | 5 | 140.000 ns | 17.490 us |
| thread_safe_graph_propagation_fan_out_eager_validation_8 | other | 351 | 1.611 ms | 101.630 us |
| thread_safe_graph_propagation_fan_out_eager_validation_8 | get_refresh | 64 | 1.730 us | 4.820 us |
| thread_safe_graph_propagation_fan_out_eager_validation_8 | dependency_edge | 64 | 1.680 us | 24.630 us |
| thread_safe_graph_propagation_fan_out_eager_validation_8 | set_cell_invalidation | 128 | 13.423 ms | 3.194 ms |
| thread_safe_graph_propagation_fan_out_eager_validation_8 | publish | 560 | 2.499 ms | 476.084 us |
| thread_safe_graph_propagation_fan_out_eager_validation_16 | other | 479 | 16.908 ms | 99.181 us |
| thread_safe_graph_propagation_fan_out_eager_validation_16 | get_refresh | 64 | 1.830 us | 4.810 us |
| thread_safe_graph_propagation_fan_out_eager_validation_16 | dependency_edge | 64 | 1.720 us | 24.180 us |
| thread_safe_graph_propagation_fan_out_eager_validation_16 | set_cell_invalidation | 256 | 49.780 ms | 6.258 ms |
| thread_safe_graph_propagation_fan_out_eager_validation_16 | publish | 561 | 5.446 ms | 468.495 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_8 | other | 210 | 4.299 ms | 10.570 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_8 | get_refresh | 64 | 1.840 us | 5.700 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_8 | dependency_edge | 32 | 840.000 ns | 14.850 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_8 | set_cell_invalidation | 128 | 12.349 ms | 2.981 ms |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_8 | publish | 64 | 1.810 us | 38.151 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_16 | other | 351 | 16.035 ms | 12.310 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_16 | get_refresh | 64 | 1.740 us | 4.550 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_16 | dependency_edge | 32 | 840.000 ns | 13.250 us |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_16 | set_cell_invalidation | 256 | 57.075 ms | 5.949 ms |
| thread_safe_graph_propagation_fan_out_lazy_dirty_epochs_16 | publish | 64 | 1.670 us | 33.990 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_8 | other | 739 | 2.418 ms | 32.761 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_8 | get_refresh | 68 | 1.950 us | 8.900 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_8 | dependency_edge | 64 | 1.760 us | 23.670 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_8 | set_cell_invalidation | 508 | 4.236 ms | 398.384 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_8 | publish | 66 | 1.750 us | 49.300 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_16 | other | 1379 | 10.357 ms | 53.961 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_16 | get_refresh | 132 | 3.720 us | 12.700 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_16 | dependency_edge | 128 | 3.390 us | 50.900 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_16 | set_cell_invalidation | 1020 | 19.770 ms | 810.545 us |
| thread_safe_graph_propagation_fan_in_lazy_dirty_epochs_16 | publish | 130 | 3.530 us | 89.680 us |
| thread_safe_graph_propagation_fan_in_batched_flush_8 | other | 471 | 2.369 ms | 176.243 us |
| thread_safe_graph_propagation_fan_in_batched_flush_8 | get_refresh | 706 | 26.721 us | 90.430 us |
| thread_safe_graph_propagation_fan_in_batched_flush_8 | dependency_edge | 65 | 1.720 us | 23.620 us |
| thread_safe_graph_propagation_fan_in_batched_flush_8 | set_cell_invalidation | 16 | 510.000 ns | 145.311 us |
| thread_safe_graph_propagation_fan_in_batched_flush_8 | publish | 151 | 207.472 us | 142.780 us |
| thread_safe_graph_propagation_fan_in_batched_flush_16 | other | 788 | 5.938 ms | 335.656 us |
| thread_safe_graph_propagation_fan_in_batched_flush_16 | get_refresh | 132 | 3.690 us | 13.750 us |
| thread_safe_graph_propagation_fan_in_batched_flush_16 | dependency_edge | 129 | 3.390 us | 50.290 us |
| thread_safe_graph_propagation_fan_in_batched_flush_16 | set_cell_invalidation | 4 | 3.090 us | 87.640 us |
| thread_safe_graph_propagation_fan_in_batched_flush_16 | publish | 130 | 12.840 us | 83.241 us |
Scale (≥1M cells) — #lzscalebench
The scale group in the generated section above is a rigorous criterion benchmark
over a spreadsheet-shaped graph of N input cells + N formula slots
(formula[i] = input[i] + input[i-1]). At the default N = 1_000_000 that is
~2,000,000 reactive nodes. It is gated behind the scale-bench feature so a plain
cargo bench skips it; the benchmark generator enables the feature so the group is
tracked by make benchmark-check. Run it directly, or at a larger size:
cargo bench --features scale-bench --bench scale
LAZILY_SCALE_N=2000000 cargo bench --features scale-bench --bench scale
What the four cases show at N = 1_000_000 (reference machine below): build
constructs 2M nodes (~0.12 s), cold_full_recalc computes every formula from cold
(~0.105 s), full_recalc_invalidate_all re-edits every input and recomputes the
whole sheet (~0.080 s), and viewport_recalc edits one input and reads only a
1,000-cell viewport — ~3.7 µs, ~21,000× cheaper than a full recalc because the
lazy pull-based model leaves off-viewport formulas dirty and never recomputes them
(the property a viewport-rendered spreadsheet needs).
(build/cold_full_recalc/full_recalc_invalidate_all are unaffected by the
v0.22.2 #lzslotfastpath refresh fast path — they are cold/slow-path — so their
figures are retained from the original run; only viewport_recalc, which is
~998/1000 cache-hit reads, moved, by the controlled A/B below. The generated
scale rows in the table above reflect the latest single criterion run on this
host and drift with host load for the allocation-heavy build/cold cases; the
curated baseline here is the reference.)
Memory (not captured by criterion): building 2,000,000 nodes uses ~414 MiB RSS, i.e. ~216 B/node, so 1M populated formula cells land in the low hundreds of MiB.
Spreadsheet cell-count context
How the two dominant spreadsheets bound a sheet:
| Spreadsheet | Documented limit | Cells |
|---|---|---|
| Google Sheets | 10,000,000 cells per workbook (also 18,278 columns max) | 10,000,000 |
| Microsoft Excel | 1,048,576 rows × 16,384 columns per worksheet | 17,179,869,184 |
Google Sheets (10M cells) — measured. Modeled as 5,000,000 input cells + 5,000,000
formula cells (= 10M cells) by running the bench at LAZILY_SCALE_N=5000000. Criterion
median on the cross-language reference machine (AMD Ryzen 9 9950X3D), pinned to one core
(taskset -c 4) and run serially so nothing contends for L3 / memory bandwidth:
| case | mean | per cell |
|---|---|---|
build (10M cells) | ~718 ms | ~72 ns |
cold_full_recalc (5M) | ~544 ms | ~109 ns |
full_recalc_invalidate_all (5M) | ~398 ms | ~80 ns |
viewport_recalc (1k) | ~3.8 µs | ~4 ns |
So lazily backs a full-capacity Google Sheets workbook: build under a second, full
recompute ~0.5 s, and — crucially — viewport recalc stays ~3.8 µs independent of sheet
size (it was ~3.7 µs at 1M too), because the lazy pull-based model only recomputes the
cells you read. Reproduce: LAZILY_SCALE_N=5000000 cargo bench --features scale-bench --bench scale.
Across the three implementations lazily-rs holds the cheapest viewport reads (3.7–3.8 µs);
see the cross-language table in lazily-zig’s BENCHMARKS.md for the full head-to-head.
Controlled A/B isolating the v0.22.2 #lzslotfastpath refresh fast path on
viewport_recalc (--save-baseline pre_fix, same session, toggling only
src/context.rs between 8c64f33 and 1390a6e): 13.78 µs → 4.49 µs,
−64.1% (p=0.00) at N = 1_000_000. Only ~2 of the 1,000 viewport cells recompute; the
other ~998 are cache-hit slot reads, each now ~7 ns cheaper because refresh_slot
early-returns on a clean hit instead of cloning the dependency Vec and walking deps.
Microsoft Excel (17.18B grid) — sparse, not dense. Excel’s
1,048,576 × 16,384 = 17,179,869,184 is the grid capacity, not a populated-cell count.
Building all 17.18B cells densely would need ~7 TB at ~216 B/node — infeasible and
unrepresentative: real sheets populate a tiny fraction of the grid, and lazily’s storage
is a sparse arena (Vec<Option<Node>> with a free-list) that only allocates cells you
actually create. The practical limit is therefore populated cells vs. available RAM, not
the 17.18B grid. With the flat per-node cost above (~216 B, ~70–100 ns/cell), capacity ≈
available RAM ÷ ~216 B — e.g. this 186 GB host could hold on the order of ~10⁸–10⁹
populated cells, far beyond any realistically-populated Excel sheet. The scale group’s
linear scaling (1M → 10M held ~constant per-cell cost) is the evidence that the model
extrapolates rather than degrading at spreadsheet capacity.
Cross-library comparison — #lzscalecompare
Head-to-head against leptos_reactive
(Leptos 0.6’s fine-grained reactivity) on the identical spreadsheet graph
(N input signals + N formula memos, formula[i] = input[i] + input[i-1]), in
the same criterion harness on the same host. leptos_reactive is the fair
apples-to-apples pick: like lazily it is a lazy, pull-based memo system (a memo
recomputes only when read while dirty), so this isolates per-node runtime overhead
and the lazy-pull viewport property rather than comparing a pull model against an
eager push one. (JS signal libraries — Solid, MobX, Preact Signals — are a
different runtime and are excluded; the standard js-reactivity-benchmark / cellx
harnesses also measure small/medium graphs, not a 100k-node sheet.)
Measured at N = 100_000 (200,000 nodes/library; leptos is far heavier per node,
so this size keeps its wall clock feasible — lazily’s own 1M/10M numbers are above):
| case | lazily | leptos_reactive | ratio |
|---|---|---|---|
build (200k nodes) | 8.58 ms | 12.89 ms | lazily 1.5× faster |
cold_full_recalc (100k formulas) | 8.45 ms | 30.06 ms | lazily 3.6× faster |
full_recalc_invalidate_all (100k) | 6.26 ms | 17.29 ms | lazily 2.8× faster |
viewport_recalc (edit 1, read 1k) | ~4.5 µs † | 8.22 µs | lazily ~1.8× faster |
† lazily’s viewport_recalc is post-v0.22.2 (#lzslotfastpath). Before that refresh
fast path it measured 11.52 µs and leptos led ~1.4× (the original row this table
shipped with). The v0.22.2 controlled A/B on this case is
13.78 µs → 4.49 µs, −64.1% (p=0.00) (--save-baseline pre_fix, toggling only
src/context.rs). leptos_reactive is an unchanged external library so its 8.22 µs is
retained from the original same-host run; a fresh same-session re-measure under load gave
~10.5 µs, i.e. lazily leads by ~1.8–2.3× depending on leptos’s run-to-run variance.
Honest read: lazily now leads all four cases — building the sheet (1.5×), computing
it cold (3.6×), recomputing the whole sheet after a full invalidation (2.8×), and the
cached-read-dominated viewport read (~1.8×) — driven by its sparse arena + lean
single-threaded Context versus leptos’s runtime slotmap and subscriber bookkeeping, plus
the v0.22.2 refresh_slot clean-cache-hit fast path that removed the per-read
dependency-walk tax on the ~998/1000 viewport cells that are cache hits. The fairness
evidence is no longer “leptos wins a case” (it did, before v0.22.2, and that historical
result is documented in the footnote above) — it is that leptos’s genuine 30 ms cold
recalc proves its memos truly recompute (this is not a straw-man comparison), and that
lazily’s viewport lead is a recent code improvement, not an inherent property: the
pre-v0.22.2 code lost this case. The shared headline is unchanged: the lazy-pull property
both exhibit — a one-input edit + bounded-viewport read is microseconds, ~1000×
cheaper than a full recalc, independent of total sheet size — neither library
recomputes off-viewport formulas. The defensible claim is now “lazily has materially
higher throughput than a comparable native-Rust pull-based reactive system across both
whole-graph and incremental-viewport workloads,” not a blanket “fastest reactive
library.”
Reproduce (gated behind the scale-compare feature so the comparison dependency is
never pulled into normal builds / make check):
cargo bench --features scale-compare --bench scale_compare
LAZILY_SCALE_N=250000 cargo bench --features scale-compare --bench scale_compare
Cross-language comparison (lazily-rs / lazily-cpp / lazily-zig)
Head-to-head on the same spreadsheet-shaped workload (N input cells + N
formula slots, formula[i] = input[i] + input[i-1]), measured on x86_64
Linux. lazily-rs uses criterion; lazily-cpp uses its std::chrono harness;
lazily-zig uses clock_gettime(.MONOTONIC) for the scale bench. Numbers are
the current published results from each repo’s BENCHMARKS.md.
Micro-benchmarks (single-threaded Context unless noted)
| Metric | lazily-rs | lazily-cpp | lazily-zig |
|---|---|---|---|
| cached read (Context) | 5.7 ns | 23 ns | — † |
| cached read (ThreadSafeContext) | 68 ns | 22 ns | — † |
| cold first get (Context) | 129 ns | 97 ns | — † |
| cold first get (ThreadSafeContext) | 1.17 µs | 107 ns | — † |
| fan-out 256 (Context) | 58.4 µs | 1.12 µs | — † |
| fan-out 256 (ThreadSafeContext) | 182 µs | 1.68 µs | — |
| set_cell high_fan_out 512 | 139 µs | 3.26 µs | — † |
| memo equality suppression (Context) | 3.3 µs | 34 ns | — † |
| effect flushing (Context) | 90 ns | 87 ns | — |
| batch storms 64 (Context) | 3.1 µs | 1.55 µs | — |
† lazily-zig 0.17-dev removed std.time.Timer, so its reactive-core
micro-bench is counter-based (deterministic work-counts: allocations,
edges, recomputes — not wall-clock). The counters confirm the same zero-work
steady state (cached reads = 0 allocs / 0 recomputes) but are not directly
comparable on a wall-clock axis. See
lazily-zig BENCHMARKS.md.
Scale — 1M rows (~2M cells)
| Metric | lazily-rs | lazily-cpp | lazily-zig |
|---|---|---|---|
| build (2N nodes) | 105 ms | 123 ms | 132 ms |
| cold full recalc | 106 ms | 36 ms | 381 ms |
| viewport recalc (edit 1, read 1k) | 4.5 µs | 35.1 µs | 6.4 µs |
Scale — 10M cells (full Google Sheets workbook capacity)
| Metric | lazily-rs | lazily-cpp | lazily-zig |
|---|---|---|---|
| build | 706 ms | 1.41 s | 1.13 s |
| cold full recalc | 518 ms | 415 ms | 2.26 s |
| viewport recalc | 4.1 µs | 43.8 µs | 6.6 µs |
Honest read: lazily-rs’s monomorphized Rc<T> fast path leads the
spreadsheet-scale build wall clock (leanest per-node storage) and — after the
v0.22.2 #lzslotfastpath refresh fast path — delivers the cheapest viewport
reads of the three (4.5 µs @ 1M, 4.1 µs @ 10M, undercutting lazily-zig’s
integer-keyed cache at 6.4/6.6 µs). lazily-cpp’s v0.6.0 SmallAny inline value
storage (optimization B) + alloc-free batch bookkeeping (E) flipped the
cold-recalc lead: lazily-cpp cold full recalc is now ~3× faster than lazily-rs
at both 1M (36 vs 106 ms) and 10M (415 vs 518 ms), and its batch_storms now
edges out lazily-rs (1.55 vs 3.1 µs). lazily-cpp’s type-erased SmallFn +
SmallVec node layout still wins the high-fan-out micro-benchmarks (fan-out 256,
set_cell 512, memo equality) by 16–49× over lazily-rs. The shared headline
across all three: they back a full-capacity Google Sheets workbook and all
exhibit the lazy-pull viewport property — a one-cell edit + bounded-viewport
read stays in the microsecond range, independent of sheet size, because
off-viewport formulas are left dirty and never recomputed (~2,000–60,000× cheaper
than a full recalc across the three runtimes).
Phase 3 Wire-Format Optimizations (#lzperfaudit)
Three spec-ratified wire wins (#lzspecfrontiersuppress, #lzspecbase64,
#lzspecintern), measured by benches/wire_optimizations.rs. Run with:
cargo bench --features json-base64 --bench wire_optimizations
#lzspecfrontiersuppress — optional CrdtSync frontier
Omitting the stamp frontier when unchanged cuts wire size and encode/decode cost:
| Variant | Wire size | Encode | Decode |
|---|---|---|---|
| with frontier (8 peers) | 879 B | ~740 ns | ~1.6 µs |
| ops only (suppressed) | 514 B (−42%) | ~463 ns | ~1.0 µs |
#lzspecbase64 — base64 byte arrays vs JSON-u8 arrays
Under the json-base64 capability flag, Inline/Payload bytes travel as base64
strings instead of JSON integer arrays:
| Payload | json-u8 wire | base64 wire | Savings | Decode (u8 → b64) |
|---|---|---|---|---|
| 64 B | 395 B | 228 B | 42% | 911 ns → 710 ns |
| 1 KiB | 4,235 B | 1,508 B | 64% | 36 µs → 25 µs |
| 16 KiB | 65,675 B | 21,988 B | 67% | 89 µs → 65 µs |
#lzspecintern — batch string-intern table
Deduplicating repeated type_tag strings into a sidecar intern table (256 nodes,
4 distinct tags):
| Variant | Wire size | Savings |
|---|---|---|
| inline tags | 15,729 B | — |
| interned | 14,890 B | 5% |
Savings grow with the node-to-tag ratio (more nodes sharing fewer tags).
Revision engine crossover (#lzspecrevisionengine)
The revision (pull) invalidation engine gives O(1) writes (no dependent cone
walk) at the cost of O(changed-subpath) reads. Observable values are provably
identical to push mode (get_equiv_push, lazily-formal RevisionEngine.lean).
Benchmark: 10 writes to a source cell with N dependent slots (construction + priming included in each measurement). Run with:
cargo bench --bench revision_engine
| Fan-out | Push | Revision | Revision win |
|---|---|---|---|
| 1 | 194 ns | 127 ns | 1.5× |
| 16 | 1.19 µs | 822 ns | 1.4× |
| 128 | 10.9 µs | 8.75 µs | 1.25× |
| 1024 | 192 µs | 177 µs | 1.08× |
The write cost scales linearly with fan-out in push (O(N) dirty walk) but is O(1) in revision (revision bump). The construction+priming overhead (same for both) dilutes the pure write-cost gap; workloads with high write:read ratios and large fan-out benefit most.
Multi-Language
lazily is implemented across three languages with shared semantics:
| lazily-rs | lazily-zig | lazily-py | |
|---|---|---|---|
| Context | Owned Context struct | Explicit allocator | Plain dict |
| Slot creation | Box<dyn Fn> closures | comptime function pointers | Lambdas |
| Cell equality | PartialEq trait | std.meta.eql | != operator |
| Thread safety | Single-threaded Context; explicit ThreadSafeContext | Mutex by default | GIL |
| Storage | Unified generics | .direct / .indirect | Object identity |
Related
- lazily-zig — Zig implementation with FFI support
- lazily-py — Python implementation with context-as-dict
- Blog post: Lazily — Reactive Primitives Done Right
License
MIT
lazily v0.11.0
Prepared for the operator publish (#12b1). Last published: crates.io 0.10.3.
Tag v0.11.0 is intended to point at this release commit on main.
Highlights
This minor lands eager Signal primitives across the single-threaded, thread-safe, and async contexts, plus a full WebRTC DataChannel transport stack on top of the v0.10.x reactive core: a sans-IO str0m backend, a real-socket networked backend, a WebSocket fallback, signaling, and the glue that drives a complete handshake end to end.
Features
- #3dmm / #x7sp - add
SignalHandle,ThreadSafeSignalHandle, andAsyncSignalHandleas eager derived values backed by memo slots plus puller effects. Signals provide always-materializedv1 -> v2updates with no observable unset window, inherit memo equality suppression, and are documented in the README, SPEC, PROTOCOL, and mdBook docs. - #lzwebrtcwire — wire
SignalingClienttoStr0mNet. Newwebrtc_signalingmodule (offer_to_peer/answer_next_offer) owns the full SDP offer/answer + trickled-ICE handshake overSignalingClient, pumping frames intoaccept_answer/add_remote_candidateuntil the data channel opens. Integration test brokers two realSignalingClientWebSocket peers through an in-process #yxjw-protocol loopback relay and proves a permission-filteredSnapshotcrosses the negotiated channel. - #lzwebrtcnet — networked str0m
DataChannelbackend (Str0mNet) over a real UDP socket with the str0m DTLS/SCTP/ICE driver. - #97xn — multi-channel reactive bridge hub.
- #akp3 — WebSocket
DataChannelbackend (in-process loopback over a real WS handshake). - #webrtcbackend — concrete sans-IO str0m
DataChannelbackend. - #webrtc2 / #webrtc3 — WebRTC
DataChannelIPC transport abstraction, loopback integration tests, and Criterion benchmarks.
CI / tests
- #lzleanmodel - CI now builds the sibling Lean formal model so protocol invariants stay checked alongside the Rust suite.
- #lzspecconf — IPC conformance run against the canonical lazily-spec fixtures.
- #k03k / #lzasync — deterministic async resolve-loop window coverage.
Remaining (operator-gated)
- Live two-host / NAT validation of
Str0mNetthrough the deployed #yxjw Cloudflare Worker (#lzwebrtcnet-e2e, part of #h6qb) — cannot be done in CI.
Publish checklist (#12b1)
cargo publish(dry-run verified clean: 72 files, 233 KiB compressed).gh release create v0.11.0 --notes-file RELEASE_NOTES_v0.11.0.md --title "lazily v0.11.0".- Rotate the crates.io token if expired before step 1.