// When a byte-convergence pass FAILS, what happens next.
//
// ⛔ THE CONTROL BELOW IS THE BUG. The reconcile driver used to end each pass with
//
// if (!r || r.skipped || !r.more || !r.fetched) break // done, or no progress → wait for an event
//
// which collapses two opposite outcomes: "everything resolved" and "I tried and could not reach the
// bytes". Both stopped, and neither scheduled anything. Since the driver is deliberately zero-polling —
// it runs on a StateBase advance, a presence change, or a peer's overlay attach — the retry for the MOST
// RECENTLY converged pointer never came: the advance that would have triggered it already happened.
//
// Measured on the handset 2026-08-18: a post converged at 21:26, its single fetch failed, and nothing
// retried for 13 minutes. On the next boot the same fetch failed once and succeeded 3 seconds later, so
// the miss was transient the whole time. Downstream the appview correctly refuses to author bytes hpp
// owns, so the record indexed but could not render — every layer correct, the post invisible.
//
// So the first test pins the old decision, and the rest assert the policy that replaces it.
//
// Run: bare test/reconcile-retry.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import { nextRetry } from '../lib/reconcile-retry.js'
const P = { baseMs: 5000, maxMs: 300000, maxAttempts: 8 }
const clean = { failed: 0, fromRetry: false, attempts: 0, delay: 0, ...P }
test('control: the old rule could not tell "resolved" from "could not reach it"', (t) => {
// Both of these ended the pass identically, and neither armed anything.
const resolvedEverything = { fetched: 3, failed: 0 }
const couldNotReachIt = { fetched: 0, failed: 1 }
const oldDecision = (r) => (!r.fetched ? 'stop' : 'continue')
t.is(oldDecision(couldNotReachIt), 'stop', 'a failed fetch stopped the loop')
t.is(oldDecision({ ...resolvedEverything, fetched: 0 }), 'stop', '…exactly as a finished one did')
})
test('a clean pass arms nothing and holds steady state at zero timers', (t) => {
const d = nextRetry(clean)
t.is(d.arm, false)
t.is(d.attempts, 0, 'budget reset')
t.is(d.delay, 0)
t.is(d.giveUp, false)
})
test('a failure after an event arms the first retry at the base delay', (t) => {
const d = nextRetry({ ...clean, failed: 1 })
t.is(d.arm, true)
t.is(d.delay, 5000)
t.is(d.attempts, 1)
})
test('a failed retry backs off exponentially', (t) => {
let d = nextRetry({ ...clean, failed: 1 })
const seen = [d.delay]
for (let i = 0; i < 4; i++) {
d = nextRetry({ ...P, failed: 1, fromRetry: true, attempts: d.attempts, delay: d.delay })
seen.push(d.delay)
}
t.alike(seen, [5000, 10000, 20000, 40000, 80000], 'doubling, not a fixed interval')
})
test('backoff is capped — an absent peer is never hammered, and never waits forever', (t) => {
let d = { attempts: 3, delay: 200000 }
d = nextRetry({ ...P, failed: 1, fromRetry: true, attempts: d.attempts, delay: d.delay })
t.is(d.delay, 300000, 'capped at maxMs')
d = nextRetry({ ...P, failed: 1, fromRetry: true, attempts: d.attempts, delay: d.delay })
t.is(d.delay, 300000, 'stays at the cap rather than growing')
})
test('the budget is bounded, and exhausting it is SAID rather than silent', (t) => {
const d = nextRetry({ ...P, failed: 1, fromRetry: true, attempts: 8, delay: 300000 })
t.is(d.arm, false, 'no further timer')
t.is(d.giveUp, true, 'and the caller is told, because giving up silently is the original defect')
})
test('an EVENT after exhaustion earns a fresh budget', (t) => {
// A retry is the same information again; an event is new information. Without this a node that had a
// bad patch would burn its 8 attempts once and never try again, however much the world changed.
const d = nextRetry({ ...P, failed: 1, fromRetry: false, attempts: 8, delay: 300000 })
t.is(d.arm, true)
t.is(d.attempts, 1, 'counted from scratch')
t.is(d.delay, 5000, 'and backed off from scratch')
})
test('a clean pass after failures disarms and resets, so the next incident gets the full budget', (t) => {
const d = nextRetry({ ...P, failed: 0, fromRetry: true, attempts: 5, delay: 80000 })
t.is(d.arm, false)
t.is(d.attempts, 0)
t.is(d.delay, 0)
})
test('blob failures count the same as record failures', (t) => {
// reconcileBytes skips `__`-prefixed keys, so blobs converge through a separate pass. A blob that
// cannot be fetched is the same class of fault and must not be the one that goes unretried.
t.is(nextRetry({ ...clean, failed: 0, failedBlobs: 2 }).arm, true)
t.is(nextRetry({ ...clean, failed: 0, failedBlobs: 0 }).arm, false)
})