OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / reconcile-sweep-yields.test.js

121 lines · javascript ref:
/**
 * Regression: the periodic reconcileAll sweep must not starve the daemon.
 *
 * The sweep walks every repo, and each repo costs a `base.update()` plus git
 * plumbing. Run back-to-back with no yield, that saturates the event loop for
 * the whole sweep and the HTTP server serves NOTHING until it finishes — in
 * production (139 repos) a request landing mid-sweep blocked for ~27s, and the
 * sweep fires every 5 minutes, so roughly 1 visit in 11 hung for 20-28s.
 *
 * `await` alone does not fix this: awaiting an already-resolved promise drains
 * the MICROtask queue but never lets timers or I/O (i.e. inbound requests) run.
 * Only handing the loop back with a macrotask does. This test therefore counts
 * timer ticks during a sweep: 0 ticks == the loop was starved.
 */

import test from 'brittle'

import { RefReconcilerManager } from '../lib/services/ref-reconciler-manager.js'

// Burn CPU synchronously, the way a real per-repo reconcile does.
function burnMs (ms) {
  const until = Date.now() + ms
  while (Date.now() < until) { /* spin */ }
}

test('reconcileAll yields to the event loop between repos', async (t) => {
  const REPOS = 40
  const BURN_MS = 2 // ~80ms of sweep total
  const names = Array.from({ length: REPOS }, (_, i) => 'repo-' + i)

  const mgr = new RefReconcilerManager({
    issueRegistry: { listRepos: () => names },
    overlay: {},              // only needs to be truthy for `enabled`
    reposDir: '/nonexistent'  // never touched: every repo is pre-populated
  })

  // Pre-populate so the sweep takes the `existing.reconcile()` branch and
  // never opens a tracker, touches disk, or hits the network.
  for (const name of names) {
    mgr._recs.set(name, { reconcile: async () => burnMs(BURN_MS) })
  }

  // A macrotask that can ONLY run if the sweep hands the loop back. This
  // stands in for an inbound HTTP request arriving mid-sweep.
  let ticks = 0
  const iv = setInterval(() => { ticks++ }, 1)

  const res = await mgr.reconcileAll()
  clearInterval(iv)

  t.is(res.scanned, REPOS, 'swept every repo')
  t.ok(ticks > 0, `event loop got control during the sweep (${ticks} ticks; 0 == starved)`)
})

/**
 * The sweep is a BACKSTOP: per-repo reconcile is already event-driven off each
 * tracker's `update`. Re-running `listRefs()` — an `await base.update()` plus a
 * full ref range-scan — across every repo every 5 minutes is therefore redundant
 * for any repo that is quiet and materialized.
 *
 * Measured on the public backend (146 repos, 0.1.219): the sweep degraded TTFB
 * from 11ms to ~450ms for ~57s of every 300s — a ~19% duty cycle, so roughly one
 * request in five. `git` subprocess count was 0 throughout, so the cost is the
 * per-tracker autobase work, not git plumbing. Yielding between repos (0.1.180)
 * bounded a single stall but not the total, because the work itself is unchanged.
 *
 * So the sweep must ask each reconciler whether it actually has anything to do.
 */
test('reconcileAll skips reconcilers that report no pending work', async (t) => {
  const calls = []
  const mgr = new RefReconcilerManager({
    issueRegistry: { listRepos: () => ['quiet', 'deferred', 'due', 'legacy'] },
    overlay: {},
    reposDir: '/nonexistent'
  })

  const rec = (name, needsPass) => ({
    reconcile: async () => { calls.push(name) },
    ...(needsPass === undefined ? {} : { needsPass: () => needsPass })
  })

  mgr._recs.set('quiet', rec('quiet', false))     // materialized + nothing new
  mgr._recs.set('deferred', rec('deferred', true)) // a ref waiting on objects
  mgr._recs.set('due', rec('due', true))           // past its backstop deadline
  mgr._recs.set('legacy', rec('legacy'))           // no needsPass -> must not be skipped

  const res = await mgr.reconcileAll()

  t.absent(calls.includes('quiet'), 'the quiet repo was NOT reconciled')
  t.ok(calls.includes('deferred'), 'a repo with deferred objects still reconciles')
  t.ok(calls.includes('due'), 'a repo past its backstop still reconciles')
  t.ok(calls.includes('legacy'), 'a reconciler without needsPass() is never skipped')
  t.is(res.skipped, 1, 'reports how many were skipped')
  t.is(res.scanned, 3, 'skipped repos are not counted as scanned')
})

test('reconcileAll still sweeps every repo when one throws', async (t) => {
  const names = ['a', 'b', 'c']
  const seen = []

  const mgr = new RefReconcilerManager({
    issueRegistry: { listRepos: () => names },
    overlay: {},
    reposDir: '/nonexistent'
  })

  for (const name of names) {
    mgr._recs.set(name, {
      reconcile: async () => {
        seen.push(name)
        if (name === 'b') throw new Error('boom')
      }
    })
  }

  const res = await mgr.reconcileAll()

  t.alike(seen, names, 'every repo was visited despite the failure')
  t.is(res.scanned, 2, 'the throwing repo is not counted as scanned')
})