OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / eviction.test.js

418 lines · javascript ref:
/**
 * Phase 0 + A tests: StorageAccounting + EvictionManager + tombstones.
 *
 * Context (2026-06-11): fleet restarts let replication-repair adopt the
 * union of all catalogs (5/5 replicas at floor 2) while the storage
 * guard compared against the always-zero seeder.totalBytesStored — disks
 * filled to 100%. Phase 0 measures real bytes; Phase A sheds surplus
 * replicas under pressure without ever touching archive/custody entries
 * or breaching the floor, and tombstones stop resurrection.
 */

import test from 'brittle'
import { mkdtemp } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { StorageAccounting } from 'p2p-hiverelay/core/relay-node/storage-accounting.js'
import { EvictionManager, xorDistance } from 'p2p-hiverelay/core/relay-node/eviction.js'
import { AppRegistry } from 'p2p-hiverelay/core/app-registry.js'

// ─── fixtures ──────────────────────────────────────────────────────

function fakeDrive (metaBytes, blobBytes) {
  const core = (n) => ({
    info: async (opts) => ({
      storage: opts && opts.storage ? { oplog: 10, tree: 20, blocks: n, bitfield: 5 } : null
    })
  })
  return { core: core(metaBytes), blobs: blobBytes != null ? { core: core(blobBytes) } : null }
}

function fakeRegistry (entries) {
  const map = new Map(entries)
  const evicted = new Map()
  return {
    get: (k) => map.get(k),
    keys: () => map.keys(),
    entries: () => map.entries(),
    delete: (k) => map.delete(k),
    markEvicted: (k, at) => evicted.set(k, at || Date.now()),
    isEvicted: (k) => evicted.has(k),
    clearEvicted: (k) => evicted.delete(k),
    _evicted: evicted,
    _map: map
  }
}

const NOW = Date.UTC(2026, 5, 11, 12, 0, 0)
const OLD = NOW - 10 * 24 * 60 * 60 * 1000 // 10 days — past minAge

// 64-hex keys engineered so xor-distance ranks are predictable.
const APP = 'aa'.repeat(32)
const MY_PK = '00'.repeat(32) // distance from APP: aa... (far)
const NEAR_PK = 'aa'.repeat(31) + 'ab' // distance ~ 00..01 (near)
const MID_PK = '55'.repeat(32) // distance ff.. (farthest)

function makeEviction (over = {}) {
  const entries = over.entries || [[APP, { durability: 0, custodyIntentId: null, addedAt: OLD, drive: fakeDrive(100, 1000) }]]
  const registry = over.registry || fakeRegistry(entries)
  const health = over.health || new Map([[APP, { current: 4, target: 2 }]])
  const holders = over.holders || [MY_PK, NEAR_PK, MID_PK, 'bb'.repeat(32)]
  const unseeded = []
  const purged = []
  const ev = new EvictionManager({
    appRegistry: registry,
    seedingRegistry: { getRelaysForApp: async () => holders.map(pk => ({ relayPubkey: pk })) },
    storageAccounting: over.accounting || {
      getBytes: () => 5000,
      measure: async () => 5000,
      getSummary: () => ({ totalBytes: 5000 })
    },
    // Default 85%: above diskPressurePct (80) so sweeps run, below
    // rankBypassPct (92) so stagger semantics stay testable.
    diskMonitor: { getInfo: () => (over.disk || { usedPct: 85, totalBytes: 20e9 }) },
    getReplicationHealth: () => health,
    myPubkeyHex: over.myPubkey || MY_PK,
    getStorageCap: over.getStorageCap,
    unseed: async (k) => { unseeded.push(k); registry._map && registry._map.delete(k) },
    purgeDrive: async (k) => { purged.push(k) }
  }, { enabled: true, minAgeMs: 3 * 24 * 60 * 60 * 1000, ...over.config })
  return { ev, registry, unseeded, purged }
}

// ─── StorageAccounting ─────────────────────────────────────────────

test('accounting: measures meta+blob file bytes per entry and totals', async (t) => {
  const reg = fakeRegistry([
    ['k1', { drive: fakeDrive(1000, 5000) }],
    ['k2', { drive: fakeDrive(200, null) }]
  ])
  const acc = new StorageAccounting({ appRegistry: reg, tickMs: 60_000, batchSize: 10 })
  await acc._tick()
  // k1: meta(10+20+1000+5) + blobs(10+20+5000+5) = 6070 ; k2: 235
  t.is(acc.getBytes('k1'), 6070, 'meta+blob summed from file stats')
  t.is(acc.getBytes('k2'), 235, 'metadata-only drive measured')
  t.is(acc.getSummary().totalBytes, 6305, 'summary totals all measured entries')
  acc.stop()
})

