llm-chord
llm-chord
A Crystal port of @earendil-works/chord.
The port currently covers chord's foundation layer:
LLM::Chord::Delta—@earendil-works/chord/delta: transactional JSON revision tracking, structural diff, immutable apply, and the wire codec.LLM::Chord.replicated_state— chord's observable replicated state, either authoritative and mutable or attached to an external revision source.LLM::Chord::Context—@earendil-works/chord/context: explicit invocation-scoped values, cancellation, and telemetry spans. Telemetry comes from thellm-telemetryshard (LLM::Telemetry).LLM::Chord::Output—@earendil-works/chord/output: bounded output streams and paced progress commits.LLM::Chord::ChordError— chord's error codes and helpers.LLM::Chord.remote/.local— contracts with placement, variants, versions, fingerprints, coverage, and binding;LLM::Chord::Schema— canonical JSON,hash64, input normalization and identity, and thefits?compatibility rule. Schemas are built with thetypeboxshard, which emits the same JSON Schema as TypeBox.LLM::Chord::Host— chord's single-process runtime: objects with cells, derivations, resources, listeners and tasks; references with variants, late binding andacceptsadapters; roots and call contexts; lifetimes and linger; principals; facets; registries.- Links — chord's wire protocol (version 4) between hosts: memory, IPC (Node's IPC channel format) and WebSocket links; remote references with shared bindings, units, flow control and consumer-side linger; relays and routes; principal stamping; packed payloads.
LLM::Chord::HostManager— child host processes: the fork driver (process groups, an IPC channel onNODE_CHANNEL_FD), the manager, the child's control channel, and the kernelprocess_lock.- Reload — chord's hot reload, in process: code versions with the two-version rule and retirement, state versions and
migrate, prepare in recording mode, the gate with leases and grandfathered roots, exclusive resources, the synchronous commit, and cluster reloads across hosts (prepare, vote, commit, abort, retire; fencing and reconciliation of children that connect behind). LLM::Chord::Sqlite—@earendil-works/chord/sqliteand/sqlite/node: a portable SQLite database interface, schema-prefix validation, atomic migrations, and an adapter oncrystal-sqlite3.LLM::Chord::SqliteTelemetry—@earendil-works/chord/telemetry-sqlite: a buffered SQLite telemetry recorder andTelemetryStore.
Values are Crystal's JSON::Any. Chord's Cloudflare Durable Object adapter (/sqlite/cloudflare) has no Crystal target and is not ported.
Requirements
- Crystal
>= 1.21.0
Installation
dependencies:
llm-chord:
github: shpeckman/llm-chord
llm-chord depends on the typebox shard (github: shpeckman/typebox), the llm-telemetry shard (github: shpeckman/llm-telemetry) and crystal-sqlite3 (github: crystal-lang/crystal-sqlite3, ~> 0.24.0, with crystal-db 0.15) in its shard.yml.
Then shards install, and in your code:
require "llm-chord"
The SQLite modules have their own entry points, so require "llm-chord" alone never links libsqlite3:
require "llm-chord/sqlite" # LLM::Chord::Sqlite
require "llm-chord/telemetry-sqlite" # LLM::Chord::SqliteTelemetry (also requires llm-chord/sqlite)
The SQLite modules need the SQLite 3 library and headers (3.37 or newer, for STRICT tables and the JSON functions).
Tracking JSON deltas
tracker = LLM::Chord::Delta.track(JSON.parse(%({"output":"","entries":[]})))
change = tracker.begin_change
change.state["output"] = change.state["output"].as_s + "done\n"
change.state["entries"] << {"id" => 1}
prepared = change.prepare
tracker.adopt(prepared)
tracker.value # => {"output" => "done\n", "entries" => [{"id" => 1}]}
tracker.revision # => 1
replica = LLM::Chord::Delta.apply_immutable(prepared.base, prepared.ops)
tracker.value is always the latest adopted immutable revision. begin_change opens a copy-on-write draft over it; prepare materializes the candidate and its operation batch without changing authority; adopt validates the preparation and swaps the root. Unchanged subtrees are shared between revisions.
Ownership
Immutability is an ownership contract. Nothing is frozen, so an illegal mutation is not detected; it silently corrupts state.
| Value | May it be mutated? |
|---|---|
Root passed to track, prepare_replace, replicated_state, or replace |
No. Ownership moved to the tracker. |
change.state and handles read from it |
Only while the change is open. After prepare, abort, or another adoption, every use raises OverlayError. Writes through a handle whose element was removed from the draft are ignored. |
| A value after placing it into a draft | Yes. The draft stored a validated clone. |
tracker.value, prepared.base, retained older revisions |
No. |
prepared.value, prepared.ops and their payloads |
No. Payloads may be shared with prepared.value. |
apply_immutable / apply_immutable_batches inputs and result |
No. The result shares containers with both inputs. |
Values from replicated state (value, listener values) |
No. Subscribers share the provider's containers. |
Mutable replica passed to apply |
Only through apply, with batches it owns exclusively. |
Batch passed to apply |
Consumed. apply adopts payload containers into the replica. |
Lifecycle
- Several changes may be open or prepared from one revision. Adopting one makes all others stale: their drafts raise and
adoptrejects them. adoptrejects foreign, aborted, stale, and already used preparations.Change#abortorPrepared#abortafterprepareprevents adoption; the candidate stays readable.- An empty
opsmeansprepared.valueisprepared.base. Adopting a no-op still advancesrevisionand stales competitors. prepare_replace(value)is a whole-root operation, not a diff: its ops are[Replace(value)], or empty whenvalueis deeply equal to the current root.- Roots must be JSON objects or arrays.
Drafts
Change#state returns a Draft, a revocable handle over the draft:
- Reads:
[],[]?(keys and indices),as_s,as_i,as_f,as_bool,null?,as_a(Array(Draft)),as_h(Hash(String, Draft)),string?,int,float,bool,array,object,size,empty?, andvalue(a detached deep copy). - Writes:
[]=,delete,<<,push,pop,shift,unshift,splice,delete_at,size=,reverse!,sort!(with or without a block),fill,copy_within. Array indices follow JavaScript semantics where chord does (negative relative indices, clamped ranges, default sort by string form). - Placed values are validated as strict JSON and cloned before the draft changes; placing a
Draftclones its current content. Arrays stay dense: writing past the next index raises,size=grows withnull. - Held handles follow their element through reindexing; two handles to the same element see each other's writes.
Operations
| Verb | Struct | Meaning |
|---|---|---|
r |
Op::Replace |
Replace the whole value. |
s |
Op::Set |
Set an object property or array element. |
d |
Op::Delete |
Delete an object property or array element. |
a |
Op::Append |
Append text to a string. |
t |
Op::Truncate |
Remove count UTF-16 code units from a string's front. |
p |
Op::Splice |
Splice an array. |
m |
Op::Move |
Reorder an array: new[i] = old[permutation[i]]. |
Op::Set, Delete, Append, Truncate, Splice, and Move share the Op::Targeted base with a path. Constructors validate: s/d/a/t need a non-empty path, __proto__/constructor/prototype segments raise UnsafePathError, counts must be non-negative, and permutations must be bijections.
Op#to_tuple and Op.from_tuple convert to and from chord's decoded tuple form (["s", path, value]), validating like chord's assertValidOp. Delta.assert_valid_wire_op validates the wire form. Op#replace? and Delta.base? classify replacements and base batches.
Batches are exact but not canonical: the same change may use different tuples, and large edit sets may fold into a region splice, an ancestor s, or r.
Diffing and applying
ops = LLM::Chord::Delta.diff_revisions(before, after)
LLM::Chord::Delta.apply(replica, ops) # mutates and adopts payloads
LLM::Chord::Delta.apply_immutable(base, ops) # copy-on-write, shares untouched subtrees
LLM::Chord::Delta.apply_immutable_batches(base, [b1, b2]) # one copy-on-write scope for a backlog
diff_revisions matches chord's algorithm, including identity-based array anchoring: containers shared by both revisions anchor array alignment.
Wire codec
wire = LLM::Chord::Delta::Encoder.new.encode(ops) # Array(WireOp)
ops = LLM::Chord::Delta::Decoder.new.decode(wire)
Paths are interned on their second use (["#", id, path]), and an op on the same path as the previous one omits it. Use one encoder/decoder pair per ordered state stream: path ids span batches, and an r resets both dictionaries. The decoder validates every tuple.
Context
request = LLM::Chord::ContextKey(String).new("request")
context = LLM::Chord::Context::BACKGROUND.with_value(request, "abc")
context.value(request) # => "abc"
Contexts are immutable; every with_* derives a child and never changes the parent. Keys match by identity. Context::BACKGROUND and Context::TODO are the empty roots.
Cancellation
child, controller = context.with_cancel
controller.abort("shutting down") # or abort(an_exception)
child.abort_signal.not_nil!.aborted? # => true
cleanup = child.without_abort_signal # mandatory cleanup ignores caller cancellation
linked = context.with_abort_signal(other_controller.signal)
value = child.await(channel) # receive, or raise the abort reason
value = child.await { slow_work } # runs on its own fiber; cancellation stops waiting only
An AbortSignal has aborted?, reason (an Exception; AbortError by default), on_abort { |reason| } (returns a remover), throw_if_aborted, and AbortSignal.any(signals). A child signal aborts when its own controller or any ancestor aborts; siblings are unaffected.
Telemetry
recorder = LLM::Telemetry::InMemoryTelemetryContext.new
recorder.start_span(LLM::Telemetry::SpanOptions.new("app.request")) do |span|
context = LLM::Chord::Context::BACKGROUND.with_telemetry(span)
context.trace_in("app.load") do |child|
child.telemetry.add_event("cache.miss")
child.trace_in("app.read") { |nested| read_account(nested) }
end
end
context.telemetry is the context's span, or LLM::Telemetry::NOOP when none was set. trace_in(options) (or trace_in(name)) runs the block in a child span and passes a derived context; the block's value and exceptions pass through. There is no ambient current span.
Spans, metrics, propagation, the in-memory recorder, trace queries and render_trace are documented in the llm-telemetry shard's README.
Output
limits = LLM::Chord::Output::Limits.tail(200, 64 * 1024) # max_lines, max_bytes
buffer = LLM::Chord::Output::Buffer.new(limits)
buffer.push(bytes) # String or Bytes; UTF-8 split across chunks is decoded
buffer.end
buffer.snapshot # => Bounded(text, dropped_bytes, dropped_lines)
Output.bound(text, limits)keeps whole lines (head or tail) within both limits; a single line longer thanmax_bytesis cut on a character boundary. It returns anExcerpt(text,bytes,dropped_bytes,dropped_lines).Output.sanitize(text)removes control characters except tab and newline.Buffercosts time proportional to each chunk, keeps exact totals for the whole stream, decodes bytes like a WHATWG UTF-8 decoder (invalid sequences become U+FFFD), drops a byte-order mark only at the very start, and acceptsSkipcounts of omitted output for tail retention.Output::Progress.new(write, on_error, min_interval_ms, clock)paces commits: the first change after a pause commits at once; each commit then delays the next by at leastmin_interval_msand by its written size at 100 KiB/s.markschedules a commit,mark_and_waitreturns aWaiter(waitraises if the commit failed), andstopwaits for the commit in flight and returns the waiters no commit covered.writereturns the bytes it wrote and runs on its own fiber.clockdefaults toOutput::SYSTEM_CLOCK.
Errors
ChordError carries a code (ErrorCode: Aborted, ContractMismatch, Forbidden, Gone, Invalid, NotInVariant, NotReady, Released, Superseded, Unavailable, UpgradeConflict, UpgradeRequired), an optional reason, details, and cause; its message is code/reason: message. Superseded and UpgradeConflict are retryable?. Helpers: LLM::Chord.chord_error?, .forbidden, .invalid, .as_chord_error (keeps a ChordError, wraps anything else as unavailable), and .run_all (runs every step, then raises the one error or an AggregateError).
Contracts
A contract declares a type: its name, version, input schema, and members. A remote type also declares where its objects live. Schemas come from TypeBox::Type.
T = TypeBox::Type
C = LLM::Chord
View = T.object({text: T.string})
Chat = C.remote("t.chat",
version: 1,
input: T.object({session: T.string, conversation: T.number(default: 0)}),
host: C.host_family("session:", "session"),
variants: {
viewer: C.variant(state: {view: View}),
participant: C.variant(
state: {view: View},
methods: {send: C.method(T.object({text: T.string}), T.object({id: T.number}))},
),
})
Index = C.local("t.index", version: 1, input: T.object, members: {
harness: C.opaque("harness@1"),
count: T.integer,
bump: C.method,
})
Chat.fingerprint # => 16 hex digits, identical to chord's
Chat.description.json # the description that travels in tokens
C.method(args = nil, result = nil): noargsmeans the method takes no arguments; noresultmeans it returns nothing.C.opaque("name@version"): a native in-process object, for local contracts only.- Placement:
C.host_named("server"), orC.host_family(prefix, key), which puts an object onprefix+ the input's string fieldkey.C.resolve_host,C.placed_on?, andC.placement_textwork on either. - Members named
variant,status,ready,shape,error,onShape,onStatus,release,then, orcontractare rejected, as are opaque members in remote contracts. Construction errors raiseArgumentError. RemoteDefandLocalDefare records:C.remote(name, Chat.definition.copy_with(version: 2))derives a new version.- The fingerprint is
hash64(canonical_json(description)), so a contract built here and the same contract built by chord share a fingerprint.
Compatibility
C.coverage(provider_description, consumer_description) returns nil when the provider covers the consumer, or the reason it does not ("input", "participant.send arguments", "host", ...). C.bind(provider, accepts, consumer) returns nil for a direct binding, the consumer version's adapter from accepts (a Hash(Int32, A) of any adapter type), or raises contract_mismatch:
- equal fingerprints bind directly;
- a different placement never binds;
- the same version binds when the provider covers the consumer;
- another version needs an adapter in
accepts.
C.contract_from_description(json) rebuilds a consumer token from a received description (raising invalid/token when malformed); DESCRIPTION_SCHEMA validates one. C.takes_no_args?(contract, name) tells how a method is called.
Schema helpers
LLM::Chord::Schema ports chord's schema.ts:
| Function | Behavior |
|---|---|
canonical_json(value) |
sorted-key JSON, numbers and strings as JSON.stringify writes them |
hash64(text) |
two FNV-1a lanes over UTF-16 code units, 16 hex digits |
deep_equal?(a, b) |
equality by canonical JSON |
normalize(schema, value, what) |
clone, apply defaults, strip unknown fields, validate; raises invalid/input |
canonical_identity(schema, value) |
canonical JSON with fields equal to their default omitted |
assert_valid(reason, what, schema, value) |
raises invalid/<reason> with the first three errors |
fits?(sub, sup) |
conservative structural subtyping used by coverage |
Objects and hosts
A host serves types. An object is one instance of a type at an address: the type, its normalized input, and the principal it was created for. Its factory runs once and returns the implementation; references reach the object through the consumer's contract.
T = TypeBox::Type
C = LLM::Chord
Rows = T.array(T.object({text: T.string, done: T.boolean}))
TodoStore = C.local("todos", version: 1, input: T.object({list: T.string}), members: {
label: C.method(nil, T.string),
rows: Rows,
openCount: T.number,
add: C.method(T.object({text: T.string})),
})
host = C.create_host(self: "server")
host.serve(TodoStore) do |input, _principal, scope|
rows = scope.state("rows", Rows, [] of JSON::Any)
open_count = scope.derive([rows]) { |(todos)| todos.as_a.count { |todo| !todo["done"].as_bool } }
C::Implementation.new
.method("label") { "Todos for #{input["list"]}" }
.state("rows", rows)
.state("openCount", open_count)
.method("add") { |args, ctx| rows.change(ctx) { |draft| draft << {text: args.not_nil!["text"], done: false} } }
end
todos = host.get(TodoStore, {list: "family"})
stop = todos.state("openCount").subscribe { |count| puts count } # 0, then 1
host.root { |ctx| todos.call("add", {text: "Buy milk"}, ctx) }
todos.call("label") # => "Todos for family"
stop.call
todos.release
host.close
Hosts
C.create_host(**options) is Host.new(**options) (a macro, so symbols such as kind: :client still autocast):
| Option | Meaning |
|---|---|
self (required) |
The principal of the host's own code: its roots, code inside its objects, and its host-lifetime objects. |
name |
Default "host". Remote types must be placed on it to be served here. |
principal_key |
Proc(JSON::Any, String) mapping a principal to its object key. Default: canonical JSON. A principal with the same key as self is the host's own. |
dev |
Validates every cell write, and every value exposed as a remote state member, against its schemas. |
on_error |
Receives errors from background work (disposal, listeners, tasks, host objects). Default: printed to STDERR. |
ready_timeout |
How long an activation waits for its references to be ready and hydrated, and an awaited get for its reference (unavailable/ready_timeout). Default 60 s; nil waits forever. |
task_stop_deadline |
How long a reload waits for an object's aborted tasks to finish before starting their replacements anyway (reported). Default 5 s. |
loader |
FacetLoader (Proc(Array(String), Array(Facet))): resolves module names to facets for load, reload and the prepare frames of a cluster reload. |
kind |
:host (default) or :client (a browser: never carries roots). |
upstream |
The link to the parent: C.upstream(link, principal: ..., **link_options). Also connect later. |
linger, linger_cap |
Consumer-side linger of remote references: {type_name => Time::Span}; at most linger_cap (default 4) bindings linger. |
Principals and inputs are anything that serializes to JSON (named tuples, hashes, strings, JSON::Any).
serve(contract, lifetime:, linger:, accepts:) { |input, principal, scope| implementation }registers a factory.lifetime: :hostcreates the type's one object at once (a local type whose input accepts{}); everygetreaches it, it is created for the host principal, and only such objects may register into registries. A referenced object is disposed when its last reference is released, afterlinger.acceptsmaps older consumer versions to adapters (Proc(Implementation, Implementation)).get(type, input = nil, principal: nil, signal: nil, state: true)blocks until the reference is ready (live, with its state members hydrated) and returns it. It raises the activation error,aborted, orunavailable/ready_timeout, and releases the reference then.holdreturns the reference at once, maybe stillloading.state: falsemakes a remote reference method-only.root(principal: nil, signal: nil) { |ctx| ... }runs a call tree and returns the block's value. References taken withctx.getare released when the root ends.install(facet, await_host_objects: true),load(plugins, await_host_objects: true)andreadystart and await host-lifetime objects;loadinstalls every facet before awaiting any.pluginsare facets or module names resolved by theloader. A facet is installed once; later versions come withreload.reload(plugins, remove:, ...)replaces facets in place (see Reload).code_versionandprevious_code_versiontell which code versions the host retains.invalidate_principal { |principal| bool }orinvalidate_principal(pattern)force-disposes the objects of matching principals (never the host's own); a pattern matches principals whose listed fields are deep-equal.closedisposes every object, holders before what they hold, then closes the links.
Factories
The factory gets the normalized input, the principal, and an ObjectScope (chord's self) through which it declares what the object owns. Names are unique per kind, and declarations are validated at activation.
| Declaration | Meaning |
|---|---|
scope.state(name, schema, initial, version: 1, migrate: nil) |
A cell with an initial value (always validated). A reload keeps the cell and its value by name (initial is ignored); a raised version runs migrate (Proc(JSON::Any, Int32, T): old value, its version). |
scope.state(name, schema, version: 1, migrate: nil) { |ctx| value } |
A cell filled by an init that runs in its own root after the object's resources are open and its references live, before the object goes live. A kept cell does not run it again. |
scope.get(type, input = nil, principal: nil) |
A reference the object holds for its lifetime, kept across reloads by address. Holding cycles raise invalid/cycle. |
scope.resource(name, version:, deps:, exclusive:, close:) { |signal| value } |
Opened before the object goes live (after its deps, references or resources), closed in reverse order after it is disposed. value raises not_ready while any factory runs. A reload keeps it unless version, deps, exclusive or its value type change; a kept resource adopts the new open and close. |
scope.on(name, source) { |event, ctx| } |
A listener on a readable (current value, then every new one) or a Subscribable. on(name, resource) listens to the resource's value; on(name, resource, selector) to selector.call(value). Each event runs in a fresh root. A reload keeps the subscription while its source and selector (the same proc literal) are unchanged and swaps the handler. |
scope.task { |ctx| } |
Long-running work of the live object, aborted at disposal. Calls made with its ctx start fresh roots. A reload restarts it with the new code once the old run finished. |
scope.derive(sources) { |values| value } |
A synchronous projection of readables, recomputed atomically with object writes and on publication of other sources. Every reload makes a new one. |
scope.register(registry, entries) |
Registry entries (host-lifetime objects only). |
scope.expose(resource) |
A member backed by a resource's value. |
The factory returns an Implementation: Implementation.new(variant = nil) with method(name) { |args, ctx| result }, state(name, readable), expose(name, exposed), and opaque(name, value), plus [], []?, []=, delete, keys, dup and variant= for adapters. Method results convert to JSON; nil means no result. A remote contract's implementation names its variant and may only contain that variant's members.
References
| Member | Meaning |
|---|---|
call(name, args = nil, ctx = nil) |
Calls a method and returns its result (JSON::Any?). Without a ctx, or with a task's, it runs in a fresh root. Remote arguments and results are validated at the provider. |
state(name) |
A late-bound Readable following the member: reading raises not_ready until live and not_in_variant outside the variant. A listener added early gets its snapshot once live; a changed source is announced as a reset. |
opaque(name, T) |
An opaque or exposed member as T. |
status, ready, error |
RefStatus (Loading, Live, Released, Gone), a Future(Nil), and why the reference is gone. |
variant, shape, on_shape, on_status |
The current variant and shape, and listeners for their changes. |
release |
Releases the reference; calls then raise released. |
Future(T) has wait (blocks, raising a rejection), await_settled, settled?, rejected?, error, on_settle, and Future.run { } to run a block on its own fiber.
Cells and derivations
Cell#change(ctx) { |draft| } mutates a Delta::Draft of the value and publishes exactly the changed paths. Cell#update(ctx) { |value| new_value } replaces the value with the block's result, always validated. Cell#replace(ctx, value, diff: nil) replaces the whole value: subscribers get a structural diff when diff is true, or by default when the new value shares containers with the old one, and otherwise one r op. Cells reject writes while a factory runs (invalid/read_only).
Readables (Cell, Derived, reference members) have value, subscribe { |value| } (current value at once, then each new one) and events { |event| }: a StateEvent with kind (Snap, Ops, Reset), value and ops. Each write adopts its revision and every derived one before any observer runs, and notifications are delivered in adoption order, so a re-entrant write never overtakes the one being published.
Contexts
A Ctx has signal, root_id, code_version, principal (the caller at ingress, the host inside objects) and get(type, input = nil, principal: nil). A ctx expires when its call settles: using it later raises aborted/expired.
Facets and registries
Actions = C.define_registry(Proc(C::Ctx, Int64), "actions")
Menu = C.local("menu", version: 1, input: T.object)
store = C.define_facet("store") { |api| api.serve(TodoStore) { |input, principal, scope| todo_store(input, scope) } }
menu = C.define_facet("menu", uses: [TodoStore]) do |api|
api.serve(Menu, lifetime: :host) do |_input, _principal, scope|
todos = scope.get(TodoStore, {list: "family"})
scope.register(Actions, {
"addSample" => ->(ctx : C::Ctx) { todos.call("add", {text: "Try"}, ctx); todos.state("openCount").value.as_i64 },
})
C::Implementation.new
end
end
host.load([menu, store])
host.root { |ctx| host.registry(Actions).get("addSample", ctx).try &.call(ctx) }
todo_store(input, scope) stands for the factory body of the first example. A FacetApi has serve, root, registry, name, and signal (aborted when this run of the facet is no longer installed: a committed reload replaced or removed it, the reload that loaded it aborted, or the host closed). define_registry(V, name, conflict: :error) refuses two objects registering one key; :last keeps the later one. RegistryView has table(ctx = nil), get(key, ctx = nil) and subscribe { |table, code_version| }; tables are kept per code version (a ctx reads the table of its code version) and keep registration order.
Links
A link is an ordered frame channel between two hosts. A remote type's placement decides where its objects live; a reference to one on another host is a RemoteRef, a view onto the host's shared binding for that object.
server = C.create_host(self: {kind: "host"}, name: "server")
server.serve(Board) { |_input, principal, scope| ... }
down, up = C.memory_link_pair
server.accept(down, principal: {guest: "g1"})
browser = C.create_host(self: {kind: "host"}, name: "browser", kind: :client,
upstream: C.upstream(up, principal: {kind: "server"}))
board = browser.get(Board)
board.call("add", "hello")
board.state("notes").subscribe { |notes| puts notes }
accept(link, principal:, kind: :client, name: nil, restamp: nil, max_calls: nil, **link_options)accepts a peer. Everything it asks acts asprincipal; frames cannot claim another. A:hostpeer needs itsname, must announce it, and is reachable at it.restampgives a new principal oninvalidate_principal(ornilto close the link).max_callsanswers calls beyond it withunavailable/too_many_calls.connect(link, principal:, **link_options)sets (or replaces, after a reconnect) the link to the parent; remote references re-hold over it and keep their last values meanwhile. The parent's own requests act asprincipal.route(prefix, Route.new(authorize:, resolve:, principal:))relays holds for hosts namedprefix…:authorizeruns before and afterresolve(which returns aLink, spawning the host if needed);principalis what the routed host acts as when it calls back.LinkOptions:ping_interval(15 s),ping_timeout(45 s),window(256 KiB of unacknowledged frames;nilnever holds state back),pending_cap(4 windows of waiting ops),on_protocol_mismatch(Proc(Int32, Int32?, Nil): local and peer version).- Transports:
C.memory_link_pair(MemoryLink#seversimulates a dead network);C.ipc_link(IpcChannel.new(io))orIpcChannel.from_env(Node's IPC format: one JSON message per line, frames wrapped as{"chord2": frame});WebSocketLink.connect(url)orC.web_socket_link(socket).C.accept_web_sockets(host, **link_options) { |context| principal_or_nil }returns anHTTP::WebSocketHandlerthat accepts every socket as a client stamped with the authenticator's principal. - Frames are
Frameobjects (t,[],payload,packed);Wirebuilds, validates and encodes them, andWire.inflate_messageturns a binary WebSocket message back into text. Payloads abovePACK_THRESHOLD(64 KiB of JSON) travel deflated; relays forward them unread. - A hold implies the state of its variant: the provider sends a unit (the shape, then a snapshot of each member the consumer lacks), then delta-encoded ops. When the peer's acknowledgements lag a window behind, state waits in one slot per stream (ops coalesced, or replaced by a reset beyond
pending_cap), while results and first units go out at once. - Remote references to one object share one hold per host, principal key and contract fingerprint.
Ref#readyresolves once the binding has its shape and every state member's value.
Child host processes
manager = C.create_host_manager(C.fork_driver("./worker", init: {greeting: "hi"}, lock: "session-S1"))
instance = manager.start # spawned, linked, and reported ready
server.accept(instance.link, principal: {worker: "w1"}, kind: :host, name: "w1")
manager.stop # shutdown, then SIGKILL to the group after the grace period
In the child:
control = C.child_control # the IPC channel from NODE_CHANNEL_FD
control.lock # the manager's lock, before opening storage
host = C.create_host(self: {kind: "host"}, name: "w1",
upstream: C.upstream(control.link, principal: {kind: "server"}))
greeting = control.init.wait
control.on_shutdown { host.close; exit 0 }
control.ready
C.fork_driver(entry, args:, env:, init:, lock:, on_output:, on_message:)spawnsentrywithposix_spawnas the leader of its own process group, with stdin on/dev/nulland an IPC socket on fd 3 (NODE_CHANNEL_FD=3,NODE_CHANNEL_SERIALIZATION_MODE=json), so the child may be a Crystal host or a Node process. When the leader exits, the whole group is killed. A lock the parent inherited is not passed on.HostManager(status,instance,liveness,start,stop,replace) serializes its operations, waitsready_timeout(30 s) forready, and stops the child whenkeep_alive.call(liveness)returns false (checked everycheck_interval).Livenesshasbooting,active_calls,viewers,durable_tasksandidle_for(since the busy → idle transition).ChildControlhasinit(aFuture(JSON::Any?)),lock,ready,fatal(error),report(active_calls:, viewers:, durable_tasks:),on_shutdown,channelandlink.C.process_lock(name)holds a kernel lock for the process's lifetime: a Linux abstract socket named like chord's (\0chord2-+ 32 hex digits of SHA-256), so Crystal and Node processes exclude each other; a socket file in the temporary directory elsewhere. It returns the release proc and raisesunavailable/lockedwhen another process holds it.
Reload
A reload replaces facets while the host runs. Objects survive it: their cells, open resources, references and listener subscriptions are found again by name in the new factory's run. Facets are values compiled into the program, so a reload switches between facets the binary contains; shipping new Crystal code is a new binary (HostManager#replace), which a parent reconciles when it connects.
facet = ->(greeting : String) do
C.define_facet("greeter") do |api|
api.serve(Greeter) do |_input, _principal, scope|
count = scope.state("count", T.object({n: T.number}), {n: 0})
C::Implementation.new.state("count", count).method("greet") { |name| "#{greeting} #{name}" }
end
end
end
host.install(facet.call("hello"))
report = host.reload([facet.call("hi")]) # compatible: no gate, nothing waits
report.code_version # the new code version; report.previous was current
reload(plugins = [] of Facet, remove:, gate_deadline:, retire_deadline:, remote:, remote_remove:, vote_deadline:) returns a ReloadReport (code_version, previous, changed and removed type names, prepared and gated object addresses, and children: each participating child's report). Reloads are serialized (unavailable/reload_running); a failed one raises its cause's code with the message reload aborted: ... and changes nothing.
- Prepare. For each live object of a changed type, the new factory runs in recording mode: cells are readable and writes raise
invalid/read_only; listeners, tasks and registrations are recorded; new non-exclusive resources open. A cell whose schema changed without a raisedversionis refused, as is a lowered version. Contracts are checked: the same contract version must cover the old description (a forgotten bump iscontract_mismatch), and every surviving consumer and every facet'susesmust bind, directly or throughaccepts. - Gate. Only objects with a raised cell version or a replaced exclusive resource are gated. Roots that already called them are grandfathered; other old roots, and new objects of changed types, get
upgrade_conflict(retryable). The gate waits for grandfathered roots at mostgate_deadline(5 s;aborted/gate_deadlinenames the blocking roots), swaps exclusive resources, and runsmigrateinto private copies, validated against every schema that will apply. - Commit. One synchronous step installs the new code version: implementations, migrated values (subscribers see a reset), registry tables, new derivations, rebound listeners and restarted tasks; references see a shape push, and browser references that no longer bind become
gonewithupgrade_required. - Two-version rule. A host keeps the current and the previous code version. A call carrying a code version the host no longer has is
superseded(retryable). The previous code version retires when its last root ended (and, on a child, its parent retired it). The next reload waits for that at mostretire_deadline(5 s), then aborts those roots, gives them a second, and closes what only that code version declared. - Clusters. A host with child hosts (or given
remote:) coordinates: it sendspreparewith the module namesremotegives each child by host-name prefix (longest wins), collects votes withinvote_deadline(gate + retire deadlines + 5 s), commits itself, then sendscommitbefore anything of the new code version can reach a child. A child cannot reload on its own (unavailable/not_coordinator). A child that connects behind is fenced (its roots' calls are admitted in the parent's code version) and reconciled by a reload of the modules earlier cluster reloads gave it.
Replicated state
state = LLM::Chord.replicated_state(JSON.parse(%({"n":0})))
stop = state.subscribe do |value, context, delivery|
# delivery.kind : DeliveryKind::Hydrate | Update, delivery.sequence : Int64
end
state.change(LLM::Chord::Context::BACKGROUND) { |draft| draft["n"] = 1 }
state.replace(LLM::Chord::Context::BACKGROUND, JSON.parse(%({"fresh":true})))
stop.call
- A successful
changepublishes exactly one revision; if the block raises, the value and sequence are unchanged and the draft is revoked. No-op changes do not publish. Changes and replacements cannot be made from inside a change block. - Each subscription serializes its callbacks: hydration first, then updates. At most 100 deliveries wait behind the running callback; on overflow only the newest is kept (plus a not-yet-started hydration), so sequences may skip.
subscribe(async: true)runs the subscription's callbacks on their own fiber, one at a time, so a slow listener never blocks the publisher.- Listener exceptions are reported and delivery continues. Mutable states report by raising in a spawned fiber.
- Unsubscribing discards pending deliveries; it neither waits for nor aborts a running callback.
LLM::Chord.replicated_state(source, on_error) attaches a publication-only state to a ReplicatedStateSource. The source's attachment provides a Snapshot and delivers SourceFrames (cursor, value, ops, context) in cursor order. A cursor gap, an unsafe cursor, or a failing attachment disposes the attachment and reports to on_error (an AggregateError when disposal also fails). dispose is idempotent.
SQLite
require "llm-chord/sqlite"
S = LLM::Chord::Sqlite
db = S.open_database("data/state.db") # or ":memory:"
S.apply_migrations(db, [
S::Migration.new(1, ["CREATE TABLE notes (body TEXT)"]),
S::Migration.new(2, ["ALTER TABLE notes ADD COLUMN title TEXT"]),
], table: "notes_schema")
db.run("INSERT INTO notes (body, title) VALUES (?, ?)", "hello", nil)
db.get("SELECT body FROM notes") # => {"body" => "hello"}
db.all("SELECT * FROM notes") # => [{"body" => "hello", "title" => nil}]
db.transaction do |tx|
tx.run("UPDATE notes SET title = ?", "greeting")
end
db.close
Sqlite::Database is chord's portable SqliteDatabase facade:
| Member | Meaning |
|---|---|
exec(sql) |
Runs SQL text without bindings; it may contain several statements. |
run(sql, *params) |
Runs one statement with positional bindings. |
get(sql, *params) |
The first row (Row?). |
all(sql, *params) |
Every row (Array(Row)). |
transaction { |tx| ... } |
Runs the block in a transaction and returns its value. All its work must use tx, which raises Sqlite::Error ("SQLite transaction handle is no longer active") once the block returns. |
close |
Closes the database; idempotent. |
- Values are
Sqlite::Value(Int64 | Float64 | String | Bytes | Nil);run,getandallalso take otherIntandFloatvalues. A row is aSqlite::Row(Hash(String, Value)) keyed by column name. - Bindings are values, never interpolated SQL: prepared statements are cached by SQL text on the connection.
- A failing block rolls the transaction back and raises the block's own exception. If the rollback fails too, it raises an
AggregateErrorinstead, so a caller cannot mistake the error for a guaranteed rollback. Executoris the interface of a database and a transaction handle. An adapter implementsexec,run_values,get_valuesandall_values(takingArray(Value));run,getandallconvert and delegate.
Sqlite.open_database(path, wal_autocheckpoint_pages: 1_000, busy_timeout_ms: 5_000) returns a Sqlite3Database: one crystal-sqlite3 connection (not a pool, so a :memory: database is one database). It creates the file's directory, sets journal_mode = WAL, synchronous = NORMAL, the WAL auto-checkpoint threshold (0 disables it) and the time SQLite waits for a competing lock.
- Operations run strictly in call order, on any fiber: a transaction holds the queue until its block returns, and calls made meanwhile wait behind it, so they neither see its uncommitted rows nor join its rollback. Calling the database itself (not
tx) from inside a transaction block waits for that transaction, which never ends. - Transactions begin with
BEGIN IMMEDIATE.closecheckpoints the WAL withTRUNCATE; operations on a closed database raiseSqlite::Error. - SQL errors raise
SQLite3::Exception.
Sqlite.apply_migrations(db, migrations, table:) applies every pending migration atomically: the version table's creation and every pending migration commit or roll back together. Versions must be 1, 2, 3, ...; the table (singleton, version, STRICT) records the version reached, a database newer than the last migration raises, and independent tables keep independent histories. Sqlite.validate_schema_prefix(prefix) accepts identifiers ([A-Za-z_][A-Za-z0-9_]*) that do not start with sqlite_ in any case; the migration table name is validated before any SQL runs. Both raise Sqlite::Error with chord's messages.
SQLite telemetry
require "llm-chord/telemetry-sqlite"
db = LLM::Chord::Sqlite.open_database("data/telemetry.db")
telemetry = LLM::Chord.create_sqlite_telemetry(db)
telemetry.start_span(LLM::Telemetry::SpanOptions.new("request")) do |span|
span.add_event("loaded")
telemetry.counter("requests").add(1, LLM::Telemetry.attributes(route: "/"))
end
telemetry.flush
telemetry.find_traces(LLM::Telemetry::TraceQuery.new(name: "request"))
telemetry.close # stops recording and drains; the database stays open
db.close
create_sqlite_telemetry(db, retention: 7.days, max_queued_spans: 10_000, clock: LLM::Telemetry::SYSTEM_CLOCK) migrates its tables (telemetry_spans, telemetry_metrics, versioned in telemetry_schema) and returns a SqliteTelemetry: an LLM::Telemetry::TelemetryContext that is also its own Metrics, Propagation and TelemetryStore.
- Recording never runs SQL. Only settled spans are stored, with their attributes, events (time relative to the span's start), links and status. Metrics aggregate
count,sum,minandmaxper series (name, kind, unit, attributes) and ten-second interval; descriptions and histogram boundaries are not kept. - A flush runs every second.
flushpersists settled spans and completed intervals;closealso writes partial intervals. Each flush is one transaction, which also deletes rows older thanretention. Flushes are serialized, and concurrentclosecalls share one drain. max_queued_spansbounds the settled spans awaiting commit, including the batch being written, and separately the pending metric interval/series pairs. What does not fit is counted indropped_spansanddropped_measurements.- A write failure stops recording for good:
flushandcloseraise that same exception from then on, without retrying. - After
close, recording is inert (spans still run their blocks) and the store still reads committed rows. inject/extractcarry the span identity underpi-sqlite-spanandpi-sqlite-trace, so a trace continues across processes and recorders. Span ids are<recorder UUID>:<sequence>; a root span's trace id is its own id.- Reads see committed rows only;
flushfirst to include new spans.find_tracespages with an opaque cursor in two queries per page, filtering by the first span's name and start, any error, and attributes all present on one span (a boolean never equals a number). - Stored JSON (attributes, events, links, status and the series key) is written exactly as
JSON.stringifywrites it, so Crystal and Node recorders can share one database file and merge aggregates. Consequently a wholeFloat64attribute such as2.0is stored as2and reads back asInt64. clock(anLLM::Telemetry::Clock) provides the wall time, the monotonic time and the one-second timer; pass anLLM::Telemetry::Testing::ManualClockto control them in tests.
Public API reference
LLM::Chord::Delta.track(initial) : TrackerTracker#value,#revision,#begin_change,#prepare_replace,#adoptChange#state,#prepare,#abortPrepared#base,#value,#ops,#base_revision,#abortDraft(see above),OverlayErrorLLM::Chord::Delta.diff_revisions,.apply,.apply_immutable,.apply_immutable_batchesLLM::Chord::Delta::Encoder#encode,Decoder#decodeLLM::Chord::Delta.overlap(a, b, scan, probe = 64, max_candidates = 8)Op,Op.from_tuple,Op#to_tuple,Op#replace?,Op::Targeted#pathLLM::Chord::Delta.base?,.assert_safe_path,.assert_permutation,.assert_valid_wire_op- Types:
Seg = String | Int32,Path = Array(Seg),WireOp = JSON::Any - Errors:
UnsafePathError,PathError,OverlayError LLM::Chord::Json.deep_copy,.copy_json,.container?,.identical?,.equal?,.to_any,.to_struct,.from_struct,.valid?,.strict?,.validate!,.js_string,.number_textLLM::Chord::Context(BACKGROUND,TODO,#value,#with_value,#abort_signal,#with_abort_signal,#without_abort_signal,#with_cancel,#await,#telemetry,#with_telemetry,#trace_in),ContextKey(T),AbortSignal,AbortController,AbortErrorLLM::Chord::Output:Limits,Retain,Excerpt,Bounded,Skip,Buffer,Progress,Waiter,Clock,SYSTEM_CLOCK,.bound,.sanitize,.character_end,.utf8_byte_length,PROGRESS_BYTES_PER_SECONDLLM::Chord::ChordError,ErrorCode,.chord_error?,.forbidden,.invalid,.as_chord_error,.run_allLLM::Chord.remote,.local,.variant,.method,.opaque,.host_named,.host_family,.resolve_host,.placed_on?,.placement_text,.coverage,.bind,.contract_from_description,.takes_no_args?Contract(RemoteContract,LocalContract;#name,#version,#input,#description,#fingerprint,#scope,#definition),RemoteDef,LocalDef,VariantDef,MethodSpec,OpaqueSpec,Member,Placement(HostNamed,HostFamily),Description,MemberDescription,LOCAL_VARIANT,RESERVED_MEMBERS,DESCRIPTION_SCHEMA,PLACEMENT_SCHEMALLM::Chord::Schema.canonical_json,.hash64,.deep_equal?,.assert_valid,.normalize,.canonical_identity,.fits?LLM::Chord.replicated_state(initial) : MutableReplicatedState(#value,#subscribe,#change,#replace)LLM::Chord.replicated_state(source, on_error = nil) : AttachedReplicatedState(#value,#subscribe,#dispose)Delivery,DeliveryKind,StateListener,ErrorReporter,AggregateError,Snapshot,SourceFrame,ReplicatedStateSource,ReplicatedStateSourceAttachmentLLM::Chord.create_host,Host(#name,#principal,#dev?,#ready_timeout,#code_version,#previous_code_version,#serve,#install,#load,#ready,#get,#hold,#root,#registry,#invalidate_principal,#report,#close),#reload,Lifetime,Accepts,Adapter,CodeVersionReloadReport(#code_version,#previous,#changed,#removed,#prepared,#gated,#children),ReloadOptions,Migration,FacetLoader,DEFAULT_GATE_DEADLINE,DEFAULT_RETIRE_DEADLINEObjectScope(#state,#get,#resource,#on,#task,#derive,#register,#expose),Implementation,MethodFn,MemberValue,OpaqueValue,OpaqueBox(T),Exposed,Resource(T)(#key,#value,#version,#deps,#exclusive?),SubscribableRef(#call,#state,#opaque,#contract,#address,#status,#ready,#error,#variant,#shape,#on_shape,#on_status,#release),RefStatus,Shape,Ctx(#signal,#root_id,#code_version,#principal,#holder?,#closed?,#get)Readable(#value,#subscribe,#events),Cell(#change,#update,#replace,#schema),Derived,StateEventFuture(T),LLM::Chord.race,.await_all,.not_readyHost#accept,#connect,#route,#connections,#upstream,#kind;Route,Upstream,LLM::Chord.upstream,LinkOptions,PeerKind,Role,Peer,Connection(#waiting,#served,#relayed_refs,#relayed_calls),ServedLocal,ServedRelayLink,BaseLink,MemoryLink,LLM::Chord.memory_link_pair,IpcChannel,IpcLink,LLM::Chord.ipc_link,WebSocketLink,LLM::Chord.web_socket_link,.accept_web_socketsFrame,Packed,Wire(.frame?,.to_error,.from_error,.encode_object,.decode_object,.parse,.encode_message,.decode_message,.inflate_message,FRAME_SCHEMA),PROTOCOL_VERSION,PACK_THRESHOLDLocalRef,RemoteRef,RemoteBinding,ViewReadable,MemberReadable,Ref#ended?LLM::Chord.create_host_manager,HostManager,Liveness,Exit,Driver,ForkDriver,LLM::Chord.fork_driver,Instance,ForkInstance,ControlMessage,ChildControl,LLM::Chord.child_control,.process_lock,LOCK_ENVFacet,LLM::Chord.define_facet,FacetApi,Registry(V),RegistryConflict,RegistryView(V),LLM::Chord.define_registryLLM::Chord::Schema.stringify(insertion-orderJSON.stringify)LLM::Chord::Sqlite(require "llm-chord/sqlite"):Value,Row,Error,Executor(#exec,#run,#get,#all,#run_values,#get_values,#all_values),Database(#transaction,#close),Migration,.apply_migrations,.validate_schema_prefix,.open_database,Sqlite3Database(#closed?),Sqlite3Executor,Sqlite3Transaction,SerialQueue,DEFAULT_WAL_AUTOCHECKPOINT_PAGES,DEFAULT_BUSY_TIMEOUT_MSLLM::Chord.create_sqlite_telemetry(require "llm-chord/telemetry-sqlite"),SqliteTelemetry(#start_span,#counter,#up_down_counter,#histogram,#inject,#extract,#get_trace,#find_traces,#find_metrics,#flush,#close,#dropped_spans,#dropped_measurements,#stopped?;.get_trace,.find_traces,.find_metricsover a database;SPAN_KEY,TRACE_KEY,INTERVAL_MS,FLUSH_INTERVAL,DEFAULT_RETENTION,DEFAULT_MAX_QUEUED_SPANS,MIGRATIONS)
Differences from chord
- Drafts are explicit
Drafthandles rather than JavaScript proxies, andnilstores JSONnull(chord'sundefineddeletion isDraft#delete). - Asynchronous listeners are opted into with
subscribe(async: true)and run on fibers, instead of being detected from a returned promise. Delivery assumes Crystal's default single-threaded scheduler, as chord assumes one event loop. - Promises become blocking calls on fibers:
start_spanandtrace_inreturn the block's value,Context#awaitreplacesawaitWithContext, andProgress#mark_and_waitreturns aWaiter. - Abort reasons are always exceptions;
abort("text")wraps the text in anAbortError. Outputworks on UTF-8 Crystal strings. JavaScript lone surrogates have no equivalent, so the related upstream cases do not apply.- Telemetry is the separate
llm-telemetryshard (LLM::Telemetry). Its span setters follow Crystal naming:merge_attributesforsetAttributes,status=forsetStatus, andrecorded_spans/recorded_metricsforgetSpans/getMetrics. pi-telemetry's typed schema helpers are TypeScript type-level only and are not ported. - Contracts carry no static types: chord's
Ref,ImplementationOf, andInputOfare TypeScript type-level only. Contract construction errors areArgumentErrorwhere chord throwsTypeError, anddefisdefinition. - Schema behavior inherits the
typeboxshard's documented differences from TypeBox (see its README). - Factories receive an
ObjectScope(chord'sself, a Crystal keyword) and return anImplementationbuilder. References expose members throughcall,stateandopaqueinstead of a JavaScript proxy. Cell#changeignores its block's value, since Crystal draft mutators return values; returning a new value isCell#update.- Factories, methods, inits, tasks and listeners are ordinary Crystal code. Awaiting a promise becomes blocking a fiber; activations, disposals, listener invocations and tasks run on their own fibers. Chord's async-factory check has no equivalent.
lingerandready_timeoutareTime::Spans.- Facets are values: Crystal cannot load code at runtime, so a
FacetLoadermaps module names to facets compiled into the program, and a reload switches between them (see the roadmap's reload decision). Upstream's Node loader is not ported. - A listener's selector counts as unchanged when it is the same proc literal (
Proc#pointer), where chord compares the function's source text. - A kept resource must also keep its value type: a resource whose block now returns another type is a new resource.
- Upstream's generation GC tests rely on V8's
FinalizationRegistry; Crystal's conservative collector cannot assert collection, so they are not ported. A retired code version's implementations, declarations and cached adapter views are dropped all the same. Linkis also the name of Crystal's top-level@[Link]annotation: code that includesLLM::ChordwritesLLM::Chord::Link.- A remote state member whose value is
undefinedreads as JSONnull. - Flow control counts frame sizes in UTF-8 bytes, where chord counts UTF-16 code units; the window and caps are local accounting and never cross the wire.
- The fork driver spawns an executable with arguments (
entry,args) rather than a Node module withexecArgv; to run a Node child, passnodeand the script. - SQLite operations block the calling fiber instead of returning promises, and rows are
Hash(String, Value)rather than typed objects (get<T>). The adapter iscrystal-sqlite3where chord usesnode:sqlite; itsopen_databaseis chord'sopenNodeSqliteDatabase, withbusy_timeout_msandwal_autocheckpoint_pagesforbusyTimeoutMsandwalAutoCheckpointPages. A closed database raisesSqlite::Error. create_sqlite_telemetrytakesretentionas aTime::Span(chord'sretentionMs) and an injectableclock, where chord readsDate.now,performance.nowandsetInterval. A span that raised gets the exception's class name as its error name.
Development
shards install
crystal spec
ameba
The SQLite specs use in-memory databases (and one temporary file). The SQLite telemetry specs run llm-telemetry's adapter and store conformance suites (require "llm-telemetry/testing") against SqliteTelemetry.
conformance/run.sh links Crystal and Node hosts to each other (a Node server with a Crystal child over IPC, a Node browser on a Crystal server over a WebSocket). It needs upstream chord's sources (CHORD_SRC) and Node 22 with typebox 1.3.27.
Benchmarks live in bench/ (see bench/README.md):
crystal run bench/bench.cr --release
License
MIT. See LICENSE.
llm-chord
- 0
- 0
- 0
- 0
- 3
- about 1 hour ago
- October 20, 2026
MIT License
Thu, 22 Oct 2026 09:06:21 GMT