Skip to content

lattice / field guide

Presence

Tracking distributed presence with add-wins CRDT semantics.

Presence is provided by the lattice_presence package. It tracks which pids are present for a topic and key, together with arbitrary JSON metadata.

Use it when multiple nodes need to maintain a shared view of online users, socket processes, sessions, or similar ephemeral membership without routing all joins and leaves through one coordinator.

import gleam/json
import lattice_presence/presence_state as presence
pub fn main() {
let state =
presence.new_incarnation("node-a")
|> presence.join("pid-1", "room:lobby", "alice", json.object([]))
presence.get_by_topic(state, "room:lobby")
// -> [#("pid-1", "alice", _)]
}

Each join creates a causal tag owned by the local replica. Queries hide entries from replicas you have marked down locally.

Replica identity uniqueness is per process incarnation, not just per stable node name. Create a fresh identity every time the presence process starts:

let state = presence.new_incarnation("node-a")

This preserves node-a as the stable name while giving each run a unique causal identity. On merge, a restarted state ignores cached values owned by an earlier incarnation of that stable name but retains their causal context. Syncing the restarted state back to peers therefore removes those stale entries.

Use new only when the supplied identity is already unique for the entire process incarnation and will never be reused after restart.

merge returns the merged state:

let merged = presence.merge(node_a, node_b)

Use merge_with_diff when an application needs Phoenix-style join and leave notifications while applying remote state:

let #(merged, diff) = presence.merge_with_diff(node_a, node_b)

The diff groups joins and leaves by topic. It is for notifying subscribers; the merged state is still the source of truth.

Replica liveness is local view state, not replicated CRDT state. If a node sees a peer go down, it marks that peer down in its own state:

let #(state, diff) = presence.replica_down(state, "node-b")

Entries owned by a down replica become invisible to query functions and appear as leaves in the returned diff. If the replica comes back:

let #(state, diff) = presence.replica_up(state, "node-b")

Those entries become visible again and appear as joins. This keeps cluster liveness decisions in the embedding application instead of trying to replicate up/down status as CRDT data.

leave removes one local pid/topic/key entry:

let state = presence.leave(state, "pid-1", "room:lobby", "alice")

leave_by_pid removes all local entries for a pid. Both operations only remove entries owned by the local replica; foreign entries must be removed by their owning replica or hidden with replica liveness.

After a replica is permanently gone, remove_down_replica can discard its entries and causal context:

let state = presence.remove_down_replica(state, "node-b")

Only call this after the application has decided that the replica will not return with useful state.

Use lattice_presence/state_json for cross-node payloads:

import lattice_presence/state_json
let payload = state_json.to_json_string(state)
let decoded = state_json.from_json(payload)

The JSON format contains replicated CRDT data: replica name, causal context, clouds, and presence entries. Local replica visibility (replica_up / replica_down) is intentionally not serialized. Decoding validates causal clock values and limits nested metadata depth so malformed payloads fail as Result(Error(_)) instead of producing invalid state.