// 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 }