OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / reconcile-retry.js

59 lines · javascript ref:
// The retry decision for byte convergence — pure, so it can be tested without booting a device.
//
// ⛔ WHY THIS IS ITS OWN MODULE. The rule it replaces lived inside a closure in `bootSwarmDevice`, where
// nothing could reach it, and it was wrong in a way no test could have caught there: the pass loop ended
// with `if (!r.fetched) break`, which treats "everything resolved" and "I tried and could not reach the
// bytes" as the same outcome. The driver is deliberately zero-polling — it runs on a StateBase advance, a
// signer-presence change, or a peer's overlay attach — so "wait for an event" meant the most recently
// converged pointer waited for an event that had already happened.
//
// Measured on the handset 2026-08-18: a post converged at 21:26, its one fetch failed, nothing retried
// for 13 minutes; at the next boot the same fetch failed once and succeeded 3 seconds later. Transient
// all along. See test/reconcile-retry.test.mjs, whose first test pins the old decision.
//
// The caller owns the timer (and must unref it — a convergence retry may never be the reason a process
// stays alive). This owns only the arithmetic and the budget.

/**
 * @param {object}  opts
 * @param {number}  opts.failed        records that could not be fetched this pass
 * @param {number}  [opts.failedBlobs] blobs that could not be fetched this pass (same class of fault:
 *                                     reconcileBytes skips `__`-prefixed keys, so blobs converge in a
 *                                     separate pass and would otherwise be the half that never retries)
 * @param {boolean} opts.fromRetry     true when this pass WAS a retry; false when an event drove it
 * @param {number}  opts.attempts      retries already spent in this run of failures
 * @param {number}  opts.delay         the delay used last time, 0 if none
 * @param {number}  [opts.baseMs]
 * @param {number}  [opts.maxMs]
 * @param {number}  [opts.maxAttempts]
 * @returns {{arm: boolean, delay: number, attempts: number, giveUp: boolean}}
 */
export function nextRetry ({
  failed = 0,
  failedBlobs = 0,
  fromRetry = false,
  attempts = 0,
  delay = 0,
  baseMs = 5000,
  maxMs = 300000,
  maxAttempts = 8
} = {}) {
  // AN EVENT IS NEW INFORMATION; A RETRY IS THE SAME INFORMATION AGAIN. Only the former earns a fresh
  // budget — otherwise a node that hit a bad patch would spend its attempts once and never try again, no
  // matter what changed afterwards (a peer arriving is exactly such a change, and it arrives as an event).
  if (!fromRetry) { attempts = 0; delay = 0 }

  if (!failed && !failedBlobs) return { arm: false, delay: 0, attempts: 0, giveUp: false }

  // Bounded. A peer that genuinely does not hold these bytes must not be asked forever — but the caller
  // is TOLD, because the defect this module exists to fix was precisely a node giving up in silence.
  if (attempts >= maxAttempts) return { arm: false, delay, attempts, giveUp: true }

  return {
    arm: true,
    delay: delay ? Math.min(delay * 2, maxMs) : baseMs,
    attempts: attempts + 1,
    giveUp: false
  }
}