Skip to content
Get Started

Distribution & Clustering

Nulang actors are location-transparent: you send to an actor without knowing (or caring) which node it lives on. The runtime resolves actor addresses — local or remote — transparently.

actor Counter {
behavior inc() { 0 }
}
let some_actor = spawn Counter {}
// Same code works for local AND remote actors
send some_actor inc()

An ActorAddress is either:

  • Local — a direct 64-bit actor id on the current node
  • Remote — a (node_id, actor_id) pair on a different node

The AddressResolver maintains an LRU cache (10k entries) mapping remote addresses.

Nodes discover each other via a gossip protocol:

  1. Seeds — A node joins a cluster by connecting to one or more seed nodes
  2. Heartbeats — Nodes periodically heartbeat all known members
  3. Gossip — Membership state propagates transitively through connected peers

Cluster membership uses heartbeat-based discovery and a gossip protocol for transitive propagation. A node joins by connecting to one or more seed nodes; membership state (Joining, Healthy, Suspicious, Left, Removed) propagates through connected peers. Each membership entry carries an incarnation number — higher incarnations win in merge conflicts, preventing split-brain regressions.

State Description
Joining Initial state, awaiting first gossip round
Healthy Active member, heartbeating and receiving messages
Suspicious Missed heartbeats, awaiting confirmation
Left Gracefully departed
Removed Pruned from membership after timeout

Membership state carries an incarnation number — higher incarnations win in merge conflicts, preventing split-brain regressions.

Remote spawn is accessed via the Rust API: behaviors are registered with Runtime::register_spawnable_behavior, and the wire protocol uses Packet::SpawnRequest/SpawnResponse for cross-node actor creation. The receiver spawns the named behavior only if it was pre-registered; unknown names return a failed response. A language-level remote-spawn expression is planned but not yet implemented.

The distribution layer uses a custom TCP protocol:

  • Magic: NUL0 (4 bytes)
  • Handshake: 8-byte node id exchange
  • Frames: Length-prefixed, big-endian encoded
  • Packet types: ActorMessage, Heartbeat, Ack, SpawnRequest, SpawnResponse, CrdtSync, CrdtDeltaSync, Gossip

String values travel by content — the sender populates a string table and the receiver interns strings into its module pool. Heap pointers, closures, actor refs, and nil are rejected at send time.

Nulang supports 8 Conflict-free Replicated Data Types for eventually-consistent state:

CRDT Description
GCounter Grow-only counter
PNCounter Positive-negative counter (increment/decrement)
GSet Grow-only set
ORSet Observed-remove set
AWORSet Add-wins observed-remove set
LWWRegister Last-writer-wins register
MVRegister Multi-value register
RGA Replicated growable array

CRDTs use delta-state replication: only changed state (deltas) is shipped over the wire, with periodic full syncs (every 16 rounds) as a repair mechanism.

CRDT types (GCounter, PNCounter, GSet, ORSet, AWORSet, LWWRegister, MVRegister, RGA) are Rust-level APIs on CrdtManager. Actor state fields can also be declared CRDT-backed (state crdt gcounter hits: Int = 0 — the crdt state model takes an optional CRDT type keyword: gcounter, pncounter, gset, orset, aworset, lwwregister, mvregister, rga), and the runtime wires a language-level Crdt.* built-in effect for operating on them:

Effect op Signature Description
Crdt.increment increment(field: String) -> Unit Increment a gcounter/pncounter field
Crdt.decrement decrement(field: String) -> Unit Decrement a pncounter field
Crdt.add add(field: String, item: String) -> Unit Add an element to a gset/orset/aworset field
Crdt.remove remove(field: String, item: String) -> Unit Remove an element from an orset/aworset field
Crdt.set set(field: String, value: String) -> Unit Write a lwwregister/mvregister field
Crdt.read read(field: String) -> Int | String Read the materialized value (count for counters, element count for sets/RGA, stored string for registers)

Each op is rejected (returns nil) when the field’s CRDT type doesn’t match, and also returns nil outside an actor context. See CRDT Stdlib Reference for details.

Actors can be persisted for durability:

Store Description
MemoryStore In-memory (default, ephemeral)
JsonFileStore JSON file on disk
SqliteStore SQLite database (via rusqlite)

Persistent actors support journaling and checkpointing for crash recovery.

// State durability is configured at the runtime level
// (Local, Durable, EventSourced, Crdt — see PersistenceStore)
actor DurableWorker {
state tasks: [String] = []
behavior add_task(task: String) {
self.tasks = self.tasks + [task]
}
}