| … | … | @@ -0,0 +1,123 @@ |
| 1 | // Two concurrent enrich passes over the same repo must compute it ONCE. |
| 2 | // |
| 3 | // startPearGit fires a fire-and-forget background pre-warm of the /repos |
| 4 | // enrich cache (lib/pear-git.js, "Pre-warm the /repos landing-page enrich |
| 5 | // cache"). Nothing coordinates it with the request path, so a visitor who |
| 6 | // arrives during boot starts a SECOND full enrich over the same repos while |
| 7 | // the first is still running. Neither has populated the cache yet, so both |
| 8 | // miss, and every repo is walked twice — up to 2x CONCURRENCY git processes |
| 9 | // at once, on exactly the cold boot where the box is already busy. |
| 10 | // |
| 11 | // `search-all-repos` runs the same full pass on an autocomplete keystroke, so |
| 12 | // a third and fourth overlapping pass are reachable from a single visitor. |
| 13 | // |
| 14 | // The fix is an in-flight map: the second caller joins the first's promise |
| 15 | // instead of forking its own git. getRepoActivityStats() exposes the two |
| 16 | // counters so this is observable rather than inferred from timing. |
| 17 | |
| 18 | import test from 'brittle' |
| 19 | import { mkdtemp, rm, mkdir, writeFile } from '#node:fs/promises' |
| 20 | import { tmpdir } from '#node:os' |
| 21 | import { join } from '#node:path' |
| 22 | import process from '#node:process' |
| 23 | import { execFileSync } from '../lib/_compat-exec.js' |
| 24 | |
| 25 | import { |
| 26 | enrichRepos, |
| 27 | getRepoActivityStats, |
| 28 | resetRepoActivityCacheForTests |
| 29 | } from '../lib/services/repo-activity.js' |
| 30 | |
| 31 | function git (cwd, args) { |
| 32 | return execFileSync('git', args, { |
| 33 | cwd, |
| 34 | stdio: ['ignore', 'pipe', 'ignore'], |
| 35 | env: { |
| 36 | ...process.env, |
| 37 | GIT_AUTHOR_NAME: 't', |
| 38 | GIT_AUTHOR_EMAIL: 't@t', |
| 39 | GIT_COMMITTER_NAME: 't', |
| 40 | GIT_COMMITTER_EMAIL: 't@t' |
| 41 | } |
| 42 | }).toString() |
| 43 | } |
| 44 | |
| 45 | async function makeBareRepo (root, name, n) { |
| 46 | const barePath = join(root, name + '.git') |
| 47 | const workPath = join(root, name + '-work') |
| 48 | await mkdir(barePath, { recursive: true }) |
| 49 | await mkdir(workPath, { recursive: true }) |
| 50 | git(barePath, ['init', '--bare', '-q']) |
| 51 | git(workPath, ['init', '-q']) |
| 52 | for (let i = 0; i < n; i++) { |
| 53 | await writeFile(join(workPath, 'f'), 'v' + i) |
| 54 | git(workPath, ['add', 'f']) |
| 55 | git(workPath, ['commit', '-q', '-m', 'c' + i]) |
| 56 | } |
| 57 | const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() |
| 58 | git(workPath, ['remote', 'add', 'origin', barePath]) |
| 59 | git(workPath, ['push', '-q', 'origin', branch]) |
| 60 | return barePath |
| 61 | } |
| 62 | |
| 63 | const statusFor = (names) => ({ active: true, repos: names.map((name) => ({ name, serving: true })) }) |
| 64 | const reposMapFor = (root, names) => { |
| 65 | const m = new Map() |
| 66 | for (const name of names) m.set(name, { path: join(root, name + '.git') }) |
| 67 | return m |
| 68 | } |
| 69 | |
| 70 | test('concurrent enrich passes share one computation per repo', async (t) => { |
| 71 | const root = await mkdtemp(join(tmpdir(), 'ra-inflight-')) |
| 72 | t.teardown(() => rm(root, { recursive: true, force: true })) |
| 73 | |
| 74 | const names = ['alpha', 'beta', 'gamma'] |
| 75 | for (const n of names) await makeBareRepo(root, n, 3) |
| 76 | |
| 77 | // No persist path — this test is about in-flight coalescing, not disk. |
| 78 | resetRepoActivityCacheForTests() |
| 79 | t.is(getRepoActivityStats().computes, 0, 'counters start clean') |
| 80 | |
| 81 | const status = statusFor(names) |
| 82 | const map = reposMapFor(root, names) |
| 83 | |
| 84 | // Model the boot race: pre-warm and a visitor's render, started without an |
| 85 | // await between them so both are genuinely in flight. |
| 86 | const [warm, request] = await Promise.all([ |
| 87 | enrichRepos(status, map), |
| 88 | enrichRepos(status, map) |
| 89 | ]) |
| 90 | |
| 91 | const stats = getRepoActivityStats() |
| 92 | t.is(stats.computes, names.length, 'each repo computed exactly once across both passes') |
| 93 | t.ok(stats.deduped > 0, 'the second pass joined the first instead of forking its own git') |
| 94 | |
| 95 | // Coalescing must not change what either caller sees. |
| 96 | t.is(warm.length, names.length, 'pre-warm pass returned every repo') |
| 97 | t.is(request.length, names.length, 'request pass returned every repo') |
| 98 | for (let i = 0; i < names.length; i++) { |
| 99 | t.is(warm[i].name, names[i], 'input order preserved') |
| 100 | t.is(warm[i].commits, 3, 'commit count correct in the computing pass') |
| 101 | t.is(request[i].commits, 3, 'the joined pass got the same result, not a stub') |
| 102 | t.alike(request[i].activity, warm[i].activity, 'both callers see identical activity buckets') |
| 103 | } |
| 104 | }) |
| 105 | |
| 106 | test('a later pass still hits the cache rather than recomputing', async (t) => { |
| 107 | const root = await mkdtemp(join(tmpdir(), 'ra-inflight2-')) |
| 108 | t.teardown(() => rm(root, { recursive: true, force: true })) |
| 109 | |
| 110 | await makeBareRepo(root, 'solo', 2) |
| 111 | resetRepoActivityCacheForTests() |
| 112 | |
| 113 | const status = statusFor(['solo']) |
| 114 | const map = reposMapFor(root, ['solo']) |
| 115 | |
| 116 | await enrichRepos(status, map) |
| 117 | t.is(getRepoActivityStats().computes, 1, 'first pass computed') |
| 118 | |
| 119 | // Sequential, after the in-flight entry has been cleared — this must be a |
| 120 | // plain cache hit, proving the dedup map does not leak or shadow the cache. |
| 121 | await enrichRepos(status, map) |
| 122 | t.is(getRepoActivityStats().computes, 1, 'second pass served from cache, no recompute') |
| 123 | }) |