OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 548106a

flush the /repos enrich cache on shutdown
repo-activity.js persists on a 1s debounce and unref()s the timer, so the write cannot hold the process open. flushRepoActivityCache() exists to force that pending write out — its own doc says "call on graceful shutdown" — but nothing ever called it. So every cache entry computed in the last second before an exit was dropped, and the next boot re-ran git against those repos. pear-git-v3 restarts often (OTA applies by exiting for the supervisor to respawn), which makes this the common path, not an edge case: it is a direct contributor to "/repos is slow on the first load after a restart". close() now flushes first, before any teardown that could hang. Rebuildable derived data, so a failure there is not worth failing shutdown. The test closes and asserts with NO sleep, so a regression to "wait for the debounce" fails rather than passing slowly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
548106aa20e17b43946d2bc1b8d2cae003cc5016
by plankatron <fleeky@gmail.com> · 2026-08-16T16:27:58.000Z

Changed files (2) +75 -1

lib/pear-git.js +9 -1
……@@ -101,7 +101,7 @@ import { sshKeyHandlers } from './handlers/ssh-keys.js'
101101import { attachSshResyncTrigger } from './auth/ssh-resync-trigger.js'
102102import { userHandlers } from './handlers/users.js'
103103import { startDeployService, deployHandlers } from './services/deploy.js'
104import { configureRepoActivityCache, enrichRepos as enrichReposForWarm, invalidateRepoActivity } from './services/repo-activity.js'
104import { configureRepoActivityCache, enrichRepos as enrichReposForWarm, invalidateRepoActivity, flushRepoActivityCache } from './services/repo-activity.js'
105105import { configurePushIndex } from './services/push-index.js'
106106import { configureIssueIndex, backfillIssueIndex } from './services/issue-index.js'
107107import { startSyncService, syncHandlers } from './services/sync.js'
……@@ -3044,6 +3044,14 @@ export async function startPearGit (opts = {}) {
30443044 orgEventLog,
30453045 repoSync,
30463046 async close () {
3047 // Force the debounced /repos enrich-cache write out before anything
3048 // else can hang the teardown. The persist timer is unref'd (it must not
3049 // hold the process open), so without this every entry computed in the
3050 // last second before exit is lost and re-run against git on the next
3051 // boot — the "slow on the first load after a restart" path. Rebuildable
3052 // derived data, so a failure here is not worth failing shutdown.
3053 try { await flushRepoActivityCache() } catch {}
3054
30473055 // Drain any in-flight auto-build jobs first so they don't write to
30483056 // the release dir or spawn subprocesses after the rest of the daemon
30493057 // tears down. buildAndPublish handles its own errors so allSettled
test/repo-activity-flush-on-close.test.js +66 -0
……@@ -0,0 +1,66 @@
1// The /repos enrich cache must survive a daemon shutdown.
2//
3// repo-activity.js persists on a 1s DEBOUNCE, and the timer is `unref()`d so
4// it cannot hold the process open. flushRepoActivityCache() exists precisely
5// to force that pending write out — its own doc says "call on graceful
6// shutdown" — but nothing called it. So every cache entry computed in the last
7// second before an exit was silently dropped, and the next boot re-ran git
8// against those repos: the "slow on first load after a restart" mechanism.
9//
10// pear-git-v3 restarts often (OTA applies by exiting for the supervisor to
11// respawn), so this is the common path, not an edge case.
12//
13// The daemon calls configureRepoActivityCache(storage) at boot, so the module
14// is already pointed at <storage>/repo-activity-cache.json by the time close()
15// runs. This test enriches (populating the cache and arming the debounce),
16// closes immediately, and asserts the file landed — with no sleep, so a
17// regression to "wait for the debounce" fails rather than passes slowly.
18
19import test from 'brittle'
20import process from '#node:process'
21import { mkdtempSync, existsSync } from '#node:fs'
22import { readFile } from '#node:fs/promises'
23import { join } from '#node:path'
24import { tmpdir } from '#node:os'
25
26import { startPearGit } from '../index.js'
27import { enrichRepos, resetRepoActivityCacheForTests } from '../lib/services/repo-activity.js'
28
29process.env.PGV3_USE_RAW_IDENTITY = '1'
30const tmp = (p) => mkdtempSync(join(tmpdir(), p))
31
32test('close() flushes the pending repo-activity cache write', async (t) => {
33 // The cache is module-global. Drop any state a sibling test file left
34 // behind so configureRepoActivityCache() actually binds to OUR storage.
35 resetRepoActivityCacheForTests()
36
37 const storage = tmp('pgv3-flush-')
38 const node = await startPearGit({
39 storage,
40 uds: join(tmp('pgv3-flush-s-'), 'svc.sock'),
41 http: { port: 0 },
42 swarm: false,
43 sync: false,
44 deploy: false,
45 autoImportSSH: false
46 })
47
48 const cacheFile = join(storage, 'repo-activity-cache.json')
49 t.absent(existsSync(cacheFile), 'no cache file before anything is enriched')
50
51 // A plain directory is enough: getRepoDetails stats objects/pack (absent →
52 // mtime 0), the git calls fail and resolve empty, and the zero-commit result
53 // is still cached + marks the module dirty. That is the state we need — a
54 // pending write — without paying for a real repo.
55 const repoDir = tmp('pgv3-flush-repo-')
56 await enrichRepos({ repos: [{ name: 'r1' }] }, new Map([['r1', { path: repoDir }]]))
57
58 // No sleep. Pre-fix the debounced write is still 1s out and unref'd, so the
59 // file does not exist; the flush on close is the only thing that writes it.
60 await node.close()
61
62 t.ok(existsSync(cacheFile), 'cache persisted by close(), without waiting for the debounce')
63
64 const parsed = JSON.parse(await readFile(cacheFile, 'utf8'))
65 t.ok(parsed.entries && parsed.entries.r1, 'the entry enriched just before shutdown survived')
66})