test('accounting: paces in batches and prunes removed entries', async (t) => {
  const reg = fakeRegistry([['a', { drive: fakeDrive(1, 1) }], ['b', { drive: fakeDrive(2, 2) }], ['c', { drive: fakeDrive(3, 3) }]])
  const acc = new StorageAccounting({ appRegistry: reg, tickMs: 60_000, batchSize: 2 })
  await acc._tick()
  t.is(acc.getSummary().measuredEntries, 2, 'first tick measures only batchSize entries')
  await acc._tick()
  t.is(acc.getSummary().measuredEntries, 3, 'second tick completes the pass')
  reg._map.delete('b')
  await acc._tick() // wraps: cursor reset + prune
  t.absent([...reg._map.keys()].includes('b'), 'entry removed from registry')
  await acc._tick(); await acc._tick()
  t.is(acc.getBytes('b'), null, 'cache pruned for removed entry')
  acc.stop()
})

// ─── Designated-cap shedding (v0.23.0 storage designation) ─────────

test('eviction: over the DESIGNATED cap sheds even below whole-disk pressure', async (t) => {
  // Physical disk only 40% full — far below diskPressurePct (80). Normally the
  // sweep skips 'below-pressure' and sheds nothing. But our own measured usage
  // (5000) exceeds the operator's designated cap (1000), so the cap gate must
  // run the sweep AND bypass rank-deferral to shrink us toward the cap.
  const { ev, unseeded } = makeEviction({
    disk: { usedPct: 40, totalBytes: 20e9 },
    accounting: { getBytes: () => 5000, measure: async () => 5000, getSummary: () => ({ totalBytes: 5000 }) },
    getStorageCap: () => 1000
  })
  const res = await ev.sweep({ now: NOW })
  t.absent(res.skipped, 'did not skip below-pressure — we are over the designated cap')
  t.is(unseeded.length, 1, 'shed the surplus replica')
  t.is(unseeded[0], APP)
})

test('eviction: UNDER the designated cap + below disk pressure still skips (no needless shedding)', async (t) => {
  const { ev, unseeded } = makeEviction({
    disk: { usedPct: 40, totalBytes: 20e9 },
    accounting: { getBytes: () => 500, measure: async () => 500, getSummary: () => ({ totalBytes: 500 }) },
    getStorageCap: () => 1000
  })
  const res = await ev.sweep({ now: NOW })
  t.is(res.skipped, 'below-pressure', 'under cap + calm disk → nothing shed')
  t.is(unseeded.length, 0)
})

test('eviction: an unset cap (0) preserves the pure disk-pressure behavior', async (t) => {
  const { ev, unseeded } = makeEviction({
    disk: { usedPct: 40, totalBytes: 20e9 },
    accounting: { getBytes: () => 5000, measure: async () => 5000, getSummary: () => ({ totalBytes: 5000 }) },
    getStorageCap: () => 0
  })
  const res = await ev.sweep({ now: NOW })
  t.is(res.skipped, 'below-pressure', 'no cap set → calm disk means no shedding, as before')
  t.is(unseeded.length, 0)
})

// ─── EvictionManager policy ────────────────────────────────────────

test('eviction: below disk pressure -> no-op', async (t) => {
  const { ev, unseeded } = makeEviction({ disk: { usedPct: 50, totalBytes: 20e9 } })
  const res = await ev.sweep({ now: NOW })
  t.is(res.skipped, 'below-pressure', 'gated on pressure')
  t.is(unseeded.length, 0, 'nothing unseeded')
})

test('eviction: archive tier, custody-bound, young, and census-less entries are untouchable', async (t) => {
  const entries = [
    ['11'.repeat(32), { durability: 1, addedAt: OLD, drive: fakeDrive(1, 1) }], // archive pin
    ['22'.repeat(32), { durability: 0, custodyIntentId: 'intent-x', addedAt: OLD, drive: fakeDrive(1, 1) }],
    ['33'.repeat(32), { durability: 0, addedAt: NOW - 1000, drive: fakeDrive(1, 1) }], // too young
    ['44'.repeat(32), { durability: 0, addedAt: OLD, drive: fakeDrive(1, 1) }] // no census row
  ]
  const health = new Map([
    ['11'.repeat(32), { current: 5, target: 2 }],
    ['22'.repeat(32), { current: 5, target: 2 }],
    ['33'.repeat(32), { current: 5, target: 2 }]
    // 44.. deliberately absent
  ])
  const { ev, unseeded } = makeEviction({ entries, health })
  const res = await ev.sweep({ now: NOW })
  t.is(res.candidates, 0, 'no candidates')
  t.is(unseeded.length, 0, 'nothing evicted')
})

