Lazily 0.6.0
dotnet add package Lazily --version 0.6.0
NuGet\Install-Package Lazily -Version 0.6.0
<PackageReference Include="Lazily" Version="0.6.0" />
<PackageVersion Include="Lazily" Version="0.6.0" />
<PackageReference Include="Lazily" />
paket add Lazily --version 0.6.0
#r "nuget: Lazily, 0.6.0"
#:package Lazily@0.6.0
#addin nuget:?package=Lazily&version=0.6.0
#tool nuget:?package=Lazily&version=0.6.0
lazily-cs
Lazy reactive primitives for C#/.NET — Source, Computed, and Effect with automatic
dependency tracking, plus the lazily-spec wire
protocol, CRDTs, and distributed plane.
This is the ninth binding in the lazily family. The reactive kernel is a direct port of the reference semantics, and it replays the shared cross-language conformance corpus rather than asserting behaviour it invented locally.
The Lazily.InteropPeer console project is the production-backed adapter for the
cross-binding network suite. It advertises distributed_crdt with the JSON
codec, routes semantic operations through CrdtPlaneRuntime and IpcWire, and
leaves transport links unadvertised until executable channel adapters exist.
Status: active and spec-conformant. The binding replays all 125 canonical fixtures. Feature-specific peers remain staged until that execution flavor exists; the generated matrix below is the honest, single-source record of what can join each peer group.
Install
dotnet add package Lazily
Targets netstandard2.1, net8.0, and net10.0.
The optional net10-only R3 bridge is packaged separately:
dotnet add package Lazily.R3
See R3 adapter semantics for ownership, threading, errors, completion, batching, and the state-not-events boundary.
Quick start
using Lazily;
var ctx = new Context();
var celsius = ctx.Source(21.0);
var fahrenheit = ctx.Computed<double>(c => celsius.Get(c) * 9 / 5 + 32);
Console.WriteLine(fahrenheit.Get()); // 69.8 — computed on first read, then cached
using var log = ctx.Effect(c =>
{
Console.WriteLine($"now {fahrenheit.Get(c):F1}F");
return null; // an optional cleanup callback
});
celsius.Set(25.0); // the effect reruns; fahrenheit recomputes on demand
Latest-durable projection egress
LatestDurableProjectionCore<TKey, TValue> retains only the latest unclaimed
value per key while fencing every claimed sink attempt by connection generation
and epoch. UpsertDesired, Claim, AckApplied, FailRetryable, and
Reconnect implement the lazily-spec state machine: failures requeue work,
newer pending values supersede older ones, the durable frontier never moves
backward, and stale receipts cannot clear newer desire.
LatestDurableProjection, ThreadSafeLatestDurableProjection, and
AsyncLatestDurableProjection add reactive entry and generation views for the
three native context families. Sink side effects stay outside the graph; run the
state-machine mutations and the actual write on the same per-key serialization
lane.
The model
Two cell kinds, one read surface, one write surface.
Source<T>is a value written from outside the graph. It is the only kind carryingSet/Merge, so write protection lives in the type rather than in a runtime gate.Computed<T>derives a value from upstream. It is lazy and cached, and guarded by default: a recompute yielding an equal value suppresses the whole downstream cascade.Effectis the only sink. There are no observers, no subscriptions, and no change callbacks on a handle — if you want to react to something, you write an effect.
Tracking is value-threaded, never ambient. A compute body receives a Compute view carrying
the recomputing node's identity as a value:
var total = ctx.Computed<int>(c => a.Get(c) + b.Get(c)); // tracked: forms edges
var once = ctx.Computed<int>(c => a.Get(c.Untracked())); // untracked: forms none
There is no ambient recompute stack to read from, so Untracked() is genuinely untracked and a
read outside a compute cannot accidentally attribute an edge to whatever ran last. C# cannot bind
the view to its recompute the way a lifetime does, so escape is caught at runtime: using a stored
view after its compute returned throws StaleComputeException instead of silently registering an
edge against a node that is no longer recomputing.
Invalidation is a non-consuming mark-frontier walk. A write marks its transitive dependent cone stale and leaves every edge in place; nothing recomputes until something is read. Because the edges survive, a node can be marked clean again without recomputing and still be reachable from its source — which is what keeps the next genuine change from being lost at depth two.
Batching coalesces the cascade, never the algebra.
ctx.Batch(() =>
{
acc.Merge(1);
acc.Merge(2);
acc.Merge(3);
});
// three folds happened synchronously; the watcher ran once
Eager is a state, not a kind. computed.Eager() attaches a puller effect that materializes
the value immediately and again after every invalidation. Because the puller is an ordinary
effect, N invalidations inside a batch coalesce into one pull at the flush. Lazy() reverses it.
Disposal is explicit, and it dirties what survives. Dropping the last C# reference to a node
reclaims nothing reactive: the graph holds strong edges, so a long-lived source retains every node
that ever read it. Dispose() detaches both edge directions and marks the surviving cone stale.
A TeardownScope groups nodes and tears them down in reverse creation order:
var scope = ctx.Scope();
var view = scope.Own(ctx.Computed<int>(c => source.Get(c) * 2));
scope.Close(); // reverse creation order; effect cleanups run
A divergent feedback loop reports exhaustion instead of hanging. An effect that writes into
its own dependency cone closes a loop through the scheduler, not the graph — it is not a
dependency cycle, and it runs flat at constant stack depth, so neither acyclicity nor recursion
bounds can catch it. Context.DrainBudget is the only exit, and LastDrainExhaustion identifies
the effect that concentrated the runs rather than merely announcing that a counter was hit.
Concurrency layers
.NET has real threads, so both concurrency layers are required of this binding rather than
declared none.
ThreadSafeContext — lock-backed. It wraps a single-threaded Context with a reentrant lock
and reuses the core batch coalescing, so it refines the kernel rather than reimplementing it: a
one-write critical section is observationally a plain Set, and a batch of concurrent writes
coalesces into one invalidation pass whose result is a function of the serialized write list, not
of the interleaving the lock happened to pick.
var ts = new ThreadSafeContext();
Source<int> total = null!;
ts.WithLock(ctx => total = ctx.Source(0));
ts.Batch(() => // three writes, one coalesced cascade
{
ts.Set(total, 1);
ts.Set(total, 2);
ts.Set(total, 3);
});
var now = ts.WithLock(_ => total.Peek());
ThreadSafeKernel.ApplyBatch / FlushBatch are the pure counterpart of the Lean
LazilyFormal.ThreadSafe model — the coalescing law over a plain node table, checkable without a
live graph.
AsyncContext — a distinct graph. It is not an overload of the other two. An async slot can
be in flight when its inputs change, can complete after those inputs are gone, and can be
cancelled mid-flight, so it carries an explicit state machine (Empty / Computing / Resolved
/ Error), a revision per slot, and its own handles. Sources stay the synchronous input layer:
Source, Peek, and Set are synchronous; only computed evaluation and effects are async.
await using var ctx = new AsyncContext();
var userId = ctx.Source(1);
var profile = ctx.Computed(async cc => await FetchAsync(cc.Track(userId), cc.Token));
Console.WriteLine(await profile.GetAsync());
userId.Set(2); // supersedes the in-flight compute
Console.WriteLine(await profile.GetAsync());
The contract it honours in full:
- Revision tracking discards every stale completion. A run publishes only while the slot still holds the token it started with, so a value the graph has already moved past is never served.
- Dropping one waiter cancels only that waiter. The shared computation keeps running for the readers that remain, and there is at most one in flight per revision — concurrent readers attach rather than spawning duplicates.
GetAsyncre-resolves rather than asserting. The slot can change between lock acquisitions and a superseded run closes its waiters without a value; both windows are benign and neither throws.- Effect cleanup runs on rerun or dispose and at no other time — never at the end of the flush that ran the body. The canonical effect acquires in the body and releases in the cleanup, so a flush-end cleanup would release while the effect is still live. Reruns are serialized: the next body does not start until the previous cleanup completes.
- Disposal awaits. Disposing the context cancels every in-flight computation and awaits every active cleanup before returning.
Batchis synchronous at the mutation boundary. Writes queue their roots; async reruns fire after the outermost batch exits, never inside it.
Merge algebra
A Source<T> folds writes under a MergePolicy<T>; the default is keep-latest, so a plain source
is a plain cell (Cell ≡ Source<KeepLatest>). Associativity is a law, verified by the law tests.
The flags are declarations about which overflow behaviour is sound downstream: commutativity is
the reordering tax, idempotency the durability tax, and only raw FIFO cannot conflate.
| Policy | Fold | Commutative | Idempotent | Conflates |
|---|---|---|---|---|
KeepLatest |
op |
— | ✅ | ✅ |
Sum |
a + b |
✅ | — | ✅ |
Max |
max(a, b) |
✅ | ✅ | ✅ |
SetUnion |
a ∪ b |
✅ | ✅ | ✅ |
RawFifo |
a ++ b |
— | — | — |
The write guard runs on the merged result, so an idempotent policy's no-op merge fires no cascade.
Distributed and native planes
StateProjection atomically folds canonical SnapshotMessage and DeltaMessage frames into a
receiver-side graph mirror; gaps and invalid batches fail closed without partial state. Its
producer-side counterpart, StateProjectionMirror, emits sorted, coalesced deltas. Queue deltas
remain a separate collection projection and are rejected by the graph mirror instead of being
silently accepted.
The command-plane-v1 surface is the typed CommandMessage family plus CommandWire,
CommandProjection, and CommandRpcClient. Terminal causal receipts are authoritative, stale
generations are ignored, conflicting terminals fail closed, and NegotiatedSession prevents RPC
use unless both SessionHandshake peers advertised the feature. PeerPermissions independently
gates remote reads, writes, effects, subscriptions, snapshots, deltas, and CRDT operations with a
default-deny policy.
For native consumers, include/lazily_ffi.h exposes the normative C ABI over NativeAOT. The smoke
gate publishes the shared library, verifies all exported symbols, links a C11 consumer, and runs
it against the real IPC codec:
./scripts/check-ffi.sh
LazilyMetrics.Snapshot() reports production-path counters without coupling callers to a metrics
backend, while LazilyBenchmark.RunSuite() provides deterministic in-process benchmark probes.
Divergences from the reference bindings
- No
comparablebound. Rust and Go bound a source's value so the write guard can use==. C# has no such bound, so the guard usesEqualityComparer<T>.Defaultand every constructor accepts an explicitIEqualityComparer<T>. That is strictly more general, and it means a reference type without a value-equality override is guarded by reference identity unless you pass a comparer. - Constructors are extension methods.
ctx.Source(…),ctx.Computed(…),ctx.Slot(…), andctx.Effect(…)live onReactiveso the factory names can match the family vocabulary without colliding with the type names they return. AsyncContextserializes on a lock, not an owner loop. lazily-go funnels every graph mutation through one goroutine and a command channel;Monitoris reentrant, so the same invariant is expressed directly as a lock. Bodies run off it on the thread pool.- An async slot in
Errorretries on the next read. lazily-go serves the stored error forever;docs/async.mdlistsError → Computingon retry, and the spec is the authority.
Conformance
The cross-language corpus lives in lazily-spec and is never vendored here — a bundled copy drifts from the spec. Clone it beside this repo:
git clone https://github.com/lazily-hub/lazily-spec.git ../lazily-spec
make check
The runner fails hard when the corpus is absent rather than skipping, asserts a positive fixture and assertion count, and keeps an explicit ledger of unsupported fixtures and known divergences — both are asserted to match exactly, so a new divergence fails the build and a fixed one fails it until its entry is deleted. Today lazily-cs opens and replays all 124 canonical fixture files with an empty fixture ledger. Support remains feature-specific: for example, the synchronous queue, topic, and work-queue peers participate, while their not-yet-implemented thread-safe and async flavors remain staged in the matrix.
The runner is parameterised over the execution model and replays the same op stream against
Context, ThreadSafeContext, and AsyncContext. That is not thoroughness for its own sake: a
cascade that stops one level below the write is correct synchronously and broken asynchronously,
because an async read short-circuits on a resolved cache and serves the stale value forever. A
single-context replay cannot see it. Constructs one plane does not ship (the eager signal, the
merge_cell fold, the bounded drain — all synchronous-kernel constructs) are gated per model with
a stated reason, never degraded to the nearest available substitute.
Feature coverage
Generated from coverage.json in lazily-spec — do not edit by hand.
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 | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | — |
| Durable owner | ✅ | — | — | — | — | — | — | — | — | — |
| Durable capability tiers | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ~ |
| 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 82 feature rows. For row-level marks, per-cell notes, and platform carve-outs see the canonical coverage matrix in lazily-spec.
Development
make check # build + test — run before committing
make conformance # replay the shared lazily-spec fixtures only
make format-check # dotnet format --verify-no-changes
The lazily family
lazily is one reactive kernel — Source / Computed / Effect, keyed
collections, state charts, CRDTs, and a distributed plane — implemented natively
in each language and held to a single cross-language contract:
lazily-spec— the wire protocol, the generated feature matrix, and the conformance corpus every binding replays.lazily-formal— the Lean 4 formal model the bindings share.
| repo | language |
|---|---|
lazily-rs |
Rust — the reference implementation |
lazily-py |
Python |
lazily-go |
Go |
lazily-kt |
Kotlin / JVM |
lazily-js |
JavaScript / TypeScript |
lazily-cs |
C# / .NET — you are here |
lazily-cpp |
C++ |
lazily-zig |
Zig |
lazily-dart |
Dart / Flutter |
lazily-react |
React / Preact bindings layered over lazily-js — not a separate language binding |
The per-binding parity matrix above is generated from coverage.json in
lazily-spec, which stays the
single source for cross-binding feature coverage.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- System.Text.Json (>= 10.0.0)
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Lazily:
| Package | Downloads |
|---|---|
|
Lazily.R3
Optional R3 adapters for Lazily state graphs. |
GitHub repositories
This package is not used by any popular GitHub repositories.