OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / lib / signer-presence.js

106 lines · javascript ref:
// SignerPresence — leak-free single-active-signer election from live CONNECTION PRESENCE.
//
// Replaces the `__lease__ {holder,epoch,ts}` heartbeat (lease.js), which wrote a fresh wall-clock ts to the
// APPEND-ONLY autobase log every ~8s forever (~20MB/day, unreclaimable — see the S1 research). Keet, and
// every other holepunch app, signals liveness EPHEMERALLY over the connection and never persists it. This is
// that idiom: "which device signs" is a deterministic function of who is CONNECTED right now, computed in
// RAM. It appends NOTHING.
//
// Election = the lowest-keyed device among the currently-effective co-owners (a stable, replication-free
// tiebreak every connected peer computes identically → they agree on one signer → only one signs → no fork,
// exactly the guarantee the ts-lease gave in the connected case). A partition still diverges and reconciles
// on merge — identical to the old lease, which also forked on partition (the epoch never prevented that).
//
// GRACE window = blip tolerance: a departed holder stays "effective" for graceMs before failover, so a brief
// disconnect does not thrash the signer (mirrors the old lease's "never steal a live lease for TTL"). Default
// graceMs matches the old ttlMs (15s) so failover timing is unchanged.
//
// Presence is fed by the transport (a co-owner presence handshake on the StateBase swarm): markPresent on
// connect+identify, markAbsent on close. This module is pure + clock-injectable → unit-testable with no swarm.

export class SignerPresence {
  // self: this device's member key (hex). graceMs: how long a departed co-owner stays eligible (blip
  // tolerance / failover delay). clock: injectable now() for deterministic tests.
  // onChange: fired on an ACTUAL presence change (a co-owner connected or departed) — the EVENT that
  // drives zero-timer failover: the daemon re-evaluates holdsSigner + signs on this, plus on every
  // StateBase advance (sb.watch), so no polling timer is needed. Grace expiry itself has no event (a
  // write/replication/presence event after grace picks it up — acceptable failover latency, no loss).
  constructor ({ self, graceMs = 15000, clock = () => Date.now(), onChange = null } = {}) {
    if (!self) throw new Error('SignerPresence: self (this device member key) required')
    this.self = String(self)
    this.graceMs = graceMs
    this.clock = clock
    this.onChange = onChange
    this._present = new Map() // memberKey -> lastSeen: Infinity = connected now; a number = ms it departed
    this._present.set(this.self, Infinity) // we are always present to ourselves
    // CURRENCY (anti-freeze): each co-owner's last-announced signed head rev (a monotonic TID string) or
    // null if unknown. A BEHIND co-owner must not win the election — else it holds the lease but the
    // forward-only sign gate blocks it (it's behind) while the caught-up peer defers to it → the chain
    // FREEZES (the stortron split-brain aftermath). currentSigner filters on this vs the network head.
    this._heads = new Map() // memberKey -> headRev (string) | null
  }

  _fire () { if (this.onChange) { try { this.onChange() } catch {} } }

  // A co-owner (holds the signing key + is attested) is connected. Idempotent. Fires onChange only on
  // an actual transition (not-present/departed -> present) so the daemon re-evaluates the signer.
  markPresent (memberKey, headRev = undefined) {
    const k = String(memberKey)
    if (headRev !== undefined) this._heads.set(k, headRev || null) // learn the peer's signed-head currency
    if (this._present.get(k) === Infinity) return // already connected — no change
    this._present.set(k, Infinity)
    this._fire()
  }

  // Record OUR OWN current signed head rev (from the sign loop) so the election knows we're caught up.
  setSelfHead (rev) { this._heads.set(this.self, rev || null) }
  // Record a co-owner's announced signed head rev (from the presence channel). Fires onChange when the
  // currency changes (a peer catching up may flip who is the elected signer → re-evaluate).
  noteHead (memberKey, rev) {
    const k = String(memberKey)
    const prev = this._heads.get(k)
    if (prev === (rev || null)) return
    this._heads.set(k, rev || null)
    this._fire()
  }
  headOf (memberKey) { const v = this._heads.get(String(memberKey)); return v === undefined ? null : v }

  // A co-owner disconnected — start its grace countdown (do NOT drop it immediately; a blip must not fail
  // the signer over). No-op for a key we never saw. Fires onChange so a departure is noticed event-driven.
  markAbsent (memberKey) {
    const k = String(memberKey)
    if (this._present.has(k) && this._present.get(k) === Infinity) { this._present.set(k, this.clock()); this._fire() }
  }

  // The co-owners eligible to sign right now: connected, or departed within graceMs. Sorted (stable pick).
  effective (now = this.clock()) {
    const out = []
    for (const [k, seen] of this._present) if (seen === Infinity || (now - seen) < this.graceMs) out.push(k)
    return out.sort()
  }

  // The elected signer = lowest-keyed effective co-owner (every connected peer computes the same one).
  // CURRENCY-AWARE (anti-freeze): when netHead is given, a co-owner is only eligible if its known signed
  // head is caught up to netHead (headRev >= netHead; revs are monotonic TID strings, null = unknown =
  // not caught up). This EXCLUDES a behind co-owner so it can't hold the lease while the forward-only
  // gate blocks it → the caught-up peer signs instead. If NONE are known-caught-up (no currency info
  // yet), fall back to the lowest-key effective co-owner — the forward-only sign gate still prevents any
  // behind sign, so the worst case is a transient no-op, never a regression. Deterministic: every peer
  // computes the same signer from the same (presence, announced-heads, netHead) → still exactly one signer.
  currentSigner (now = this.clock(), netHead = null) {
    const e = this.effective(now)
    if (!e.length) return null
    if (!netHead) return e[0]
    const caughtUp = e.filter((k) => { const h = this._heads.get(k); return h != null && h >= netHead })
    return caughtUp.length ? caughtUp[0] : e[0]
  }

  // Do WE hold the signer role right now? (Drop-in for lease.holds() / renewOrClaim() in the sign loop.)
  holds (now = this.clock(), netHead = null) { return this.currentSigner(now, netHead) === this.self }

  // Diagnostics snapshot (no writes).
  state (now = this.clock(), netHead = null) {
    return { self: this.self, signer: this.currentSigner(now, netHead), effective: this.effective(now), holds: this.holds(now, netHead), heads: Object.fromEntries(this._heads) }
  }
}