test('eviction: floor math — only sheds when remaining ≥ target + margin', async (t) => {
  // current=3, target=2, margin=1: we leave -> remaining 2 < 3 -> skip
  let { ev, unseeded } = makeEviction({ health: new Map([[APP, { current: 3, target: 2 }]]) })
  await ev.sweep({ now: NOW })
  t.is(unseeded.length, 0, 'current=3 keeps the entry (would breach target+margin)')

  // current=4: remaining 3 ≥ 3 -> eligible (we are MID-distance? ensure rank passes by making us farthest)
  ;({ ev, unseeded } = makeEviction({
    myPubkey: MID_PK,
    holders: [MID_PK, NEAR_PK, MY_PK, 'bb'.repeat(32)],
    health: new Map([[APP, { current: 4, target: 2 }]])
  }))
  await ev.sweep({ now: NOW })
  t.is(unseeded.length, 1, 'current=4 sheds one copy')
  t.is(unseeded[0], APP, 'the surplus entry was evicted')
})

test('eviction: deterministic stagger — only the farthest K holders shed', async (t) => {
  // surplus K = remaining - (target+margin) + 1 = (4-1) - 3 + 1 = 1 -> only THE farthest sheds.
  // MID_PK (0x55..) xor APP (0xaa..) = 0xff.. = farthest; MY_PK (0x00) -> 0xaa.. = second.
  const holders = [MY_PK, NEAR_PK, MID_PK]
  let { ev, unseeded } = makeEviction({ myPubkey: MY_PK, holders, health: new Map([[APP, { current: 4, target: 2 }]]) })
  await ev.sweep({ now: NOW })
  t.is(unseeded.length, 0, 'second-farthest holder defers')

  ;({ ev, unseeded } = makeEviction({ myPubkey: MID_PK, holders, health: new Map([[APP, { current: 4, target: 2 }]]) }))
  await ev.sweep({ now: NOW })
  t.is(unseeded.length, 1, 'farthest holder sheds')
})

test('eviction: evicts biggest-first, respects maxEvictionsPerSweep + resumePct, tombstones each', async (t) => {
  const k1 = 'aa'.repeat(32); const k2 = 'ab' + 'aa'.repeat(31); const k3 = 'ac' + 'aa'.repeat(31)
  const entries = [
    [k1, { durability: 0, addedAt: OLD, drive: fakeDrive(1, 1) }],
    [k2, { durability: 0, addedAt: OLD, drive: fakeDrive(1, 1) }],
    [k3, { durability: 0, addedAt: OLD, drive: fakeDrive(1, 1) }]
  ]
  const sizes = { [k1]: 1e9, [k2]: 5e9, [k3]: 3e9 }
  const health = new Map(Object.keys(sizes).map(k => [k, { current: 5, target: 2 }]))
  const { ev, registry, unseeded, purged } = makeEviction({
    entries,
    health,
    myPubkey: MID_PK,
    holders: [MID_PK], // sole counted holder unrealistic but rank-passes; remaining=4≥3
    accounting: { getBytes: (k) => sizes[k], measure: async (k) => sizes[k], getSummary: () => ({ totalBytes: 9e9 }) },
    disk: { usedPct: 92, totalBytes: 20e9 },
    // resumePct low enough that the per-sweep cap is what binds here —
    // the resumePct early-stop has its own dedicated test below.
    config: { maxEvictionsPerSweep: 2, resumePct: 40 }
  })
  const res = await ev.sweep({ now: NOW })
  t.is(unseeded.length, 2, 'capped at maxEvictionsPerSweep')
  t.alike(unseeded, [k2, k3], 'biggest entries shed first')
  t.alike(purged, unseeded, 'every eviction purges the drive cores')
  t.ok(registry.isEvicted(k2) && registry.isEvicted(k3), 'tombstoned')
  t.absent(registry.isEvicted(k1), 'survivor not tombstoned')
  t.is(res.freedBytes, 8e9, 'freed bytes accounted')
  t.is(ev.getSummary().evictedTotal, 2, 'summary counts evictions')
})

