ITADN
lazily-hub/lazily-go
lazily-hub/lazily-go · 文件
文件最后提交记录最后更新时间
README.md

lazily (Go)

Lazy reactive primitives for Go — the Cell kernel (Source / Computed / Effect, all cells guarded; eager via Computed.Eager()) with automatic dependency tracking and cache invalidation, plus the full lazily-spec wire protocol, CRDT collection types, keyed cell collections, Harel state charts, and the distributed CRDT plane.

A Go port of the lazily reactive family (lazily-rs, lazily-py, lazily-kt, lazily-js, lazily-dart, lazily-zig) — conformant with lazily-spec and lazily-formal. The concurrency surfaces (async reactive context, signaling room, CRDT anti-entropy plane) are built on goroutines and channels: share state by communicating, not by locking.

go get github.com/lazily-hub/lazily-go

The reactive family — the Cell kernel

Two value kinds, named without collision (design #lzcellkernel). Cell is a conceptual word for a value-bearing reactive node, not a Go type — the two kinds are two concrete handle structs, and write protection lives in the type:

  • Source[T] — a value written from outside the graph; the only kind with Set/Merge. It folds writes under a MergePolicy (KeepLatest by default = a plain cell; Sum/Max = the former MergeCell). Cell ≡ Source<KeepLatest>. Constructors: NewSource / NewSourceWithPolicy.
  • Computed[T] — a value computed from upstream; lazily cached and dependency-tracking, with neither Set nor Merge. NewComputed(f) is guarded (an ==-guard suppresses equal recomputes) — the sole derived constructor now that the former Memo is removed: a Computed is the guarded form. computed.Eager() makes it eager (which replaces the former Signal), returning the same handle; computed.Lazy() reverses it.
  • Effect — a side-effect sink (ctx.effect), outside the Cell hierarchy.

The T comparable bound is what the guard needs. For a value type that is not comparable, drop to NewSlot(f) — the bound-free storage-sense primitive (T any, no guard), the escape hatch mirroring lazily-rs's slot().

The v1 Cell[T] read-genus interface is dropped: no Go generic code used it as a bound, and v2 no longer needs a genus for write protection.

Write protection lives in the types: because Computed has no Set, computed.Set(…) does not compile. Values are lazy by default; call Eager on a Computed for eager push-style semantics. Use Effect for side effects (a sink, outside the Cell hierarchy).

Usage

import lazily "github.com/lazily-hub/lazily-go"

ctx := lazily.NewContext()
a := lazily.NewSource(ctx, 2)
b := lazily.NewSource(ctx, 3)

// Lazy: computes on first read, caches, recomputes only when a or b changes.
sum := lazily.NewComputed(ctx, func(c *lazily.Context) int { return a.Get() + b.Get() })
sum.Get() // 5

a.Set(10)
sum.Get() // 13

// Eager: Eager() attaches a puller so the computed re-materializes on every change.
parity := lazily.NewComputed(ctx, func(c *lazily.Context) string {
	if a.Get()%2 == 0 {
		return "even"
	}
	return "odd"
}).Eager()
parity.Get() // "even"
a.Set(11)
parity.Get() // "odd" (already updated before the read)

To react to a Cell from outside the graph (the hook for UI bridges), declare a dependency edge with an Effect — a Cell has no callback registry:

count := lazily.NewSource(ctx, 0)
effect := lazily.NewEffect(ctx, func(*lazily.Context) func() {
	fmt.Println("now", count.Get()) // the Get is what subscribes
	return nil
})
count.Set(1) // prints "now 1"
effect.Dispose()

An Effect runs once at creation and then once per settled cascade, so under Batch it observes the settled value rather than each intermediate write. When you need every individual transition delivered as a stream, use TopicCell.

Batch coalesces cascades so dependent Effects flush once:

ctx.Batch(func() {
	a.Set(1)
	b.Set(2)
}) // a single coalesced cascade

Competing-consumer work queue

WorkQueueCell[T] provides exclusive FIFO claims, visibility deadlines, worker-scoped acknowledgements, tail retries, and bounded dead-letter handling. Item ids remain stable across retries; every claim gets a fresh delivery id. The same contract is available as ThreadSafeWorkQueueCell for competing goroutines and AsyncWorkQueueCell for composition on an AsyncContext; queue and topic equivalents follow the same ThreadSafe* / Async* naming.

work := lazily.NewWorkQueueCell[string](ctx, 10, 3)
work.Push("job")
delivery, _ := work.Claim("worker-a", 100)
if !work.Ack("worker-a", delivery.DeliveryID) {
    panic("ack rejected")
}

Context

All reactives that react to each other must share a Context. It holds an identity-keyed cache and the computation stack used for automatic dependency tracking. Context is single-goroutine; for concurrent access use the lock-backed ThreadSafeContext or drive the graph from one owner goroutine via the channel-serialized AsyncContext.

Reactive members on a struct

Go has no decorators, so there is no direct analog of lazily-py's @slot / @cell on a method. The idiomatic Go equivalent of a lazily-decorated method is to wire the reactive members as Source / Computed fields in the constructor and expose thin accessor methods. The accessor reads like a plain method but is lazy, cached, and dependency-tracked:

type Greeter struct {
	Name     *lazily.Source[string]
	greeting *lazily.Computed[string] // the "decorated" lazy member
}

func NewGreeter(ctx *lazily.Context) *Greeter {
	g := &Greeter{Name: lazily.NewSource(ctx, "")}
	// greeting tracks Name automatically; it recomputes only after Name changes.
	g.greeting = lazily.NewComputed(ctx, func(*lazily.Context) string {
		return "Hello, " + g.Name.Get() + "!"
	})
	return g
}

// Greeting reads like a normal method but is lazy + cached + reactive.
func (g *Greeter) Greeting() string { return g.greeting.Get() }
ctx := lazily.NewContext()
g := NewGreeter(ctx)
g.Name.Set("World")
g.Greeting() // "Hello, World!" (computed on first read, then cached)
g.Name.Set("Go")
g.Greeting() // "Hello, Go!"  (recomputed once, because Name changed)

NewComputed is guarded by default (it suppresses the downstream cascade when the recomputed value is unchanged); chain .Eager() for eager recomputation. A runnable version of this pattern lives in example_test.go.

State machine

StateMachine is a finite state machine backed by a Source, so any Computed reading its state is invalidated on transition.

State chart

StateChart is a full Harel/SCXML hierarchical state machine — the native counterpart of lazily-formal's LazilyFormal.StateChart. It is compute, not protocol (never serialized as a distinct wire kind). Built from the declarative JSON form via ChartDefFromJSON. Implements compound states, orthogonal (parallel) regions, shallow and deep history, entry/exit/transition actions, and named fail-closed guards.

Collections & CRDTs

Keyed cell collections (SourceMap, SourceTree) with LIS move-minimized reconciliation, the memoized semantic tree (SemTree), stable-id alignment, the reactive queue (QueueCell — a FIFO collection whose shell invalidates by reader kind: a push invalidates Len/IsEmpty (and Head when transitioning from empty), a pop invalidates Head/Len/IsEmpty, and a bounded queue's IsFull is the reactive backpressure signal; SPSC primitive with MPSC via Batch, over a pluggable QueueStorage backend with the default VecDequeStorage), and the CRDT family: free-text character CRDT (TextCrdt, with delta sync), move-aware sequence CRDT (SeqCrdt), the lossless tree CRDT (LosslessTreeCrdt — a single rooted concrete-syntax tree whose leaves own every rendered byte, with op-based delta sync over a dotted non-contiguous version frontier), registers (MvRegister, PnCounter, CellCrdt), and the distributed CRDT plane (CrdtPlane, CrdtPlaneRuntime) with anti-entropy and WebRTC transport + signaling.

Keyed reactive maps

ReactiveMap[K, V, H] is the unified keyed reactive collection (#reactivemap): keys map to independently-tracked per-entry reactive nodes over a handle kind H (*Source[V] input cells or *Computed[V] derived computeds), with reactive membership and order. Go generics can't add methods to a type alias, so its two specializations are thin distinct structs embedding *ReactiveMap with the handle kind fixed:

  • SourceMap[K, V] — input-cell entries. Adds the source-only Set and eager value-minting (Entry / EntryWith). Every entry is a writable *Source[V].
  • ComputedMap[K, V] — derived-slot entries. GetOrInsertWith mints a slot on first access (lazy materialization); MaterializeAll pre-mints the keyset (eager). A slot's value is derived, so ComputedMap has no Set. There is no eager/lazy mode flag — eager is a pre-mint loop, lazy is mint-on-access.

These were named CellMap / SlotMap before the v2 kernel renamed the node kinds to Source and Computed. The old spellings remain as deprecated generic type aliases (CellMap = SourceMap, SlotMap = ComputedMap, plus the Async* / ThreadSafe* flavors, and CellTree = SourceTree for the ordered keyed tree) with deprecated constructor wrappers, so existing callers keep compiling. Generic type aliases require Go 1.24+.

The shared surface — GetOrInsertWith / Remove / Move* / Keys / Len / ContainsKey / membership + order signals — lives on the generic ReactiveMap.

ctx := lazily.NewContext()
// Lazy derived-slot map over a large keyed space; only read keys are allocated.
sheet := lazily.NewComputedMap[Key, int](ctx)
sheet.GetOrInsertWith(k, func(k Key) int { return recompute(k) }) // mint on first pull
sheet.PresentCount()                                              // grows only with reads

Eager (MaterializeAll pre-mint) and lazy (GetOrInsertWith mint-on-access) return identical values for every key (observational transparency); the strategy changes allocation timing and memory, never results. The laws — observe_canonical, eager_lazy_observationally_equivalent, materialize_present_monotone / lazy_present_subset_eager, and entry-kind orthogonality (cell_entries_materialized_in_every_mode / slot_entries_deferred_under_lazy) — are proven in lazily-formal's Materialization module and pinned by the conformance/materialization/*.json fixtures. The Send + Sync (ThreadSafeSourceMap / ThreadSafeComputedMap) and async (AsyncSourceMap / AsyncComputedMap) flavors mirror the same surface.

lazily-spec IPC

The IPC types (Snapshot, Delta, CrdtSync, NodeState, ...) implement the language-agnostic lazily-spec wire protocol so a Go graph's state can be mirrored to remote observers across processes and languages. They round-trip the canonical fixtures from lazily-spec/conformance/. The C-ABI FFI boundary (cgo) exposes the state plane to in-process native embedders.

The additive command / RPC message plane (command-plane-v1) — CommandSubmit / CommandCancel / CommandEvents / CommandProjection plus the CommandRpcClient facade — rides the same wire envelope. Terminal command authority folds through a CausalReceipt, so a unary call resolves only on a terminal receipt (never on a transport ACK or accepted/queued event).

Cross-process zero-copy transport

Large cell/slot payloads cross the IPC plane as descriptors, not copies (#lzzcpy). The producer spills an oversized payload to a pluggable BlobBackend 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. Three backends ship:

  • InProcessBackend wraps a ShmBlobArena — the single-address-space case (the cgo FFI host / an in-process embedder).
  • ArrowBackend holds Apache Arrow IPC stream bytes — the descriptor's bytes are an Arrow IPC stream a columnar consumer imports zero-copy.
  • ShmBackend (Linux) is a genuine POSIX shm_open + mmap region with an atomic bump allocator — the cross-process backend: a descriptor minted by one mapping resolves zero-copy against an independent mapping of the same region.

SpillMessage replaces oversized Inline/Payload sites across a Snapshot/Delta/CrdtSync with descriptors above a deployment threshold; a receiver-side BlobRouter resolves any descriptor by its backend discriminator (a shm descriptor never resolves in an Arrow backend, and vice versa). The backend field is optional and defaults to shm, so legacy descriptors validate unchanged — the transport is a strict superset of the shared-memory blob path. The backend-agnostic invariants (spill-then-resolve identity, backend isolation, ABA generation safety, checksum integrity) are proven in lazily-formal's ZeroCopyTransport.lean and pinned by the delta_zero_copy_arrow conformance fixture.

Conformance

lazily-go replays the shared lazily-spec conformance fixtures (IPC, keyed collections, Harel state charts, the lossless-tree CRDT, and the command-plane message family) — asserting identical behavior to every other binding. Run make check (fmt + vet + build + test) locally; CI also runs the race detector.

Benchmarks

See BENCHMARKS.md for micro-benchmark results on the hot paths — reactive core read/write, slot/memo recompute, batch coalescing, keyed collections, and CRDT construction — with ns/op / B/op / allocs/op and what each case measures. The reactive steady state (Cell read/write, SourceMap insert/read) is zero-allocation. Benchmarks are defined as Go testing.B cases in bench_test.go (mirroring the in-library RunBenchmarkSuite) and reproducible with make bench (go test -bench=. -benchmem ./...).

BENCHMARKS.md also includes a spreadsheet-scale benchmark (make bench-scale) on a graph of N input cells + N formula slots (=A_i + A_{i-1}): ~2M nodes at the default N=1M, up to a full 10M-cell Google Sheets workbook at LAZILY_SCALE_N=5000000. It builds the full workbook in under a second and — via the lazy pull-based model — a one-cell edit + bounded-viewport read recomputes only the viewport (~2 formulas), staying orders of magnitude cheaper than a full recalc regardless of sheet size.

Feature coverage

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

FamilyRustPythonKotlinJSDartZigGoC++C#
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 70 feature rows. For row-level marks, per-cell notes, and platform carve-outs see the canonical coverage matrix in lazily-spec.

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-go included.
  • lazily-formal — the Lean 4 formal model every binding inherits its proofs from.
RepoLanguage
lazily-rsRust — the reference implementation
lazily-pyPython
lazily-goGo — you are here
lazily-ktKotlin / JVM
lazily-jsJavaScript / TypeScript
lazily-csC# / .NET
lazily-cppC++
lazily-zigZig
lazily-dartDart / Flutter
lazily-reactReact / Preact bindings layered over lazily-js (not a separate language binding)