OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / bf4652d

0.1.246: a mirror forgot every tombstone on restart
retired-repos.js calls the retired set "DERIVED, NOT STORED ... rebuilt identically on every boot". That is true on the ORIGIN, which folds its own writable RepoEventLog from seq 0 (pear-git.js: foldRetired(repoEventLog)). It was never true on a MIRROR. A mirror's own log carries no tombstones -- the origin wrote them to ITS log -- so the mirror's only source was the fold in RepoPuller._enqueue, and that rides on a stream opened at `cursor + 1`. A tombstone appended BELOW the cursor was folded once, in memory, and then forgotten on the next restart. The mirror resumed listing and announcing a repo the origin had retired, permanently, with no way back short of pushing a fresh op past the cursor. Measured in production 2026-09-02: tabertron (origin) held 15 tombstones, stortron (mirror -- and the load balancer's ACTIVE backend for 7rnx.net/git) reported []. It had restarted 2026-08-29 with its cursor at 1494, well past the 2026-07-30 retire events. _runLoop now re-folds the whole log before subscribing. The cursor is not the problem and does not move: it exists to avoid ~80 minutes of redundant `git fetch` per restart, and this pass reads METADATA only (~100 B/entry, no git), so that replay cost is untouched. Unconditional, not warm-boot-only. On a cold boot the stream does fold from seq 0, but only as it goes -- so a repo created at seq 3 and retired at seq 40 is cloned first and tombstoned after, and the never-delete rule then leaves those bytes on disk for good. Bounded, because this is a REPLICATED core and we await it. A block the mirror has not downloaded waits on the network with no deadline, so an unbounded fold would stall the puller completely whenever the origin is unreachable -- worse than the bug it fixes. foldRetired now forwards read-stream options and the caller passes a 15s per-block timeout; on expiry the stream throws, we log, keep what folded, and fall through to the live stream, i.e. exactly the old behaviour. foldRetired also strips `start` from forwarded opts, since a non-zero start would misnumber every seq and silently break applyEntry's newest-wins ordering. Tests are red-first against the real testnet harness. The second one guards the other direction: a re-fold must not clobber an un-retire that arrived later, which retired-repos.js promises is reversible. Pre-existing and unrelated, all three confirmed on a clean tree with these changes stashed: test/cutover-prod-copy.test.js hangs to a 180s timeout, and test/release-overlay-repair.test.js fails 2/5. test/issue-index.test.js fails only in a full-suite run -- lib/services/issue-index.js holds module-level singleton state that any daemon-booting test earlier in the run repoints; it passes in isolation both with and without this change. 767/770 with cutover-prod-copy excluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bf4652dee045784600e2f8ef7a8739f9c457dde6
by plankatron <fleeky@gmail.com> · 2026-09-02T13:44:31.000Z

Changed files (4) +256 -4

lib/services/repo-mirror.js +47 -1
……@@ -37,7 +37,12 @@ import b4a from 'b4a'
3737import fs from '#node:fs'
3838import path from '#node:path'
3939
40import { OP_RETIRED } from './retired-repos.js'
40import { foldRetired, OP_RETIRED } from './retired-repos.js'
41
42// Per-block deadline for the boot tombstone fold (see _runLoop). Generous:
43// the log is metadata (~100 B/entry) and blocks arrive in batches once a peer
44// is connected, so this only fires when a block is genuinely unreachable.
45const FOLD_BLOCK_TIMEOUT_MS = 15000
4146
4247export class RepoPuller {
4348 constructor ({ sync, issueRegistry, log, primaryServerKey,
……@@ -168,6 +173,47 @@ export class RepoPuller {
168173 console.log('[repo-mirror] warm-boot: resuming log from seq', startSeq)
169174 }
170175
176 // Re-fold the WHOLE log for tombstones before streaming anything.
177 //
178 // retired-repos.js calls the set "derived, not stored ... rebuilt
179 // identically on every boot", and on the ORIGIN that holds: pear-git.js
180 // folds its own writable log from seq 0. A MIRROR has no such log — the
181 // origin wrote the tombstones to ITS log — so the mirror's only source
182 // was the fold in _enqueue, which rides on a stream opened at
183 // `cursor + 1`. A tombstone below the cursor was therefore folded once,
184 // in memory, and forgotten on the next restart: the mirror resumed
185 // listing and announcing a repo the origin had retired, permanently.
186 // (Production, 2026-09-02: the origin held 15 tombstones, the mirror —
187 // the load balancer's ACTIVE backend — reported none.)
188 //
189 // The cursor is not the problem and does not move: it exists to avoid
190 // ~80 minutes of redundant `git fetch` per restart. This pass reads
191 // METADATA only (~100 B per entry, no git), so the expensive replay the
192 // cursor guards against is unaffected.
193 //
194 // Unconditional, not just on warm boot. On a cold boot the stream does
195 // fold from seq 0, but only as it goes — so a repo created at seq 3 and
196 // retired at seq 40 gets cloned first and tombstoned after, and the
197 // never-delete rule then leaves those bytes on disk for good.
198 //
199 // Ordering is safe: applyEntry keeps the highest seq per name, and this
200 // runs BEFORE the live stream, so a later un-retire still wins.
201 //
202 // Bounded, because this is a REPLICATED core and we await it. A block
203 // this mirror has not downloaded waits on the network with no deadline,
204 // so an unbounded fold would stall the loop completely whenever the
205 // origin is unreachable — strictly worse than the bug above. The timeout
206 // makes the stream throw instead; we log, keep whatever folded, and fall
207 // through to the live stream, which is exactly the old behaviour.
208 if (this._retired) {
209 try {
210 await foldRetired(this._log, this._retired, { timeout: FOLD_BLOCK_TIMEOUT_MS })
211 } catch (err) {
212 // eslint-disable-next-line no-console
213 console.error('[repo-mirror] tombstone re-fold incomplete:', err && err.message)
214 }
215 }
216
171217 // Snapshot the log's live frontier — the index at the moment we
172218 // subscribe. Entries with seq < liveFrontier are BACKLOG (pre-
173219 // existing on the log when we subscribed); seq >= liveFrontier are
lib/services/retired-repos.js +12 -2
……@@ -108,14 +108,24 @@ export class RetiredRepos {
108108 * Reads to the current end of the log and returns; pass `{ live: true }` in
109109 * opts to keep folding (the caller owns the loop lifetime in that case).
110110 *
111 * `opts` is forwarded to the read stream, minus `start` — seq numbering here
112 * counts from 0 and a non-zero start would silently misnumber every entry,
113 * breaking applyEntry's newest-wins ordering. Callers reading a REPLICATED
114 * log should pass a `timeout`: on a sparse core an undownloaded block waits
115 * on the network forever, and a caller that awaits this fold would hang with
116 * it. With a timeout the stream throws instead, leaving the set folded up to
117 * the last block that did arrive — partial, but strictly better than none.
118 *
111119 * @param {object} log - a RepoEventLog (needs .entries())
112120 * @param {RetiredRepos} [set]
121 * @param {object} [opts] - read-stream options (e.g. { timeout })
113122 * @returns {Promise<RetiredRepos>}
114123 */
115export async function foldRetired (log, set = new RetiredRepos()) {
124export async function foldRetired (log, set = new RetiredRepos(), opts = {}) {
116125 if (!log || typeof log.entries !== 'function') return set
126 const { start: _ignored, ...rest } = opts
117127 let seq = 0
118 for await (const entry of log.entries({ live: false, start: 0 })) {
128 for await (const entry of log.entries({ live: false, ...rest, start: 0 })) {
119129 set.applyEntry(entry, seq)
120130 seq++
121131 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.245",
3 "version": "0.1.246",
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/retired-repos-mirror.test.js +196 -0
……@@ -0,0 +1,196 @@
1/**
2 * Tombstone propagation to a MIRROR (issue #135 follow-up).
3 *
4 * retired-repos.js calls the set "DERIVED, NOT STORED ... rebuilt identically
5 * on every boot". That is true on the ORIGIN, which folds its own writable
6 * RepoEventLog from seq 0 (pear-git.js: `foldRetired(repoEventLog)`).
7 *
8 * It was NOT true on a mirror. A mirror's own log carries no tombstones — the
9 * origin wrote them to ITS log — so the mirror's only source is the fold
10 * inside RepoPuller._enqueue, and that rides on a stream opened at
11 * `cursor + 1`. The cursor exists to avoid ~80 minutes of redundant git
12 * fetches per restart, so it is not going away; the consequence was that a
13 * tombstone appended BELOW the cursor was folded once, in memory, and then
14 * forgotten on the next restart. The mirror resumed listing and announcing a
15 * repo the origin had retired, for ever.
16 *
17 * Observed in production 2026-09-02: tabertron (origin) held 15 tombstones,
18 * stortron (mirror, and the load-balancer's ACTIVE backend) reported none,
19 * because it had restarted 2026-08-29 with its cursor already past the
20 * 2026-07-30 retire events.
21 *
22 * These tests use the same real-testnet harness as repo-puller-cursor.test.js
23 * so the log-streaming path is exercised end-to-end rather than stubbed.
24 */
25
26import test from 'brittle'
27import { mkdtempSync, readFileSync, existsSync } from '#node:fs'
28import { join } from '#node:path'
29import { tmpdir } from '#node:os'
30import b4a from 'b4a'
31import Corestore from 'corestore'
32import createTestnet from '@hyperswarm/testnet'
33import Hyperswarm from 'hyperswarm'
34
35import { RepoEventLog } from '../lib/services/repo-event-log.js'
36import { RepoPuller } from '../lib/services/repo-mirror.js'
37import { RetiredRepos, OP_CREATED, OP_UPDATED, OP_RETIRED } from '../lib/services/retired-repos.js'
38
39let n = 0
40function tmpDir () {
41 return mkdtempSync(join(tmpdir(), 'pgv3-retired-mirror-' + (n++) + '-'))
42}
43
44class StubSync {
45 constructor () {
46 this.calls = []
47 this.remotes = new Map()
48 }
49
50 addRemote (name, key) { this.remotes.set(name, key) }
51 async pull (remote, repoName) { this.calls.push({ remote, repoName }) }
52}
53
54async function setupOriginAndMirror (testnet, t) {
55 const topic = b4a.alloc(32, 'pgv3-retired-' + (n++))
56
57 const originStore = new Corestore(join(tmpDir(), 'origin'))
58 await originStore.ready()
59 const originLog = new RepoEventLog({ corestore: originStore })
60 await originLog.ready()
61
62 const originSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
63 originSwarm.on('connection', (conn) => originStore.replicate(conn))
64 originSwarm.join(topic, { server: true, client: false })
65 await originSwarm.flush()
66
67 const mirrorStore = new Corestore(join(tmpDir(), 'mirror'))
68 await mirrorStore.ready()
69 const mirrorLog = new RepoEventLog({ corestore: mirrorStore, key: originLog.keyHex })
70 await mirrorLog.ready()
71
72 const mirrorSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
73 mirrorSwarm.on('connection', (conn) => mirrorStore.replicate(conn))
74 mirrorSwarm.join(topic, { server: false, client: true })
75 await mirrorSwarm.flush()
76
77 t.teardown(async () => {
78 try { await originSwarm.destroy() } catch {}
79 try { await mirrorSwarm.destroy() } catch {}
80 try { await originLog.close() } catch {}
81 try { await mirrorLog.close() } catch {}
82 try { await originStore.close() } catch {}
83 try { await mirrorStore.close() } catch {}
84 })
85
86 return { originLog, mirrorLog, mirrorStore }
87}
88
89async function waitFor (predicate, timeoutMs = 8000) {
90 const t0 = Date.now()
91 while (Date.now() - t0 < timeoutMs) {
92 if (await predicate()) return true
93 await new Promise((r) => setTimeout(r, 50))
94 }
95 return false
96}
97
98function readCursor (cursorPath, serverKey) {
99 if (!existsSync(cursorPath)) return null
100 try { return JSON.parse(readFileSync(cursorPath, 'utf8'))[serverKey] } catch { return null }
101}
102
103test('mirror: a tombstone below the cursor survives a restart', async (t) => {
104 t.timeout(60000)
105 const testnet = await createTestnet(3, t.teardown)
106 const { originLog, mirrorLog, mirrorStore } = await setupOriginAndMirror(testnet, t)
107 const serverKey = 'a'.repeat(64)
108 const cursorPath = join(tmpDir(), 'repo-puller-cursor.json')
109
110 // Phase 1 — origin serves two repos, retires one, then keeps working.
111 // That last entry matters: the cursor advances only on a successful pull
112 // (a retire returns early), so without it the cursor would stop AT the
113 // tombstone and the restart would replay it, hiding the bug.
114 await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 0
115 await originLog.append({ op: OP_CREATED, name: 'beta' }) // seq 1
116 await originLog.append({ op: OP_RETIRED, name: 'alpha' }) // seq 2 <- tombstone
117 await originLog.append({ op: OP_CREATED, name: 'gamma' }) // seq 3 <- pushes cursor past it
118
119 const retired1 = new RetiredRepos()
120 const sync1 = new StubSync()
121 const puller1 = new RepoPuller({
122 sync: sync1, log: mirrorLog, primaryServerKey: serverKey, cursorPath, retired: retired1
123 })
124 await puller1.start()
125
126 t.ok(await waitFor(() => retired1.has('alpha')), 'phase 1: the live fold sees the tombstone')
127 t.ok(await waitFor(() => readCursor(cursorPath, serverKey) === 3),
128 'phase 1: cursor advanced PAST the tombstone (seq 3)')
129 await puller1.close()
130
131 // Phase 2 — restart. RetiredRepos is derived state, and on a mirror it is
132 // folded from the mirror's OWN log, which carries no tombstones. So a real
133 // boot starts with an empty set, exactly as modelled here.
134 // A real restart re-opens the core; puller1.close() closed the old session.
135 const mirrorLog2 = new RepoEventLog({ corestore: mirrorStore, key: mirrorLog.keyHex })
136 await mirrorLog2.ready()
137 const retired2 = new RetiredRepos()
138 const sync2 = new StubSync()
139 const puller2 = new RepoPuller({
140 sync: sync2, log: mirrorLog2, primaryServerKey: serverKey, cursorPath, retired: retired2
141 })
142 t.teardown(async () => { try { await puller2.close() } catch {} })
143 await puller2.start()
144
145 // A live entry for an unrelated repo proves the read loop is running, so a
146 // failure below is a missing fold and not a stalled stream.
147 await originLog.append({ op: OP_UPDATED, name: 'beta' }) // seq 4
148 t.ok(await waitFor(() => sync2.calls.some((c) => c.repoName === 'beta')),
149 'phase 2: the read loop is live')
150
151 t.ok(retired2.has('alpha'),
152 'the tombstone is still known after a restart with a warm cursor')
153 t.absent(sync2.calls.some((c) => c.repoName === 'alpha'),
154 'and the retired repo is not pulled again')
155})
156
157test('mirror: re-folding must not clobber an un-retire that came later', async (t) => {
158 // The fix re-reads the log from seq 0 at boot. That must not resurrect a
159 // tombstone the origin has since reversed — retired-repos.js promises "an
160 // op 0 after an op 2 un-retires the repo and a mistake is reversible", and
161 // a naive re-fold ordered after the live stream would break exactly that.
162 t.timeout(60000)
163 const testnet = await createTestnet(3, t.teardown)
164 const { originLog, mirrorLog, mirrorStore } = await setupOriginAndMirror(testnet, t)
165 const serverKey = 'c'.repeat(64)
166 const cursorPath = join(tmpDir(), 'repo-puller-cursor.json')
167
168 await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 0
169 await originLog.append({ op: OP_RETIRED, name: 'alpha' }) // seq 1
170 await originLog.append({ op: OP_CREATED, name: 'beta' }) // seq 2 <- cursor lands here
171
172 const puller1 = new RepoPuller({
173 sync: new StubSync(), log: mirrorLog, primaryServerKey: serverKey, cursorPath, retired: new RetiredRepos()
174 })
175 await puller1.start()
176 t.ok(await waitFor(() => readCursor(cursorPath, serverKey) === 2), 'cursor past the tombstone')
177 await puller1.close()
178
179 // A real restart re-opens the core; puller1.close() closed the old session.
180 const mirrorLog2 = new RepoEventLog({ corestore: mirrorStore, key: mirrorLog.keyHex })
181 await mirrorLog2.ready()
182 const retired2 = new RetiredRepos()
183 const sync2 = new StubSync()
184 const puller2 = new RepoPuller({
185 sync: sync2, log: mirrorLog2, primaryServerKey: serverKey, cursorPath, retired: retired2
186 })
187 t.teardown(async () => { try { await puller2.close() } catch {} })
188 await puller2.start()
189
190 // The operator changed their mind on the origin.
191 await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 3 <- un-retire
192
193 t.ok(await waitFor(() => sync2.calls.some((c) => c.repoName === 'alpha')),
194 'the un-retired repo is pulled again')
195 t.absent(retired2.has('alpha'), 'and the tombstone is gone')
196})