test('eviction: resumePct stops early once projection clears pressure', async (t) => {
  const k1 = 'aa'.repeat(32); const k2 = 'ab' + 'aa'.repeat(31)
  const entries = [
    [k1, { durability: 0, addedAt: OLD, drive: fakeDrive(1, 1) }],
    [k2, { durability: 0, addedAt: OLD, drive: fakeDrive(1, 1) }]
  ]
  const health = new Map([[k1, { current: 5, target: 2 }], [k2, { current: 5, target: 2 }]])
  // 20 GB volume at 92%; first eviction frees 6 GB -> projected 62% ≤ 70 -> stop.
  const { ev, unseeded } = makeEviction({
    entries,
    health,
    myPubkey: MID_PK,
    holders: [MID_PK],
    accounting: { getBytes: () => 6e9, measure: async () => 6e9, getSummary: () => ({ totalBytes: 12e9 }) },
    config: { maxEvictionsPerSweep: 20, resumePct: 70 }
  })
  await ev.sweep({ now: NOW })
  t.is(unseeded.length, 1, 'stopped after pressure projected below resumePct')
})

test('eviction: rank bypass under critical disk — politeness must not starve a full box', async (t) => {
  // Same fixture as the stagger-defer case (we are second-farthest, so
  // at normal pressure we defer) — but at 95% disk the rank check is
  // bypassed and the entry sheds. Floor + margin still hold: this same
  // fixture with current=3 stays blocked (covered by the floor test).
  const holders = [MY_PK, NEAR_PK, MID_PK]
  const { ev, unseeded } = makeEviction({
    myPubkey: MY_PK,
    holders,
    health: new Map([[APP, { current: 4, target: 2 }]]),
    disk: { usedPct: 95, totalBytes: 20e9 }
  })
  const res = await ev.sweep({ now: NOW })
  t.is(unseeded.length, 1, 'second-farthest holder sheds at critical disk')
  t.is(res.skips.rank, 0, 'no rank deferral above rankBypassPct')
})

test('eviction: census fallback — acceptance records stand in for missing health rows', async (t) => {
  // No health row at all; 4 acceptance records; targetFloor 2 + margin 1:
  // remaining 3 ≥ 3 -> evictable by the farthest holder.
  const holders = [MID_PK, NEAR_PK, MY_PK, 'bb'.repeat(32)]
  let { ev, unseeded } = makeEviction({
    myPubkey: MID_PK,
    holders,
    health: new Map(), // <- the 417-of-961 case from the utah canary
    config: { targetFloor: 2 }
  })
  let res = await ev.sweep({ now: NOW })
  t.is(res.skips.censusFallback, 1, 'fallback census used (and counted)')
  t.is(unseeded.length, 1, 'evicted via acceptance-record census')

  // Same but only 3 holders: remaining 2 < 3 -> floor blocks it.
  ;({ ev, unseeded } = makeEviction({
    myPubkey: MID_PK,
    holders: [MID_PK, NEAR_PK, MY_PK],
    health: new Map(),
    config: { targetFloor: 2 }
  }))
  res = await ev.sweep({ now: NOW })
  t.is(res.skips.floor, 1, 'floor still binds under fallback census')
  t.is(unseeded.length, 0, 'nothing evicted')

  // No acceptance records either -> never evict blind.
  ;({ ev, unseeded } = makeEviction({ myPubkey: MID_PK, holders: [], health: new Map(), config: { targetFloor: 2 } }))
  res = await ev.sweep({ now: NOW })
  t.is(res.skips.noCensus, 1, 'entries with zero census stay untouchable')
  t.is(unseeded.length, 0, 'nothing evicted blind')
})

test('assertPurgable: sacred entries refuse even operator-initiated purges', async (t) => {
  const { assertPurgable } = await import('p2p-hiverelay/core/relay-node/eviction.js')
  t.exception(() => assertPurgable(null), /not found/, 'missing entry')
  t.exception(() => assertPurgable({ durability: 1 }), /archive-tier/, 'archive pin refused')
  t.exception(() => assertPurgable({ durability: 0, custodyIntentId: 'x' }), /custody-bound/, 'custody refused')
  t.execution(() => assertPurgable({ durability: 0, custodyIntentId: null }), 'plain entry purgable')
})

