OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / repo-ref-activity.test.js

206 lines · javascript ref:
// Ordering /repos on a REPLICATED signal instead of a local file mtime.
//
// THE BUG. sortReposByRecency ordered on `lastUpdated`, which hyper-sync
// derives from the mtime of HEAD / packed-refs / refs/** on the local disk
// (hyper-sync/lib/repos.js). That is a per-machine number, so the two
// load-balanced backends of 7rnx.net/git could not agree by construction.
// Measured 2026-09-05: the same 135 repos on both, and all 135 with a
// different lastUpdated — 67 differing by more than an hour. Whichever
// backend the LB picked decided the front page's order.
//
// Three independent ways it drifts:
//   1. clone time. A repo nobody has pushed to since import carries the time
//      THAT host first fetched it (2026-06-03 on tabertron, 2026-04-21 on
//      stortron for one bulk-import cohort).
//   2. staleness. See mirror-pull-refresh.test.js.
//   3. self-inflicted. The 5-minute reconcileAll sweep force-writes refs from
//      the tracker view onto disk, bumping the mtime of a repo nothing
//      happened to. ifsrenderer-linux went from rank 20 to rank 1 on stortron
//      that way, with refs/heads/linux-port stamped at the exact second of the
//      sweep.
//
// THE FIX. The per-repo tracker's ref-view already carries a `timestamp` per
// ref, stamped by the writer that ACCEPTED the push and replicated verbatim
// to every peer. RefReconciler reads that view on every pass anyway, so
// recording max(timestamp) costs no extra autobase work, and every backend
// that replicates the tracker derives the identical number.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import {
  recordRefActivity, getRefActivity, mergeRefActivity,
  configureRefActivity, flushRefActivity, resetRefActivityForTests
} from '../lib/services/repo-ref-activity.js'
import { sortReposByRecency } from '../lib/web/pages/repos.js'

function tmpdir (t) {
  const dir = fs.mkdtempSync(join(os.tmpdir(), 'pgv3-refact-'))
  t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
  return dir
}

test('records the newest ref timestamp and never moves backwards', async (t) => {
  resetRefActivityForTests()
  t.teardown(resetRefActivityForTests)

  t.is(getRefActivity('demo'), null, 'unknown repo has no value')

  recordRefActivity('demo', 1000)
  t.is(getRefActivity('demo'), 1000, 'first value recorded')

  recordRefActivity('demo', 5000)
  t.is(getRefActivity('demo'), 5000, 'advances')

  // A pass can see an OLDER max than a previous pass: a ref deletion drops the
  // newest entry out of the view. That is not a reason to un-know the repo's
  // activity, and letting it fall back would re-shuffle the page.
  recordRefActivity('demo', 2000)
  t.is(getRefActivity('demo'), 5000, 'never regresses')

  recordRefActivity('demo', 0)
  recordRefActivity('demo', null)
  recordRefActivity('', 9999)
  t.is(getRefActivity('demo'), 5000, 'zero/null/nameless are ignored')
})

test('mergeRefActivity annotates repo records without inventing fields', async (t) => {
  resetRefActivityForTests()
  t.teardown(resetRefActivityForTests)

  recordRefActivity('alpha', 4242)
  const out = mergeRefActivity([{ name: 'alpha' }, { name: 'beta' }])
  t.is(out[0].lastRefUpdate, 4242, 'known repo annotated')
  t.absent('lastRefUpdate' in out[1], 'unknown repo left alone')
  t.alike(mergeRefActivity(null), [], 'non-array input is safe')
})

test('the replicated timestamp outranks the local mtime', async (t) => {
  resetRefActivityForTests()
  t.teardown(resetRefActivityForTests)

  // `quiet` looks fresher on disk — this is the reconcile-sweep bump, or a
  // late clone. `busy` is the one that actually got a push.
  const repos = [
    { name: 'quiet', branches: 1, lastUpdated: 9_000_000, lastRefUpdate: 1_000_000 },
    { name: 'busy', branches: 1, lastUpdated: 1_000, lastRefUpdate: 8_000_000 }
  ]
  t.alike(sortReposByRecency(repos).map((r) => r.name), ['busy', 'quiet'],
    'ordered by the replicated ref timestamp, not the mtime')

  // Two backends whose mtimes disagree wildly still agree on the order.
  const backendA = [
    { name: 'a', branches: 1, lastUpdated: 1, lastRefUpdate: 300 },
    { name: 'b', branches: 1, lastUpdated: 999_999, lastRefUpdate: 200 },
    { name: 'c', branches: 1, lastUpdated: 500, lastRefUpdate: 100 }
  ]
  const backendB = [
    { name: 'a', branches: 1, lastUpdated: 777_777, lastRefUpdate: 300 },
    { name: 'b', branches: 1, lastUpdated: 3, lastRefUpdate: 200 },
    { name: 'c', branches: 1, lastUpdated: 42, lastRefUpdate: 100 }
  ]
  t.alike(
    sortReposByRecency(backendA).map((r) => r.name),
    sortReposByRecency(backendB).map((r) => r.name),
    'both backends produce the same page order'
  )
})

