EphemeralMap

@Serializable
class EphemeralMap<V> : Quilted<EphemeralMap<V>>

A presence/awareness CRDT.

What this models

Each replica (A, B, C, …) owns exactly one slot. A peer can write an arbitrary value V into its own slot ("I am present with cursor = X") or explicitly vacate it ("I am leaving"). Entries expire on observers that have not received a heartbeat within a caller-supplied TTL.

Design decisions

Expiry clock — local receive time. Cross-peer wall-clock comparison is unbounded under clock skew (wasmJs, iOS). Instead, every observer measures staleness by its own locally-stamped receive time: when was the last update from replica R received here? The CRDT carries a per-replica monotonic EphemeralEntry.clock for ordering re-publishes, but that clock is never compared across replica slots. Make the time source injectable (see EphemeralMapTracker); the CRDT itself is time-free.

Graceful departure — null + higher clock. Yjs Awareness pattern. leave writes a null-valued entry with a clock one higher than the current. Peers that merge the departure suppress the slot from live output even if a stale presence entry with a lower clock also exists.

Tie-break at equal clocks — present beats null. A crash-detector tombstone minted at seenClock + 1 can collide with a live peer's next heartbeat if both increment from the same base. At equal clock, piece keeps the non-null (present) entry, so a live peer's heartbeat is never evicted by a same-clock departure. Null-vs-null at equal clock is a no-op. Value-vs-value at equal clock for the same replica is precluded by the single-writer contract (each replica writes only its own slot), so no second tie-break is needed there.

TTL eviction location. The CRDT state is time-free and serialisable: it holds all entries, including stale and null ones. The live helper filters entries given a caller-supplied receive-time map and a now timestamp — it is pure and does not mutate any state. EphemeralMapTracker wraps the CRDT with an injectable clock, maintains the receive-time map, and surfaces a single live() call that drives eviction.

Each replica writes only its own slot. There is no mechanism for replica A to write into B's slot, so no tombstone or add-wins logic is needed — absence after TTL is sufficient for removal.

Not durable. This CRDT is intentionally not designed for persistence across reconnect. Use LWWMap or ORMap for durable key→value mappings.

Reconnect and clock-reset recovery

When a replica restarts it resets its local clock to zero (or a low value), which is below the stale high-clock entry that peers already have for that replica. The join (piece, put) alone would silently drop the restarted replica's writes until its clock catches up. TTL eviction is what recovers it: an observer measures staleness by its own local receive time, and once a slot has gone ttlMs without a fresh update it reads as absent (EphemeralMapTracker evicts it on the next inbound update — see evicting), so the restarted replica's next heartbeat is accepted as fresh even though its clock counter is lower. Rejoin-visibility latency is therefore bounded by ttlMs from the restart's first heartbeat — not unbounded. Note this is a per-observer, receive-time signal: within the TTL window an observer that has not yet expired the dead slot still hides the restart (the accepted ephemeral within-TTL skew), and a stale re-delivery of the dead incarnation's high-clock entry (e.g. via anti-entropy) can re-pin it.

TTL eviction also cannot recover a replica that departed gracefully: leave is permanent by design, and the tracker will not drop a tombstone to admit an entry that tombstone already outranks (#1675). Such a replica rejoins only by out-clocking its own departure.

So for clock-domination that does not depend on TTL timing — and for any rejoin after leave — drive put from a clock whose high bits carry a per-boot incarnation epoch, so a restart's clock always exceeds the dead incarnation's; then TTL eviction is only the backstop, not the sole mechanism. IncarnationClock packs exactly that layout.

Type Parameters

V

the value type carried in each presence entry.

Types

Link copied to clipboard
object Companion

Properties

Link copied to clipboard

Per-replica latest entry. Null value = departed; null key = never heard of.

Functions

Link copied to clipboard
open fun causalDots(): Set<Dot>

The causal Dots this state has delivered — (author, author-seq) per op.

Link copied to clipboard

The per-author high-water of dots this state delivered and has since compacted away without retaining their identities.

Link copied to clipboard
open operator override fun equals(other: Any?): Boolean
Link copied to clipboard
open override fun hashCode(): Int
Link copied to clipboard
fun leave(replica: ReplicaId, clock: Long): EphemeralMap<V>

Signal graceful departure for replica: publishes a null-value entry with clock, which must be higher than any prior entry. Peers that merge this departure will suppress replica from live output.

Link copied to clipboard
fun live(receiveTime: Map<ReplicaId, Long>, now: Long, ttlMs: Long): Map<ReplicaId, V>

Returns the set of live entries: those with a non-null value whose receive time is within ttlMs milliseconds of now.

Link copied to clipboard
open override fun piece(other: EphemeralMap<V>): EphemeralMap<V>

The join: per-replica max-clock wins. At equal clocks, present beats null (see class-level KDoc for the tie-break rationale).

Link copied to clipboard
fun put(replica: ReplicaId, value: V, clock: Long): EphemeralMap<V>

Publish or update this replica's presence with value and the given clock.

Link copied to clipboard
open override fun toString(): String