test('timeouts: a wedged core cannot freeze accounting or the sweep (v0.15.6)', async (t) => {
  const never = new Promise(() => {}) // a corrupt core's hung await
  // Accounting: info() never resolves -> measure returns 0 within the timeout.
  const reg = fakeRegistry([['k1', { drive: { core: { info: () => never }, blobs: null } }]])
  const acc = new StorageAccounting({ appRegistry: reg, tickMs: 60_000, batchSize: 5, infoTimeoutMs: 60 })
  const t0 = Date.now()
  await acc._tick()
  t.ok(Date.now() - t0 < 2_000, 'tick completed despite hung info()')
  t.is(acc.getBytes('k1'), 0, 'wedged core measured as 0, not frozen')
  acc.stop()

  // Sweep: unseed hangs -> evict-failed, sweep completes.
  const { ev, registry } = makeEviction({
    myPubkey: MID_PK,
    holders: [MID_PK],
    health: new Map([[APP, { current: 5, target: 2 }]]),
    config: { unseedTimeoutMs: 60 }
  })
  // Replace unseed with a hang
  ev.deps.unseed = () => never
  const fails = []
  ev.on('evict-failed', (e) => fails.push(e))
  const res = await ev.sweep({ now: NOW })
  t.is(res.candidates, 1, 'candidate found')
  t.is(res.evicted.length, 0, 'nothing evicted')
  t.is(fails.length, 1, 'evict-failed emitted on unseed timeout')
  t.is(fails[0].error, 'unseed timeout', 'reason carried')
  t.absent(registry.isEvicted(APP), 'no tombstone when unseed never completed')
})

test('purgeDriveCores: corrupt drive header falls back to meta-core purge', async (t) => {
  const { purgeDriveCores } = await import('p2p-hiverelay/core/relay-node/eviction.js')
  // store.session() -> Hyperdrive will be constructed but ready() throws
  // DECODING_ERROR (we simulate by having store.session return a broken
  // proxy that Hyperdrive constructor accepts but ready rejects on).
  // Instead of fighting Hyperdrive internals, simulate via the meta-core
  // path: a store whose session() drive explodes and whose get() core
  // purges cleanly.
  let metaPurged = false
  const store = {
    session: () => { throw new Error('DECODING_ERROR: Decoding error') }, // drive path dies immediately
    get: () => ({
      ready: async () => {},
      purge: async () => { metaPurged = true },
      close: async () => {}
    })
  }
  const method = await purgeDriveCores(store, 'aa'.repeat(32), { purgeTimeoutMs: 500 })
  t.is(method, 'meta-only', 'fell back to meta-core purge')
  t.ok(metaPurged, 'meta core actually purged')
})

test('purgeDriveCores: both paths failing throws PURGE_FAILED', async (t) => {
  const { purgeDriveCores } = await import('p2p-hiverelay/core/relay-node/eviction.js')
  const never = new Promise(() => {})
  const store = {
    session: () => { throw new Error('DECODING_ERROR') },
    get: () => ({ ready: () => never, purge: () => never, close: async () => {} })
  }
  await t.exception(
    () => purgeDriveCores(store, 'aa'.repeat(32), { purgeTimeoutMs: 60 }),
    /purge failed/,
    'PURGE_FAILED after both paths'
  )
})

// ─── xorDistance ───────────────────────────────────────────────────

test('xorDistance: bytewise xor, stable and symmetric', (t) => {
  const d1 = xorDistance('00'.repeat(32), 'aa'.repeat(32))
  const d2 = xorDistance('aa'.repeat(32), '00'.repeat(32))
  t.is(d1.toString('hex'), 'aa'.repeat(32), 'xor of 00 and aa is aa')
  t.is(d1.toString('hex'), d2.toString('hex'), 'symmetric')
})

// ─── AppRegistry tombstone persistence ─────────────────────────────

test('tombstones: survive a registry reload (sidecar, atomic)', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'evict-reg-'))
  const reg1 = new AppRegistry(dir)
  reg1.markEvicted('cc'.repeat(32), NOW)
  reg1.markEvicted('dd'.repeat(32), NOW + 1)
  await reg1._evictedPersistTail // let the atomic write land
  const { readdir } = await import('fs/promises')
  const files = await readdir(dir)
  t.ok(files.includes('evicted.json'), 'sidecar written')
  t.absent(files.some(f => f.endsWith('.tmp')), 'no tmp orphan')

  const reg2 = new AppRegistry(dir)
  await reg2.load()
  t.ok(reg2.isEvicted('cc'.repeat(32)), 'tombstone survived reload')
  t.ok(reg2.isEvicted('dd'.repeat(32)), 'both survived')
  reg2.clearEvicted('cc'.repeat(32))
  await reg2._evictedPersistTail
  const reg3 = new AppRegistry(dir)
  await reg3.load()
  t.absent(reg3.isEvicted('cc'.repeat(32)), 'clearEvicted persisted (under-floor re-adopt path)')
  t.ok(reg3.isEvicted('dd'.repeat(32)), 'other tombstone intact')
})