test('falls back to the mtime when a repo has no replicated timestamp', async (t) => {
  // A repo whose tracker has never been mounted here (no reconciler, so no
  // value yet) must not sink below every repo that has one — it is ordered on
  // whatever local signal exists, as before.
  const repos = [
    { name: 'tracked', branches: 1, lastRefUpdate: 5_000 },
    { name: 'untracked-new', branches: 1, lastUpdated: 9_000 },
    { name: 'untracked-old', branches: 1, lastUpdated: 1_000 }
  ]
  t.alike(sortReposByRecency(repos).map((r) => r.name),
    ['untracked-new', 'tracked', 'untracked-old'],
    'mixed records order on one comparable scale')

  // Empty repos still sink regardless of which signal they carry.
  const withEmpty = [
    { name: 'empty', branches: 0, lastRefUpdate: 9_999_999 },
    { name: 'real', branches: 1, lastRefUpdate: 1 }
  ]
  t.alike(sortReposByRecency(withEmpty).map((r) => r.name), ['real', 'empty'],
    'repos with no history still sink to the bottom')
})

test('survives a restart — the value is persisted and reloaded', async (t) => {
  const dir = tmpdir(t)
  resetRefActivityForTests()
  t.teardown(resetRefActivityForTests)

  await configureRefActivity(dir)
  recordRefActivity('alpha', 12345)
  recordRefActivity('beta', 67890)
  await flushRefActivity()

  // Model a daemon restart: drop all process state, re-configure the same dir.
  resetRefActivityForTests()
  await configureRefActivity(dir)
  t.is(getRefActivity('alpha'), 12345, 'alpha survived the restart')
  t.is(getRefActivity('beta'), 67890, 'beta survived the restart')

  // Without this the first render after every restart falls back to mtimes,
  // which is exactly the disagreement being fixed — the boot ensureFor walk
  // repopulates it, but not before the first visitor arrives.
  t.ok(fs.existsSync(join(dir, 'repo-ref-activity.json')), 'persisted on disk')
})

test('a corrupt or absent cache file is not fatal', async (t) => {
  const dir = tmpdir(t)
  resetRefActivityForTests()
  t.teardown(resetRefActivityForTests)

  fs.writeFileSync(join(dir, 'repo-ref-activity.json'), '{not json')
  await configureRefActivity(dir)
  t.is(getRefActivity('anything'), null, 'starts empty rather than throwing')

  recordRefActivity('alpha', 1)
  t.is(getRefActivity('alpha'), 1, 'still usable after a bad load')
})

test('an EXACT tie orders identically on both backends', async (t) => {
  // Found in production the hour 0.1.250 shipped. lastRefUpdate agreed to the
  // millisecond across tabertron and stortron (1788551868967 for both
  // proc-nodes and blender-bridge — one batch publish stamps one timestamp),
  // and with no tiebreak the comparator returned 0, so Array.sort left them in
  // INPUT order. That input is sync.repos, a Map in readdir order, which is
  // per-host: 134 of 135 positions matched and those two were swapped.
  //
  // The replicated key made ties COMMON — mtimes were near-unique by accident,
  // a batch publish is not. Names are unique per daemon, so tie-breaking on
  // name makes the order total and host-independent.
  const tied = (order) => order.map((name) => ({ name, branches: 1, lastRefUpdate: 1788551868967 }))

  t.alike(
    sortReposByRecency(tied(['proc-nodes', 'blender-bridge'])).map((r) => r.name),
    sortReposByRecency(tied(['blender-bridge', 'proc-nodes'])).map((r) => r.name),
    'the same tied set orders the same way whatever order it arrives in'
  )
  t.alike(sortReposByRecency(tied(['proc-nodes', 'blender-bridge'])).map((r) => r.name),
    ['blender-bridge', 'proc-nodes'], 'tie broken by name')

  // The tie is only reached when the recency is equal — it must never outrank
  // the timestamp itself.
  t.alike(
    sortReposByRecency([
      { name: 'aaa', branches: 1, lastRefUpdate: 1 },
      { name: 'zzz', branches: 1, lastRefUpdate: 2 }
    ]).map((r) => r.name),
    ['zzz', 'aaa'],
    'a newer repo still wins over an alphabetically earlier one'
  )

  // Repos with NO signal at all (both keys absent) tie at 0 — same rule.
  t.alike(
    sortReposByRecency([{ name: 'b', branches: 1 }, { name: 'a', branches: 1 }]).map((r) => r.name),
    ['a', 'b'],
    'repos with no signal at all are also deterministic'
  )
})