OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / lib / lease.js

91 lines · javascript ref:
// Lease — single active-signer election over the shared intent log.
//
// The canonical atproto chain is append-only, so exactly ONE device may extend it at a time.
// The lease is a reserved `__lease__` record naming the current holder + a monotonic epoch.
// Devices claim/renew by writing it; the autobase linearizes concurrent claims into ONE
// deterministic winner (highest epoch / last in linear order). Expiry and renewal are decided
// OUT-OF-APPLY against wall-clock (legal there — the signer is an out-of-apply consumer, exactly
// like StateBaseV3's promotion timer); the `apply` only ever stores the record, staying
// deterministic. On holder failure the lease lapses (TTL) and another device claims epoch+1 and
// continues the chain from the last signed head.
//
// `statebase`: the KV intent-log (StateBaseV3 or compatible). `__lease__` has no "/", so RepoBase
// treats it as reserved and never surfaces it as a repo record.

const LEASE_KEY = '__lease__'

export class Lease {
  constructor (statebase, { self, ttlMs = 15000 } = {}) {
    if (!statebase || typeof statebase.set !== 'function') throw new Error('Lease: a statebase is required')
    if (!self) throw new Error('Lease: self (this device id) required')
    this.sb = statebase
    this.self = String(self)
    this.ttlMs = ttlMs
  }

  // The current lease record from the linearized view, or null.
  //
  // NON-BLOCKING read (Phase-2 #6): use the statebase's getNow snapshot when available. current()
  // is the FIRST call the sign loop makes every tick (renewOrClaim → renew/claim → current). If it
  // went through the blocking get()→base.update(), a single stalled drain on an un-converged replica
  // froze the ENTIRE sign loop — so it never reached the re-admission doorbell (the live tabertron
  // hang: get-state over UDS hung here). The lease is wall-clock-TTL + "never steal a live lease",
  // so a few-ms-stale snapshot is safe (the autobase still linearizes concurrent claims to one
  // winner). Falls back to get() for injected substrates without getNow.
  async current () {
    const v = typeof this.sb.getNow === 'function' ? await this.sb.getNow(LEASE_KEY) : await this.sb.get(LEASE_KEY)
    return v && v.holder ? v : null
  }

  // Claim the lease: write self as holder at epoch = (seen epoch)+1 — but NEVER steal a LIVE lease
  // from another holder (that was the split-brain bug: a booting device grabbed the lease before it
  // had replicated the current holder's still-valid lease). Only claim if the lease is FREE
  // (unheld/expired) or already ours. Returns the new epoch, or null if a live other holds it.
  async claim (now = Date.now()) {
    const cur = await this.current()
    if (cur && cur.holder !== this.self && (now - (cur.ts || 0)) < this.ttlMs) return null // live other holder
    const epoch = (cur && Number.isInteger(cur.epoch) ? cur.epoch : 0) + 1
    await this.sb.set(LEASE_KEY, { holder: this.self, epoch, ts: now })
    return epoch
  }

  // The safe per-tick lease op: renew if we hold it, else claim ONLY if free (TTL-lapsed) — never
  // steal a live lease. Returns true iff we hold the lease afterward. This is the whole failover
  // model: the primary renews every tick; a standby takes over ONLY after the primary's lease has
  // lapsed (no primary online to renew it). Read against the linearized view + wall-clock TTL.
  async renewOrClaim (now = Date.now()) {
    if (await this.renew(now)) return true
    return (await this.claim(now)) !== null
  }

  // Renew our hold (bumps ts). No-op (returns false) if we're not the current holder.
  //
  // WRITE-GATED: only actually re-write the lease once it is past HALF its TTL. The sign loop calls this
  // every ~2s, but a fresh 15s lease does not need re-writing 7× before it could lapse. Renewing every tick
  // appended a new `__lease__` op to the append-only intent log EVERY tick forever (ts:now defeats value
  // dedup) — an unbounded at-rest storage leak (the ~25KB/s-at-rest we traced). Gating at ttlMs/2 keeps
  // `now - ts` under ~ttlMs/2 + one tick (≈9.5s ≪ 15s), so no standby ever claims while we are alive, and
  // failover stays within ~1.5×TTL. We still return true (we DO hold the lease) when we skip the write.
  async renew (now = Date.now()) {
    const cur = await this.current()
    if (!cur || cur.holder !== this.self) return false
    if ((now - (cur.ts || 0)) < this.ttlMs / 2) return true // still fresh — hold stands, no write needed
    await this.sb.set(LEASE_KEY, { holder: this.self, epoch: cur.epoch, ts: now })
    return true
  }

  // Do WE currently hold a live (un-expired) lease? Out-of-apply wall-clock TTL check.
  async holds (now = Date.now()) {
    const cur = await this.current()
    return !!cur && cur.holder === this.self && (now - (cur.ts || 0)) < this.ttlMs
  }

  // Is the lease free to (re)claim — unheld or expired?
  async isFree (now = Date.now()) {
    const cur = await this.current()
    return !cur || (now - (cur.ts || 0)) >= this.ttlMs
  }
}

export { LEASE_KEY }