OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / e391840

0.1.250: /repos ordered on a local mtime, so the two backends disagreed
The landing page sorted on hyper-sync's `lastUpdated` — the newest mtime among HEAD, packed-refs and refs/** on the LOCAL disk. That is a property of the machine, not of the repo, so the two backends behind 7rnx.net/git could not agree on the front page's order by construction. Measured on the live fleet 2026-09-05: identical 135-repo sets on both, and all 135 with a different lastUpdated — 67 apart by more than an hour, 61 by more than a day. Three independent ways it drifts: 1. CLONE TIME. A repo nobody has pushed to since import carries the time THAT host first fetched it. One bulk-import cohort reads 2026-06-03 on tabertron and 2026-04-21 on stortron. 2. STALENESS. `sync.repos` is a cache and almost nothing refreshes it after boot: the repos-dir watcher is a non-recursive watch(reposDir), so writes INSIDE an existing <repo>.git never wake it, and RepoPuller's only hook is onMount -> scheduleMirrorReannounce. stortron, up 3 days, reported blender-bridge at 2026-06-12 while its own disk said 2026-09-04 15:57 — refs 2 seconds behind the origin's. 3. THE DAEMON ITSELF. The 5-minute reconcileAll sweep force-writes refs from the tracker view onto disk; materializing one bumps its mtime, so a repo nothing happened to jumps to the top. ifsrenderer-linux went from rank 20 to rank 1 on stortron with refs/heads/linux-port stamped at the exact second of the sweep. Order on the REPLICATED signal instead. The per-repo tracker's ref-view already carries a `timestamp` per ref, stamped by the writer that ACCEPTED the push and replicated verbatim, so every backend derives the same number. RefReconciler calls listRefs() on every pass anyway, so taking max(timestamp) off the array it already holds adds no autobase work — reported on no-op passes too, since a synced mirror never changes a ref and is exactly the node the LB fronts. Persisted, because otherwise the first render after every restart falls back to mtimes. A repo with no mounted tracker has no value and orders on its mtime as before; both are unix ms, so the scales mix. Also refresh the pulled repo's record after a mirror pull (cause 2 above), which fixes its branch count and pack size as well as its ordering. Per-repo, not sync.scan(): scanRepos walks every repo synchronously (364ms across 147 live repos) and a mirror pulls one at a time, so a full walk per pull would stall the event loop through the whole drain. lib/services/repo-ref-activity.js new — the replicated index, persisted ref-reconciler.js onRefActivity hook, fires on every pass ref-reconciler-manager.js wires it to recordRefActivity sync.js attachRepoRefresh + merge into list-repos repo-mirror.js refresh after a successful pull web/pages/repos.js repoRecency prefers lastRefUpdate pear-git.js configure at boot, flush on close Tests: repo-ref-activity (6), ref-reconciler-ref-activity (4), mirror-pull-refresh (4). Suite 785/788 — the 3 failures (issue-index #318, release-overlay-repair x2) reproduce identically on the parent commit, and cutover-prod-copy times out at 180s there too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e391840056d94b8e0063762575e0eb9c628bb20c
by plankatron <fleeky@gmail.com> · 2026-09-05T19:34:21.000Z

Changed files (11) +714 -15

lib/pear-git.js +13 -0
……@@ -104,6 +104,7 @@ import { userHandlers } from './handlers/users.js'
104104import { startDeployService, deployHandlers } from './services/deploy.js'
105105import { configureRepoActivityCache, enrichRepos as enrichReposForWarm, invalidateRepoActivity, flushRepoActivityCache } from './services/repo-activity.js'
106106import { configurePushIndex } from './services/push-index.js'
107import { configureRefActivity, flushRefActivity } from './services/repo-ref-activity.js'
107108import { configureIssueIndex, backfillIssueIndex } from './services/issue-index.js'
108109import { startSyncService, syncHandlers } from './services/sync.js'
109110import { RepoEventLog } from './services/repo-event-log.js'
……@@ -168,6 +169,14 @@ export async function startPearGit (opts = {}) {
168169 // Rebuildable derived data — safe to lose. See lib/services/push-index.js.
169170 await configurePushIndex(opts.storage).catch(() => {})
170171
172 // Load the persisted replicated last-ref-update index — the /repos ordering
173 // key. The boot ensureFor walk below repopulates it from the trackers, but
174 // not before the first visitor arrives, and without a value the page falls
175 // back to local mtimes, which is the cross-backend disagreement this exists
176 // to fix. Rebuildable derived data — safe to lose. See
177 // lib/services/repo-ref-activity.js.
178 await configureRefActivity(opts.storage).catch(() => {})
179
171180 // Load the persisted issue-author index (public profile "recent issues").
172181 // Rebuildable derived data — safe to lose. See lib/services/issue-index.js.
173182 await configureIssueIndex(opts.storage).catch(() => {})
……@@ -3146,6 +3155,10 @@ export async function startPearGit (opts = {}) {
31463155 // boot — the "slow on the first load after a restart" path. Rebuildable
31473156 // derived data, so a failure here is not worth failing shutdown.
31483157 try { await flushRepoActivityCache() } catch {}
3158 // Same reasoning for the /repos ordering key: its persist timer is
3159 // unref'd, so a restart would otherwise order the first page on
3160 // mtimes until the boot reconcile walk catches up.
3161 try { await flushRefActivity() } catch {}
31493162
31503163 // Drain any in-flight auto-build jobs first so they don't write to
31513164 // the release dir or spawn subprocesses after the rest of the daemon
lib/services/ref-reconciler-manager.js +5 -1
……@@ -20,6 +20,7 @@ import fs from '#node:fs'
2020
2121import { execFileSync } from '../_compat-exec.js'
2222import { invalidateRepoActivity } from './repo-activity.js'
23import { recordRefActivity } from './repo-ref-activity.js'
2324import { RefReconciler } from './ref-reconciler.js'
2425import { makeReconcilingEnsureObjects } from '../git-overlay-fetch.js'
2526import { ensureExportMarker } from '../repo-access.js'
……@@ -94,7 +95,10 @@ export class RefReconcilerManager {
9495 repoPath,
9596 ensureObjects,
9697 logger: this._log,
97 onChange: () => { invalidateRepoActivity(repoName) }
98 onChange: () => { invalidateRepoActivity(repoName) },
99 // The /repos ordering key. Recorded per pass whether or not the
100 // pass changed anything on disk — see repo-ref-activity.js.
101 onRefActivity: (ms) => { recordRefActivity(repoName, ms) }
98102 })
99103 await rec.start()
100104 this._recs.set(repoName, rec)
lib/services/ref-reconciler.js +23 -0
……@@ -49,6 +49,10 @@ export class RefReconciler {
4949 // a callback so this service stays unaware of what depends on repo content
5050 // — the manager wires it to the activity cache, which knows the repo name.
5151 this._onChange = typeof opts.onChange === 'function' ? opts.onChange : null
52 // Called with the newest `timestamp` in the view on EVERY pass, no-op or
53 // not — it describes the view, not this disk. The /repos ordering key; see
54 // services/repo-ref-activity.js.
55 this._onRefActivity = typeof opts.onRefActivity === 'function' ? opts.onRefActivity : null
5256
5357 this._materialized = new Map() // refname -> oid we last wrote to the bare repo
5458 this._started = false
……@@ -191,6 +195,25 @@ export class RefReconciler {
191195 const desired = new Map()
192196 for (const r of refs) if (r.hash) desired.set(r.ref, r.hash)
193197
198 // Newest ref timestamp in the view — the REPLICATED last-activity signal
199 // that /repos orders on. Reported from the array listRefs() already
200 // returned, so it costs nothing beyond the pass we are in, and reported
201 // unconditionally: a mirror that is fully in sync never changes a ref, and
202 // gating this on `changed` would leave exactly those backends ordering on
203 // local mtimes. A view with no timestamps (an older writer, or no refs at
204 // all) reports nothing rather than a zero that would sink the repo.
205 if (this._onRefActivity) {
206 let newest = 0
207 for (const r of refs) {
208 if (r && typeof r.timestamp === 'number' && r.timestamp > newest) newest = r.timestamp
209 }
210 if (newest > 0) {
211 try { this._onRefActivity(newest) } catch (err) {
212 this._log.warn('onRefActivity hook failed: ' + (err && err.message))
213 }
214 }
215 }
216
194217 // Did this pass actually change the bare repo? Consumers cache things keyed
195218 // on repo content (see onChange in the constructor).
196219 let changed = false
lib/services/repo-mirror.js +11 -0
……@@ -343,6 +343,17 @@ export class RepoPuller {
343343 this._cursor[this._primaryServerKey] = seq
344344 this._saveCursor()
345345 }
346 // The pull wrote refs and objects INSIDE an existing <repo>.git, which
347 // the repos-dir watcher cannot see (it is a non-recursive watch on the
348 // directory). Without this the in-memory record status() serves keeps
349 // the boot-time branch count, pack size and lastUpdated for a repo that
350 // has just advanced — and lastUpdated is what /repos orders on, so a
351 // long-lived mirror ranked active repos as if they were months quiet.
352 // Per-repo, not sync.scan(): a full walk per pulled repo would stall the
353 // event loop through the whole drain.
354 if (typeof this._sync.refreshRepo === 'function') {
355 try { this._sync.refreshRepo(entry.name) } catch {}
356 }
346357 ok = true
347358 } catch (e) {
348359 err = e
lib/services/repo-ref-activity.js +179 -0
……@@ -0,0 +1,179 @@
1/**
2 * Replicated last-activity signal for a repo — the ordering key for /repos.
3 *
4 * ## Why not the filesystem
5 *
6 * The landing page used to order on hyper-sync's `lastUpdated`, the newest
7 * mtime among HEAD, packed-refs and refs/** on the LOCAL disk. That is a
8 * property of the machine, not of the repo, so the two backends behind
9 * `7rnx.net/git` could not agree on the front page's order by construction.
10 * Measured 2026-09-05: identical 135-repo sets on both, and all 135 with a
11 * different `lastUpdated` — 67 apart by more than an hour, 61 by more than a
12 * day. Three separate ways it drifts:
13 *
14 * 1. CLONE TIME. A repo nobody has pushed to since import carries the time
15 * that host first fetched it. One bulk-import cohort reads 2026-06-03 on
16 * tabertron and 2026-04-21 on stortron.
17 * 2. STALENESS. `sync.repos` is a cache, and a mirror's pulls write inside
18 * an existing `<repo>.git` where the non-recursive repos-dir watcher
19 * cannot see them. See services/sync.js `attachRepoRefresh`.
20 * 3. THE DAEMON ITSELF. The 5-minute reconcileAll sweep force-writes refs
21 * from the tracker view onto disk. Materializing a ref bumps its mtime,
22 * so a repo nothing happened to jumps to the top — ifsrenderer-linux went
23 * from rank 20 to rank 1 on stortron with refs/heads/linux-port stamped
24 * at the exact second of the sweep.
25 *
26 * ## What replaces it
27 *
28 * The per-repo tracker's ref-view already carries a `timestamp` per ref
29 * (`listRefs()` -> `{ ref, hash, updatedBy, timestamp }`), stamped by the
30 * writer that ACCEPTED the push and replicated verbatim. Every peer holding
31 * the tracker derives the same number, so every backend renders the same
32 * order. It also keeps the property the mtime was chosen for: force-pushing
33 * an old commit is recent ACTIVITY, and unlike a git committer date it is
34 * stamped by the daemon rather than supplied by the committer.
35 *
36 * ## Where the values come from
37 *
38 * RefReconciler calls `listRefs()` on every pass anyway — event-driven off the
39 * tracker's `update`, plus the sweep — so recording `max(timestamp)` costs no
40 * extra autobase work. The manager wires the hook; nothing here talks to an
41 * autobase.
42 *
43 * Coverage is a boot walk: pear-git.js `ensureFor`s every `*.git` in the repos
44 * dir at startup and `RefReconciler.start()` reconciles immediately, so every
45 * repo with a mounted tracker has a value shortly after boot. A repo with no
46 * tracker never gets one and orders on its mtime as before.
47 *
48 * Storage: <storage>/repo-ref-activity.json
49 * { version: 1, repos: { <name>: <unix ms> } }
50 *
51 * Persisted for one reason: without it the first render after every restart
52 * falls back to mtimes — the exact disagreement this fixes — because the boot
53 * walk has not finished before the first visitor arrives. Losing the file is
54 * harmless; the next reconcile pass rewrites every entry.
55 */
56
57import { readFile, writeFile, rename } from '#node:fs/promises'
58import { join } from '#node:path'
59
60const activity = new Map() // repo name -> unix ms
61
62let persistPath = null
63let loaded = false
64let dirty = false
65let saveTimer = null
66
67/**
68 * Point the index at a storage dir and load persisted entries. Call once at
69 * daemon boot before serving. Idempotent.
70 *
71 * @param {string} storageDir - daemon storage dir (opts.storage)
72 */
73export async function configureRefActivity (storageDir) {
74 if (!storageDir) return
75 persistPath = join(storageDir, 'repo-ref-activity.json')
76 if (loaded) return
77 loaded = true
78 try {
79 const obj = JSON.parse(await readFile(persistPath, 'utf8'))
80 if (obj && obj.repos && typeof obj.repos === 'object') {
81 for (const [name, ms] of Object.entries(obj.repos)) {
82 if (typeof ms === 'number' && ms > 0) activity.set(name, ms)
83 }
84 }
85 } catch {
86 // No file yet (first boot), unreadable, or corrupt. Starting empty costs
87 // one boot's worth of mtime ordering, not correctness.
88 }
89}
90
91/**
92 * Record the newest ref timestamp seen for a repo.
93 *
94 * MONOTONIC. A later pass can legitimately see an OLDER max than an earlier
95 * one — deleting the most recently pushed ref drops that entry out of the
96 * view — and letting the value fall back would re-shuffle the page for a repo
97 * whose activity plainly did not move backwards in time.
98 *
99 * @param {string} name - repo name
100 * @param {number} ms - unix ms, the max `timestamp` across the repo's refs
101 * @returns {boolean} whether the stored value advanced
102 */
103export function recordRefActivity (name, ms) {
104 if (!name || typeof ms !== 'number' || !(ms > 0)) return false
105 const prev = activity.get(name)
106 if (typeof prev === 'number' && prev >= ms) return false
107 activity.set(name, ms)
108 dirty = true
109 schedulePersist()
110 return true
111}
112
113/**
114 * @param {string} name - repo name
115 * @returns {number|null} unix ms, or null when this daemon has no value
116 */
117export function getRefActivity (name) {
118 if (!name) return null
119 const ms = activity.get(name)
120 return typeof ms === 'number' ? ms : null
121}
122
123/**
124 * Annotate repo records with `lastRefUpdate` where one is known.
125 *
126 * A repo with no value is returned UNCHANGED rather than carrying an explicit
127 * null, so `repoRecency()` can tell "no replicated signal, use the mtime"
128 * apart from "replicated signal of zero".
129 *
130 * @param {Array<object>} repos - records from sync.status()
131 * @returns {Array<object>}
132 */
133export function mergeRefActivity (repos) {
134 if (!Array.isArray(repos)) return []
135 return repos.map((r) => {
136 const ms = r && r.name ? getRefActivity(r.name) : null
137 return ms === null ? r : { ...r, lastRefUpdate: ms }
138 })
139}
140
141/** Force any pending persist immediately (graceful shutdown + tests). */
142export async function flushRefActivity () {
143 if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
144 await persistNow()
145}
146
147/**
148 * Drop all process-local state so configureRefActivity() re-reads the file —
149 * i.e. model a daemon restart within one process. Test seam only; it does NOT
150 * touch the file. (A test cannot get a fresh copy of a module under Bare,
151 * which resolves by realpath and ignores an import query — same reason
152 * repo-activity.js carries resetRepoActivityCacheForTests.)
153 */
154export function resetRefActivityForTests () {
155 activity.clear()
156 persistPath = null
157 loaded = false
158 dirty = false
159 if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
160}
161
162function schedulePersist () {
163 if (!persistPath || saveTimer) return
164 saveTimer = setTimeout(() => { saveTimer = null; persistNow().catch(() => {}) }, 1000)
165 if (saveTimer && typeof saveTimer.unref === 'function') saveTimer.unref()
166}
167
168async function persistNow () {
169 if (!persistPath || !dirty) return
170 dirty = false
171 const repos = Object.fromEntries(activity)
172 const tmp = persistPath + '.tmp'
173 try {
174 await writeFile(tmp, JSON.stringify({ version: 1, repos }))
175 await rename(tmp, persistPath)
176 } catch {
177 dirty = true // best-effort; retry on the next change
178 }
179}
lib/services/sync.js +72 -3
……@@ -35,14 +35,73 @@ import { watch, existsSync } from '#node:fs'
3535import { join } from '#node:path'
3636
3737import HyperSync from 'hyper-sync'
38// Deep import: hyper-sync exposes scanRepos/repoInfo/readRefs from this
39// module but re-exports none of them from its index. repoInfo is the
40// per-repo half of scanRepos — see attachRepoRefresh below for why the
41// whole-dir version is the wrong tool after a single pull.
42import hyperSyncRepos from 'hyper-sync/lib/repos.js'
3843import Hyperswarm from 'hyperswarm'
3944
4045import { enrichRepos } from './repo-activity.js'
46import { mergeRefActivity } from './repo-ref-activity.js'
4147// execFile (async), not execFileSync — this runs inside the daemon's event loop
4248// and spawnSync would stall every in-flight HTTP request for the child's life.
4349import { execFile } from '../_compat-exec.js'
4450import { ensureExportMarker } from '../repo-access.js'
4551
52/**
53 * Give a hyper-sync server a `refreshRepo(name)` — re-stat ONE repo and
54 * update its entry in the in-memory map that status() serves.
55 *
56 * WHY THIS EXISTS. `sync.repos` is a cache, and after boot almost nothing
57 * refreshes it:
58 *
59 * - the repos-dir watcher below is a NON-RECURSIVE `watch(reposDir)`, so it
60 * fires when a repo directory appears or disappears and never when refs
61 * are written INSIDE an existing `<repo>.git`;
62 * - RepoPuller fetches into existing repos continuously on a mirror and its
63 * only hook is onMount -> scheduleMirrorReannounce.
64 *
65 * So a mirror served boot-time branch counts, pack sizes and lastUpdated for
66 * repos it had since pulled. Measured on the live fleet 2026-09-05: stortron,
67 * up 3 days, reported blender-bridge lastUpdated 2026-06-12 while its own disk
68 * said 2026-09-04 15:57 — refs 2 seconds behind the origin's. Since /repos
69 * orders on lastUpdated, four actively-pushed repos sat mid-page on one
70 * backend and top-of-page on the other.
71 *
72 * PER-REPO, deliberately. The obvious fix is `sync.scan()`, but scanRepos()
73 * walks every repo SYNCHRONOUSLY (364ms of a 409ms scan across 147 live
74 * repos) and a mirror pulls repos one at a time — one full walk per pulled
75 * repo would stall the event loop through the whole drain. repoInfo() is the
76 * same computation for a single repo, and it takes the previous record so the
77 * dirSize walk is skipped when the footprint has not moved.
78 *
79 * Only refreshes repos ALREADY in the map: adding one is the watcher's job
80 * (it sees the new directory) and going through here would bypass the
81 * retired-repo prune that every scan path applies.
82 *
83 * @param {object} sync - the hyper-sync server
84 * @param {string} reposDir - bare-repos directory
85 */
86export function attachRepoRefresh (sync, reposDir) {
87 sync.refreshRepo = (name) => {
88 if (!name || !sync.repos || typeof sync.repos.get !== 'function') return null
89 const prev = sync.repos.get(name)
90 if (!prev) return null
91 try {
92 const info = hyperSyncRepos.repoInfo(join(reposDir, name + '.git'), name, prev)
93 if (!info) return null
94 sync.repos.set(name, info)
95 return info
96 } catch {
97 // A repo that vanished mid-pull, or an unreadable dir. The stale entry
98 // is better than dropping the repo out of the listing entirely.
99 return null
100 }
101 }
102 return sync
103}
104
46105export async function startSyncService (opts) {
47106 const { reposDir, keypair, hyperswarmOpts } = opts
48107 if (!reposDir) throw new Error('sync service requires reposDir')
……@@ -158,6 +217,10 @@ export async function startSyncService (opts) {
158217 // the caller pays nothing for the disk scan it does not need.
159218 sync.pruneRetired = pruneRetired
160219
220 // Per-repo re-stat, for the paths that change ONE repo's bytes without
221 // touching the repos dir itself — RepoPuller's fetch above all.
222 attachRepoRefresh(sync, reposDir)
223
161224 // Populate the in-memory map. We don't call serve() here — the caller
162225 // attaches listeners (repoEventLog's on('serve') hook, etc.) AFTER we
163226 // return, so serve() must run after listener wire-up or the initial
……@@ -220,7 +283,11 @@ export function syncHandlers (svc) {
220283 const sync = svc._sync
221284 if (!sync) return { repos: [], active: false }
222285 const status = sync.status()
223 return { active: true, ...status }
286 // Annotate with the REPLICATED last-ref-update timestamp, which is what
287 // /repos orders on. status() only carries the local mtime, and that is
288 // a property of this machine — the two load-balanced backends never
289 // agreed on it. See services/repo-ref-activity.js.
290 return { active: true, ...status, repos: mergeRefActivity(status.repos) }
224291 }
225292 },
226293
……@@ -244,11 +311,13 @@ export function syncHandlers (svc) {
244311 const sync = svc._sync
245312 if (!sync) return { repos: [], active: false }
246313 const status = sync.status()
247 let subject = status
314 let subject = { ...status, repos: mergeRefActivity(status.repos) }
248315 if (Array.isArray(params.names)) {
249316 const wanted = new Set(params.names)
250 subject = { ...status, repos: status.repos.filter((r) => wanted.has(r.name)) }
317 subject = { ...subject, repos: subject.repos.filter((r) => wanted.has(r.name)) }
251318 }
319 // enrichRepos spreads the base record through, so lastRefUpdate rides
320 // along and the fragment route orders the same way the first page did.
252321 const repos = await enrichRepos(subject, sync.repos)
253322 return { active: true, ...status, repos }
254323 }
lib/web/pages/repos.js +23 -10
……@@ -113,23 +113,36 @@ function renderActivityGraph (data) {
113113 * Exported so the /repos page and the /api/repos/page fragment route order
114114 * identically — a mismatch would make paging skip or repeat repos.
115115 *
116 * Ordered on `lastUpdated` (newest ref/HEAD/packed-refs mtime), which
117 * status() already has in memory, NOT on `lastCommit.timestamp`. lastCommit
118 * only exists after enrichRepos() git-walks a repo's full history, so using
119 * it as the sort key forced walking every repo to pick a page of 25.
116 * NOT on `lastCommit.timestamp`: that only exists after enrichRepos() git-walks
117 * a repo's full history, so using it as the sort key forced walking every repo
118 * to pick a page of 25. It is also the wrong signal — force-pushing an old
119 * commit IS recent activity, and committer dates are clock-controlled, so one
120 * future-dated commit used to pin a repo to the top permanently.
120121 *
121 * It is also the better signal for "most recently pushed": force-pushing an
122 * old commit IS recent activity, and committer dates are clock-controlled,
123 * so one future-dated commit used to pin a repo to the top permanently.
122 * PREFER `lastRefUpdate` — the newest `timestamp` in the repo's replicated
123 * tracker ref-view (services/repo-ref-activity.js). Every backend derives the
124 * same number from the same autobase, so `7rnx.net/git` renders one order
125 * whichever backend the load balancer picks.
126 *
127 * FALL BACK to `lastUpdated`, the local ref/HEAD/packed-refs mtime that
128 * status() already has in memory. It is what a repo with no mounted tracker
129 * has, but it is a property of the MACHINE: on the live fleet all 135 repos
130 * carried a different value on the two backends (67 more than an hour apart),
131 * because a quiet repo's mtime records when THAT host cloned it and the
132 * 5-minute reconcile sweep re-stamps refs it materializes. Mixing the two
133 * scales is deliberate — both are unix ms, and a repo with no replicated
134 * signal should sit where its local evidence puts it rather than sink below
135 * everything that has one.
124136 *
125137 * Branch count stands in for "has commits" — free from status(), whereas a
126138 * commit count is not. Reads correctly on both cheap and enriched records,
127139 * since enrichRepos spreads the base fields through.
128140 */
129141function repoRecency (r) {
130 // Milliseconds from whichever signal the record carries. lastUpdated is an
131 // mtime in ms; lastCommit.timestamp is git's %at in SECONDS (timeAgo treats
132 // it that way too), so it is scaled rather than compared raw.
142 // Milliseconds from whichever signal the record carries. lastRefUpdate and
143 // lastUpdated are both unix ms; lastCommit.timestamp is git's %at in SECONDS
144 // (timeAgo treats it that way too), so it is scaled rather than compared raw.
145 if (r.lastRefUpdate) return r.lastRefUpdate
133146 if (r.lastUpdated) return r.lastUpdated
134147 if (r.lastCommit && r.lastCommit.timestamp) return r.lastCommit.timestamp * 1000
135148 return 0
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.249",
3 "version": "0.1.250",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/mirror-pull-refresh.test.js +112 -0
……@@ -0,0 +1,112 @@
1// A mirror's repo listing went stale the moment a pull landed.
2//
3// RepoPuller._process fetches into an EXISTING bare repo. Nothing rescans
4// afterwards: the repos-dir watcher (services/sync.js) is a non-recursive
5// watch(reposDir), so writes INSIDE <repo>.git never wake it, and the
6// puller's only hook is onMount → scheduleMirrorReannounce. So
7// sync.status() — which feeds list-repos and the /repos ordering — kept
8// serving whatever the last full scan saw.
9//
10// Measured on the live fleet 2026-09-05: stortron (up 3d) reported
11// blender-bridge lastUpdated 2026-06-12 while its OWN disk said
12// 2026-09-04 15:57 — the refs were 2s behind tabertron's. Four repos were
13// buried on one backend and top-of-page on the other for this reason alone.
14//
15// Fix: refresh THAT repo's record after a successful pull. Per-repo, not a
16// full sync.scan() — scanRepos walks every repo synchronously (364ms across
17// 147 live repos) and would stall the event loop once per pulled repo.
18
19import test from 'brittle'
20import fs from '#node:fs'
21import os from '#node:os'
22import { join } from '#node:path'
23import { RepoPuller } from '../lib/services/repo-mirror.js'
24import { attachRepoRefresh } from '../lib/services/sync.js'
25
26function tmpdir (t) {
27 const dir = fs.mkdtempSync(join(os.tmpdir(), 'pgv3-refresh-'))
28 t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
29 return dir
30}
31
32function bareRepo (dir, name, sha) {
33 const p = join(dir, name + '.git')
34 fs.mkdirSync(join(p, 'refs', 'heads'), { recursive: true })
35 fs.mkdirSync(join(p, 'objects', 'pack'), { recursive: true })
36 fs.writeFileSync(join(p, 'HEAD'), 'ref: refs/heads/main\n')
37 fs.writeFileSync(join(p, 'refs', 'heads', 'main'), sha + '\n')
38 return p
39}
40
41test('RepoPuller refreshes the pulled repo, and only on success', async (t) => {
42 const ok = []
43 const good = new RepoPuller({
44 sync: {
45 pull: async () => {},
46 remotes: new Map(),
47 addRemote () {},
48 refreshRepo: (name) => ok.push(name)
49 },
50 issueRegistry: {},
51 log: { close: async () => {} },
52 primaryServerKey: 'c'.repeat(64)
53 })
54 await good._process({ name: 'demo', op: 0 }, 0)
55 t.alike(ok, ['demo'], 'refreshRepo called with the repo name on success')
56
57 const bad = []
58 const fails = new RepoPuller({
59 sync: {
60 pull: async () => { throw new Error('boom') },
61 remotes: new Map(),
62 addRemote () {},
63 refreshRepo: (name) => bad.push(name)
64 },
65 issueRegistry: {},
66 log: { close: async () => {} },
67 primaryServerKey: 'd'.repeat(64)
68 })
69 await fails._process({ name: 'nope', op: 0 }, 0)
70 t.alike(bad, [], 'refreshRepo NOT called when the pull fails')
71})
72
73test('RepoPuller survives a sync without refreshRepo (older hyper-sync)', async (t) => {
74 const puller = new RepoPuller({
75 sync: { pull: async () => {}, remotes: new Map(), addRemote () {} },
76 issueRegistry: {},
77 log: { close: async () => {} },
78 primaryServerKey: 'e'.repeat(64)
79 })
80 await puller._process({ name: 'demo', op: 0 }, 0)
81 t.pass('no throw when the sync server has no refreshRepo')
82})
83
84test('attachRepoRefresh re-stats ONE repo without walking the others', async (t) => {
85 const dir = tmpdir(t)
86 bareRepo(dir, 'alpha', 'a'.repeat(40))
87 bareRepo(dir, 'beta', 'b'.repeat(40))
88
89 // Model a daemon whose in-memory map is stale for alpha: an old lastUpdated
90 // and no branches, exactly what a post-boot pull leaves behind.
91 const stale = { name: 'alpha', path: join(dir, 'alpha.git'), branches: [], branchCount: 0, head: null, packSize: 0, lastUpdated: 1, footprint: 0 }
92 const betaEntry = { name: 'beta', path: join(dir, 'beta.git'), branches: [], branchCount: 0, head: null, packSize: 0, lastUpdated: 2, footprint: 0 }
93 const sync = { _reposDir: dir, repos: new Map([['alpha', stale], ['beta', betaEntry]]) }
94
95 attachRepoRefresh(sync, dir)
96 const updated = sync.refreshRepo('alpha')
97
98 t.ok(updated, 'returns the refreshed record')
99 t.is(sync.repos.get('alpha').branchCount, 1, 'alpha picked up its branch')
100 t.is(sync.repos.get('alpha').head, 'a'.repeat(40), 'alpha picked up its head')
101 t.ok(sync.repos.get('alpha').lastUpdated > 1, 'alpha lastUpdated moved to the real ref mtime')
102 t.is(sync.repos.get('beta'), betaEntry, 'beta was left untouched — not a full rescan')
103})
104
105test('attachRepoRefresh is a no-op for an unknown or vanished repo', async (t) => {
106 const dir = tmpdir(t)
107 const sync = { _reposDir: dir, repos: new Map() }
108 attachRepoRefresh(sync, dir)
109 t.is(sync.refreshRepo('ghost'), null, 'unknown repo returns null')
110 t.is(sync.refreshRepo(''), null, 'empty name returns null')
111 t.is(sync.repos.size, 0, 'nothing was added to the map')
112})
test/ref-reconciler-ref-activity.test.js +110 -0
……@@ -0,0 +1,110 @@
1// RefReconciler reports the repo's newest REPLICATED ref timestamp.
2//
3// /repos orders on this instead of the local mtime — see
4// services/repo-ref-activity.js for the full why. What matters here is that
5// the reporting is FREE: _reconcileOnce already calls listRefs(), which is the
6// `base.update()` plus ref range-scan the whole sweep-skip machinery exists to
7// avoid repeating. Taking max(timestamp) off the array it already has adds no
8// autobase work at all.
9//
10// It must also fire on a NO-OP pass. The value is a property of the view, not
11// of whether this daemon's disk changed — on a mirror that is already in sync
12// (the common case, and exactly the backend the LB fronts) every pass is a
13// no-op, and gating on `changed` like onChange does would leave the ordering
14// key permanently unset there.
15
16import test from 'brittle'
17import { mkdtempSync, writeFileSync } from '#node:fs'
18import { join } from '#node:path'
19import { tmpdir } from '#node:os'
20
21import { RefReconciler } from '../lib/services/ref-reconciler.js'
22import { execFileSync } from '../lib/_compat-exec.js'
23
24function git (args, opts = {}) {
25 return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
26}
27
28function fakeTracker (refs) {
29 return {
30 base: { on () {}, off () {} },
31 async listRefs () { return refs.slice() }
32 }
33}
34
35function makeRepo () {
36 const dir = mkdtempSync(join(tmpdir(), 'pgv3-refact-rec-'))
37 const bare = join(dir, 'repo.git')
38 const work = join(dir, 'work')
39 git(['init', '--bare', '-q', bare])
40 git(['init', '-q', work])
41 git(['-C', work, 'config', 'user.email', 't@t'])
42 git(['-C', work, 'config', 'user.name', 'T'])
43 git(['-C', work, 'config', 'commit.gpgsign', 'false'])
44 writeFileSync(join(work, 'a.txt'), 'one\n')
45 git(['-C', work, 'add', '.'])
46 git(['-C', work, 'commit', '-q', '-m', 'c1'])
47 const o1 = git(['-C', work, 'rev-parse', 'HEAD'])
48 git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main'])
49 return { bare, o1 }
50}
51
52test('reports the newest ref timestamp across the view', async (t) => {
53 const { bare, o1 } = makeRepo()
54 const seen = []
55 const tracker = fakeTracker([
56 { ref: 'refs/heads/main', hash: o1, timestamp: 1000 },
57 { ref: 'refs/heads/old', hash: o1, timestamp: 500 },
58 { ref: 'refs/heads/new', hash: o1, timestamp: 7000 }
59 ])
60 const rec = new RefReconciler({
61 tracker,
62 repoPath: bare,
63 onRefActivity: (ms) => seen.push(ms)
64 })
65 await rec.reconcile()
66 t.alike(seen, [7000], 'the max, not the first or the last')
67})
68
69test('fires on a no-op pass — a synced mirror must still report', async (t) => {
70 const { bare, o1 } = makeRepo()
71 const seen = []
72 // The bare repo is ALREADY at o1, so this pass changes nothing on disk.
73 const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1, timestamp: 4242 }])
74 const rec = new RefReconciler({ tracker, repoPath: bare, onRefActivity: (ms) => seen.push(ms) })
75 await rec.reconcile()
76 t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o1, 'nothing moved on disk')
77 t.alike(seen, [4242], 'reported anyway')
78})
79
80test('stays silent when the view carries no usable timestamp', async (t) => {
81 const { bare, o1 } = makeRepo()
82 const seen = []
83 // A ref-view written by a build that predates the timestamp field, or an
84 // empty view. Reporting 0 would rank the repo below everything.
85 const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }])
86 const rec = new RefReconciler({ tracker, repoPath: bare, onRefActivity: (ms) => seen.push(ms) })
87 await rec.reconcile()
88 t.alike(seen, [], 'no timestamp, no report')
89
90 const empty = new RefReconciler({
91 tracker: fakeTracker([]),
92 repoPath: bare,
93 onRefActivity: (ms) => seen.push(ms)
94 })
95 await empty.reconcile()
96 t.alike(seen, [], 'empty view, no report')
97})
98
99test('a failing onRefActivity never breaks reconciliation', async (t) => {
100 const { bare, o1 } = makeRepo()
101 git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/main'])
102 const rec = new RefReconciler({
103 tracker: fakeTracker([{ ref: 'refs/heads/main', hash: o1, timestamp: 1 }]),
104 repoPath: bare,
105 onRefActivity: () => { throw new Error('index exploded') }
106 })
107 await rec.reconcile()
108 t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o1,
109 'the ref was still materialized despite the hook throwing')
110})
test/repo-ref-activity.test.js +165 -0
……@@ -0,0 +1,165 @@
1// Ordering /repos on a REPLICATED signal instead of a local file mtime.
2//
3// THE BUG. sortReposByRecency ordered on `lastUpdated`, which hyper-sync
4// derives from the mtime of HEAD / packed-refs / refs/** on the local disk
5// (hyper-sync/lib/repos.js). That is a per-machine number, so the two
6// load-balanced backends of 7rnx.net/git could not agree by construction.
7// Measured 2026-09-05: the same 135 repos on both, and all 135 with a
8// different lastUpdated — 67 differing by more than an hour. Whichever
9// backend the LB picked decided the front page's order.
10//
11// Three independent ways it drifts:
12// 1. clone time. A repo nobody has pushed to since import carries the time
13// THAT host first fetched it (2026-06-03 on tabertron, 2026-04-21 on
14// stortron for one bulk-import cohort).
15// 2. staleness. See mirror-pull-refresh.test.js.
16// 3. self-inflicted. The 5-minute reconcileAll sweep force-writes refs from
17// the tracker view onto disk, bumping the mtime of a repo nothing
18// happened to. ifsrenderer-linux went from rank 20 to rank 1 on stortron
19// that way, with refs/heads/linux-port stamped at the exact second of the
20// sweep.
21//
22// THE FIX. The per-repo tracker's ref-view already carries a `timestamp` per
23// ref, stamped by the writer that ACCEPTED the push and replicated verbatim
24// to every peer. RefReconciler reads that view on every pass anyway, so
25// recording max(timestamp) costs no extra autobase work, and every backend
26// that replicates the tracker derives the identical number.
27
28import test from 'brittle'
29import fs from '#node:fs'
30import os from '#node:os'
31import { join } from '#node:path'
32import {
33 recordRefActivity, getRefActivity, mergeRefActivity,
34 configureRefActivity, flushRefActivity, resetRefActivityForTests
35} from '../lib/services/repo-ref-activity.js'
36import { sortReposByRecency } from '../lib/web/pages/repos.js'
37
38function tmpdir (t) {
39 const dir = fs.mkdtempSync(join(os.tmpdir(), 'pgv3-refact-'))
40 t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
41 return dir
42}
43
44test('records the newest ref timestamp and never moves backwards', async (t) => {
45 resetRefActivityForTests()
46 t.teardown(resetRefActivityForTests)
47
48 t.is(getRefActivity('demo'), null, 'unknown repo has no value')
49
50 recordRefActivity('demo', 1000)
51 t.is(getRefActivity('demo'), 1000, 'first value recorded')
52
53 recordRefActivity('demo', 5000)
54 t.is(getRefActivity('demo'), 5000, 'advances')
55
56 // A pass can see an OLDER max than a previous pass: a ref deletion drops the
57 // newest entry out of the view. That is not a reason to un-know the repo's
58 // activity, and letting it fall back would re-shuffle the page.
59 recordRefActivity('demo', 2000)
60 t.is(getRefActivity('demo'), 5000, 'never regresses')
61
62 recordRefActivity('demo', 0)
63 recordRefActivity('demo', null)
64 recordRefActivity('', 9999)
65 t.is(getRefActivity('demo'), 5000, 'zero/null/nameless are ignored')
66})
67
68test('mergeRefActivity annotates repo records without inventing fields', async (t) => {
69 resetRefActivityForTests()
70 t.teardown(resetRefActivityForTests)
71
72 recordRefActivity('alpha', 4242)
73 const out = mergeRefActivity([{ name: 'alpha' }, { name: 'beta' }])
74 t.is(out[0].lastRefUpdate, 4242, 'known repo annotated')
75 t.absent('lastRefUpdate' in out[1], 'unknown repo left alone')
76 t.alike(mergeRefActivity(null), [], 'non-array input is safe')
77})
78
79test('the replicated timestamp outranks the local mtime', async (t) => {
80 resetRefActivityForTests()
81 t.teardown(resetRefActivityForTests)
82
83 // `quiet` looks fresher on disk — this is the reconcile-sweep bump, or a
84 // late clone. `busy` is the one that actually got a push.
85 const repos = [
86 { name: 'quiet', branches: 1, lastUpdated: 9_000_000, lastRefUpdate: 1_000_000 },
87 { name: 'busy', branches: 1, lastUpdated: 1_000, lastRefUpdate: 8_000_000 }
88 ]
89 t.alike(sortReposByRecency(repos).map((r) => r.name), ['busy', 'quiet'],
90 'ordered by the replicated ref timestamp, not the mtime')
91
92 // Two backends whose mtimes disagree wildly still agree on the order.
93 const backendA = [
94 { name: 'a', branches: 1, lastUpdated: 1, lastRefUpdate: 300 },
95 { name: 'b', branches: 1, lastUpdated: 999_999, lastRefUpdate: 200 },
96 { name: 'c', branches: 1, lastUpdated: 500, lastRefUpdate: 100 }
97 ]
98 const backendB = [
99 { name: 'a', branches: 1, lastUpdated: 777_777, lastRefUpdate: 300 },
100 { name: 'b', branches: 1, lastUpdated: 3, lastRefUpdate: 200 },
101 { name: 'c', branches: 1, lastUpdated: 42, lastRefUpdate: 100 }
102 ]
103 t.alike(
104 sortReposByRecency(backendA).map((r) => r.name),
105 sortReposByRecency(backendB).map((r) => r.name),
106 'both backends produce the same page order'
107 )
108})
109
110test('falls back to the mtime when a repo has no replicated timestamp', async (t) => {
111 // A repo whose tracker has never been mounted here (no reconciler, so no
112 // value yet) must not sink below every repo that has one — it is ordered on
113 // whatever local signal exists, as before.
114 const repos = [
115 { name: 'tracked', branches: 1, lastRefUpdate: 5_000 },
116 { name: 'untracked-new', branches: 1, lastUpdated: 9_000 },
117 { name: 'untracked-old', branches: 1, lastUpdated: 1_000 }
118 ]
119 t.alike(sortReposByRecency(repos).map((r) => r.name),
120 ['untracked-new', 'tracked', 'untracked-old'],
121 'mixed records order on one comparable scale')
122
123 // Empty repos still sink regardless of which signal they carry.
124 const withEmpty = [
125 { name: 'empty', branches: 0, lastRefUpdate: 9_999_999 },
126 { name: 'real', branches: 1, lastRefUpdate: 1 }
127 ]
128 t.alike(sortReposByRecency(withEmpty).map((r) => r.name), ['real', 'empty'],
129 'repos with no history still sink to the bottom')
130})
131
132test('survives a restart — the value is persisted and reloaded', async (t) => {
133 const dir = tmpdir(t)
134 resetRefActivityForTests()
135 t.teardown(resetRefActivityForTests)
136
137 await configureRefActivity(dir)
138 recordRefActivity('alpha', 12345)
139 recordRefActivity('beta', 67890)
140 await flushRefActivity()
141
142 // Model a daemon restart: drop all process state, re-configure the same dir.
143 resetRefActivityForTests()
144 await configureRefActivity(dir)
145 t.is(getRefActivity('alpha'), 12345, 'alpha survived the restart')
146 t.is(getRefActivity('beta'), 67890, 'beta survived the restart')
147
148 // Without this the first render after every restart falls back to mtimes,
149 // which is exactly the disagreement being fixed — the boot ensureFor walk
150 // repopulates it, but not before the first visitor arrives.
151 t.ok(fs.existsSync(join(dir, 'repo-ref-activity.json')), 'persisted on disk')
152})
153
154test('a corrupt or absent cache file is not fatal', async (t) => {
155 const dir = tmpdir(t)
156 resetRefActivityForTests()
157 t.teardown(resetRefActivityForTests)
158
159 fs.writeFileSync(join(dir, 'repo-ref-activity.json'), '{not json')
160 await configureRefActivity(dir)
161 t.is(getRefActivity('anything'), null, 'starts empty rather than throwing')
162
163 recordRefActivity('alpha', 1)
164 t.is(getRefActivity('alpha'), 1, 'still usable after a bad load')
165})