OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / ref-reconciler-debounce.test.js

127 lines · javascript ref:
/**
 * Regression: the EVENT-DRIVEN per-repo reconcile must coalesce and must hand
 * the event loop back.
 *
 * `reconcileAll` (the periodic sweep) got a macrotask yield in 0.1.180 because
 * running N repos back-to-back starved the daemon for the whole sweep. The
 * event-driven path never got the same treatment, and its justification in
 * ref-reconciler-manager.js was that per-repo reconcile is "already
 * event-driven ... so latency here is free". That holds on an ORIGIN, whose
 * trackers only advance when someone pushes.
 *
 * It does NOT hold on a MIRROR. There every tracker is a replication target, so
 * during initial sync (or a re-drain after an unclean stop) all N trackers
 * advance continuously. Each reconcile costs a `listRefs()` — `await
 * base.update()` plus a full ref range-scan — and undebounced that re-runs per
 * replication chunk across every repo. Measured on stortron: a full core burned
 * indefinitely, HTTP never got an accept(), and exited git children were never
 * reaped (SIGCHLD needs the macrotask phase).
 *
 * `await` alone cannot fix this: awaiting an already-resolved promise drains the
 * MICROtask queue only. The debounce timer is the macrotask handoff.
 */

import test from 'brittle'

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

// Minimal tracker stand-in: the reconcile path needs `base.on/off` plus
// `listRefs()`. With no refs and no `getMeta`, `_reconcileOnce` touches no git
// and no disk, so these tests exercise scheduling only.
function makeFakeTracker () {
  const listeners = []
  return {
    base: {
      on (ev, fn) { if (ev === 'update') listeners.push(fn) },
      off (ev, fn) {
        const i = listeners.indexOf(fn)
        if (i !== -1) listeners.splice(i, 1)
      },
      emit () { for (const fn of listeners.slice()) fn() }
    },
    listenerCount: () => listeners.length,
    async listRefs () { return [] }
  }
}

test('start() reconciles immediately — the debounce never delays first materialization', async (t) => {
  const tracker = makeFakeTracker()
  const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 1000 })

  let passes = 0
  rec._reconcileOnce = async () => { passes++ }

  await rec.start()
  t.is(passes, 1, 'first pass ran synchronously with start(), not after debounceMs')

  await rec.stop()
})

test('a burst of tracker updates coalesces into ONE reconcile', async (t) => {
  const tracker = makeFakeTracker()
  const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 30 })

  let passes = 0
  rec._reconcileOnce = async () => { passes++ }

  await rec.start()
  t.is(passes, 1, 'baseline: start() pass')

  // A replication burst: what a mirror sees while a tracker catches up.
  for (let i = 0; i < 50; i++) tracker.base.emit('update')

  t.is(passes, 1, 'no reconcile fires synchronously from the update event')

  await new Promise((resolve) => setTimeout(resolve, 90))

  t.is(passes, 2, '50 updates collapsed into exactly one additional pass')

  await rec.stop()
})

test('event-driven reconciles run as macrotasks, so timers still fire', async (t) => {
  const tracker = makeFakeTracker()
  const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 5 })

  // Each pass burns CPU synchronously, the way a real reconcile does.
  rec._reconcileOnce = async () => {
    const until = Date.now() + 3
    while (Date.now() < until) { /* spin */ }
  }

  await rec.start()

  let ticks = 0
  const iv = setInterval(() => { ticks++ }, 1)
  t.teardown(() => clearInterval(iv))

  for (let i = 0; i < 15; i++) {
    tracker.base.emit('update')
    await new Promise((resolve) => setTimeout(resolve, 7))
  }

  // 0 ticks would mean the loop never came back to the timer queue.
  t.ok(ticks > 0, 'timers ran between event-driven reconciles (loop not starved)')

  await rec.stop()
})

test('stop() cancels a pending debounced pass and detaches the listener', async (t) => {
  const tracker = makeFakeTracker()
  const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 25 })

  let passes = 0
  rec._reconcileOnce = async () => { passes++ }

  await rec.start()
  t.is(passes, 1)

  tracker.base.emit('update')   // schedules a pass
  await rec.stop()              // must cancel it

  await new Promise((resolve) => setTimeout(resolve, 60))

  t.is(passes, 1, 'no reconcile fired against a tracker the caller is closing')
  t.is(tracker.listenerCount(), 0, 'update listener detached')
})