OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / failover-election.test.mjs

59 lines · javascript ref:
// Step 1(c) — TIMER-LESS FAILOVER logic, modeled as a 2-node scenario with SignerPresence + an
// injectable clock. This exercises the actual election + grace + event trigger that decide who signs
// after the current signer drops. (A live 2-node e2e is not runnable here: atproto-room is bare-only
// so a node+testnet test can't load it, and the hyperswarm testnet DHT does not connect under this
// bare — so live timing is confirmed on deploy: post → drop stortron → tabertron takes over. The
// StateBase wiring onChange→_fireWatchers→sb.watch→tick→sign is proven by composition in
// event-driven-sign.test + signer-presence's onChange.)
import '../lib/bare-globals.js'
import test from 'brittle'
import { SignerPresence } from 'atproto-room/lib/signer-presence.js'

test('timer-less failover: the survivor takes over signing after the departed signer\'s grace expires', (t) => {
  let now = 1_000_000
  const clock = () => now
  let fires = 0
  // node2's view. Co-owners: node1 (lower key 'aaaa') + self ('bbbb'). Lower-keyed node1 is the signer.
  const p = new SignerPresence({ self: 'bbbb', graceMs: 15000, clock, onChange: () => { fires++ } })

  p.markPresent('aaaa') // node1 connects
  t.is(p.currentSigner(), 'aaaa', 'lower-keyed co-owner (node1) is the elected signer')
  t.is(p.holds(), false, 'we (node2) do NOT hold the signer while node1 is present')
  const f0 = fires

  // node1 (the signer) DROPS — connection close → markAbsent. This is the event-driven failover trigger.
  p.markAbsent('aaaa')
  t.ok(fires > f0, 'markAbsent fires onChange — the EVENT that drives failover (no poll)')
  t.is(p.holds(), false, 'we do NOT take over immediately: fork-safety (node1 still in grace)')

  now += 14_000 // within the 15s grace
  t.is(p.holds(), false, 'within grace: node1 stays effective, we keep waiting (no short-partition fork)')

  // Grace expires. In prod this is NOTICED on the next write/replication/presence event (zero timer);
  // here we advance the clock and re-check — the opportunistic re-evaluation.
  now += 2_000 // total 16s > 15s grace
  t.is(p.currentSigner(), 'bbbb', 'after grace: node1 drops out of the election, WE are lowest-effective')
  t.is(p.holds(), true, 'TIMER-LESS FAILOVER: the survivor takes over signing once grace passes')

  // node1 reconnects → fail-back to it (lower key), event fires so we re-evaluate + step down.
  const f1 = fires
  p.markPresent('aaaa')
  t.ok(fires > f1, 'node1 reconnect fires onChange')
  t.is(p.holds(), false, 'node1 back → it resumes as signer (lower key); we step down (no double-signer)')
})

test('partition fork-safety: grace bounds the window, immediate-takeover would fork', (t) => {
  let now = 1_000_000
  const clock = () => now
  // Two co-owners partition from each other. Each sees ONLY itself → each would elect itself.
  const a = new SignerPresence({ self: 'aaaa', graceMs: 15000, clock }) // never saw bbbb
  const b = new SignerPresence({ self: 'bbbb', graceMs: 15000, clock }) // saw aaaa, then it dropped
  b.markPresent('aaaa'); b.markAbsent('aaaa')
  // During grace, b defers to aaaa (won't sign) → only aaaa signs → NO fork for partitions < graceMs.
  t.is(b.holds(), false, 'b defers to aaaa during grace — a short partition does NOT fork')
  t.is(a.holds(), true, 'a keeps signing (it only ever saw itself)')
  // A long partition (> grace) DOES fork — this is inherent (documented), grace only bounds it.
  now += 16_000
  t.is(b.holds(), true, 'after grace b also signs — long partitions fork (grace bounds, not prevents)')
})