OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / e65ae49

0.1.223 — a failed release pull must not throw the work away
Three defects behind the [release-pull] noise on stortron. 1. reconcile() cleared `pending` BEFORE pulling and restored it for exactly ONE failure reason ('a pull is already in progress'). Every other failure — in particular the 15s `swarm-rpc: connection timeout` that fires when the mirror answers its own request for the origin — silently discarded the scoped repos and scheduled no retry. Nothing replays them: the daemon saves the release-log cursor immediately after notify(), and notify only QUEUES, so the cursor is already past the dropped work, across restarts too. Measured on stortron 2026-08-16: 24 timeouts vs 46 successful pulls in ~40 minutes — a third of reconciles dropped. Net drift was zero only because repos publish repeatedly and a later event covered the loss; a repo that publishes ONCE loses that release until its next publish. Now ANY failure re-queues, with exponential backoff (capped at 5m, reset by any success) so an unreachable origin is not hammered every debounce. 2. The per-repo summary printed ReleasePuller.stats, which is a lifetime accumulator that is never reset — so the live log read `[ifsrenderer-linux] fetched 61, skipped 1994` across DIFFERENT repos, none of which that pull touched, with counts that only ever climbed. pullFrom now returns a per-pull `delta`; the cumulative totals stay in `stats`. 3. Every error path pushed to stats.errors but none logged, so an operator saw a COUNT and never the text — and being cumulative, that count then repeated forever ("errors 1" on every line, an artifact no longer identifiable from the logs). New errors are now logged with their text. The connect/verify behaviour itself is unchanged — the verify gate is correct and is what stops a mirror silently serving itself its own release index. The 34% self-answer rate is a hyper-svc peer-selection concern, filed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e65ae49abc49276f6bfaec224783152b1efa6bf1
by plankatron <fleeky@gmail.com> · 2026-08-16T18:08:39.000Z

Changed files (3) +248 -6

lib/services/release-puller.js +60 -5
……@@ -67,6 +67,10 @@ export const INDEX_FILE = '_index.json'
6767// attachment spool, which shares the same overlay instance.
6868export const OVERLAY_PREFIX = 'release:'
6969
70// Retry backoff bounds for a failed reconcile (see startReleasePull).
71const MAX_BACKOFF_SHIFT = 5
72const MAX_RETRY_MS = 5 * 60 * 1000
73
7074/**
7175 * Decide what to fetch. Pure — no I/O, no network — so the rule that governs
7276 * whether a peer overwrites local bytes is testable in isolation.
……@@ -187,6 +191,28 @@ export class ReleasePuller {
187191 }
188192 }
189193
194 // `stats` is CUMULATIVE for the puller's lifetime and never reset, so
195 // reporting it per pull produced counts that only ever climbed — the live
196 // log read `[ifsrenderer-linux] fetched 61, skipped 1994` across DIFFERENT
197 // repos, none of which that pull touched. Snapshot here so the caller can
198 // report what THIS pull did while the lifetime totals stay available.
199 const before = {
200 fetched: this.stats.fetched,
201 replaced: this.stats.replaced,
202 skipped: this.stats.skipped,
203 suspect: this.stats.suspect,
204 bytes: this.stats.bytes,
205 errors: this.stats.errors.length
206 }
207 const delta = () => ({
208 fetched: this.stats.fetched - before.fetched,
209 replaced: this.stats.replaced - before.replaced,
210 skipped: this.stats.skipped - before.skipped,
211 suspect: this.stats.suspect - before.suspect,
212 bytes: this.stats.bytes - before.bytes,
213 errors: this.stats.errors.slice(before.errors)
214 })
215
190216 const localStat = await this._buildLocalStat(remoteIndex)
191217 const plan = planPull(remoteIndex, localStat)
192218 this.stats.skipped += plan.skip.length
……@@ -295,7 +321,14 @@ export class ReleasePuller {
295321 try { await this._deploy.rescan() } catch {}
296322 }
297323
298 return { ok: true, plan, stats: { ...this.stats } }
324 // Every error path above pushes to stats.errors but none of them logged,
325 // so an operator saw a COUNT and never the text — and because the count is
326 // cumulative it then repeated on every later line forever ("errors 1" on
327 // stortron, whose artifact could no longer be identified from the logs).
328 const d = delta()
329 for (const e of d.errors) this._log('[release-pull] error: ' + e)
330
331 return { ok: true, plan, delta: d, stats: { ...this.stats } }
299332 }
300333}
301334
……@@ -354,6 +387,8 @@ export function startReleasePull (opts = {}) {
354387 let running = false
355388 let stopped = false
356389 let debounce = null
390 // Consecutive failed reconciles, for the retry backoff. Reset by any success.
391 let failures = 0
357392 // Repos named by events that have not yet been reconciled.
358393 const pending = new Set()
359394 // Set when an event lands DURING a pull: that pull may have read the origin's
……@@ -370,7 +405,9 @@ export function startReleasePull (opts = {}) {
370405 client = await connect({ serverKey: originServerKey })
371406 const res = await puller.pullFrom(client, pullOpts)
372407 if (res.ok && !res.dryRun) {
373 const s = res.stats || {}
408 // THIS pull's numbers. `stats` is a lifetime accumulator, so reporting
409 // it here made every line climb regardless of what the pull did.
410 const s = res.delta || res.stats || {}
374411 log('[release-pull] origin ' + originServerKey.slice(0, 12) +
375412 (pullOpts.repos && pullOpts.repos.length ? ' [' + pullOpts.repos.join(',') + ']' : '') +
376413 ' → fetched ' + s.fetched + ', replaced ' + s.replaced +
……@@ -397,20 +434,38 @@ export function startReleasePull (opts = {}) {
397434 pending.clear()
398435 again = false
399436 const res = await pullNow({ repos })
400 if (!res.ok && res.reason === 'a pull is already in progress') {
401 // Lost the race with a manual pull: put the work back and retry shortly.
437 if (!res.ok) {
438 // ANY failure re-queues. This used to restore the work for exactly one
439 // reason ('a pull is already in progress') and drop it for every other —
440 // including the 15s `swarm-rpc: connection timeout` that fires when the
441 // mirror answers its own request for the origin, which on stortron was a
442 // third of all reconciles. Nothing replays a dropped event: the daemon
443 // saves the release-log cursor right after notify(), and notify only
444 // QUEUES, so the cursor is already past it, across restarts too.
402445 for (const r of repos) pending.add(r)
403446 again = true
447 failures++
448 } else {
449 failures = 0
404450 }
405451 if (again || pending.size > 0) schedule()
406452 }
407453
454 // Retry delay grows with consecutive failures so an origin that is genuinely
455 // unreachable is not hammered every debounce — but it is capped, and reset by
456 // any success, so a transient timeout costs one short wait rather than
457 // stranding the repo.
458 function retryDelay () {
459 if (failures === 0) return debounceMs
460 return Math.min(debounceMs * Math.pow(2, Math.min(failures, MAX_BACKOFF_SHIFT)), MAX_RETRY_MS)
461 }
462
408463 function schedule () {
409464 if (stopped || debounce) return
410465 debounce = setTimeout(() => {
411466 debounce = null
412467 reconcile().catch((err) => log('[release-pull] reconcile error: ' + (err && err.message)))
413 }, debounceMs)
468 }, retryDelay())
414469 if (debounce && typeof debounce.unref === 'function') debounce.unref()
415470 }
416471
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.222",
3 "version": "0.1.223",
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/release-pull-retry.test.js +187 -0
……@@ -0,0 +1,187 @@
1// A failed release pull must not throw the work away.
2//
3// reconcile() clears `pending` BEFORE pulling, then restored the repo list on
4// exactly ONE failure reason ('a pull is already in progress'). Every other
5// failure — notably the 15s `swarm-rpc: connection timeout` that fires when the
6// mirror answers its own request for the origin — silently discarded the scoped
7// repos and scheduled no retry.
8//
9// Nothing replays them: pear-git.js saveCursor(seq) runs immediately after
10// notify(entry), and notify only QUEUES, so the release-event cursor is already
11// past the dropped work — across restarts too.
12//
13// Measured on stortron 2026-08-16: 24 timeouts vs 46 successful pulls in ~40
14// minutes, i.e. a third of reconciles dropped. Net drift was zero only because
15// repos publish repeatedly and a later event covered the loss; a repo that
16// publishes ONCE loses that release until its next publish.
17
18import test from 'brittle'
19import { mkdtemp, rm } from '#node:fs/promises'
20import { tmpdir } from '#node:os'
21import { join } from '#node:path'
22
23import { startReleasePull, ReleasePuller } from '../lib/services/release-puller.js'
24
25const tick = (ms) => new Promise((r) => setTimeout(r, ms))
26
27function fakePuller (onPull) {
28 return { pullFrom: onPull, stats: { errors: [] } }
29}
30
31test('a connect failure re-queues the repos and retries', async (t) => {
32 const attempts = []
33 let failNext = true
34
35 const pull = startReleasePull({
36 puller: fakePuller(async () => ({ ok: true, stats: { fetched: 1, replaced: 0, skipped: 0, errors: [] } })),
37 originServerKey: 'a'.repeat(64),
38 debounceMs: 10,
39 connect: async () => {
40 attempts.push(Date.now())
41 if (failNext) {
42 failNext = false
43 throw new Error('swarm-rpc: connection timeout (2 peer(s) answered but failed verification)')
44 }
45 return { call: async () => ({ files: [] }), close: async () => {} }
46 },
47 log: () => {}
48 })
49 t.teardown(() => pull.stop())
50
51 pull.notify({ repo: 'ifsrenderer-linux' })
52
53 // First attempt fails. Pre-fix the repo was gone here and nothing retried.
54 await tick(400)
55
56 t.ok(attempts.length >= 2, 'the failed pull was retried rather than dropped')
57 t.is(pull.pending().length, 0, 'and the repo was drained once a retry succeeded')
58})
59
60test('the re-queued repo is the one that failed', async (t) => {
61 const seen = []
62 let fail = true
63
64 const pull = startReleasePull({
65 puller: fakePuller(async (client, opts) => {
66 seen.push([...(opts.repos || [])].sort())
67 return { ok: true, stats: { fetched: 0, replaced: 0, skipped: 0, errors: [] } }
68 }),
69 originServerKey: 'b'.repeat(64),
70 debounceMs: 10,
71 connect: async () => {
72 if (fail) { fail = false; throw new Error('swarm-rpc: connection timeout') }
73 return { call: async () => ({ files: [] }), close: async () => {} }
74 },
75 log: () => {}
76 })
77 t.teardown(() => pull.stop())
78
79 pull.notify({ repo: 'hyper-tps' })
80 await tick(400)
81
82 t.ok(seen.length >= 1, 'a pull eventually reached the puller')
83 t.alike(seen[0], ['hyper-tps'], 'carrying the repo whose event was dropped')
84})
85
86test('repeated failures back off instead of hot-looping', async (t) => {
87 const at = []
88 const pull = startReleasePull({
89 puller: fakePuller(async () => ({ ok: true, stats: { errors: [] } })),
90 originServerKey: 'c'.repeat(64),
91 debounceMs: 20,
92 connect: async () => {
93 at.push(Date.now())
94 throw new Error('swarm-rpc: connection timeout')
95 },
96 log: () => {}
97 })
98 t.teardown(() => pull.stop())
99
100 pull.notify({ repo: 'always-fails' })
101 await tick(700)
102
103 t.ok(at.length >= 3, 'it kept retrying')
104 // Fixed-interval retry at 20ms would be ~35 attempts in 700ms. Backoff must
105 // grow the gap, so the count stays far below that.
106 t.ok(at.length < 15, 'but backed off rather than retrying every debounce (' + at.length + ' attempts)')
107 const first = at[1] - at[0]
108 const last = at[at.length - 1] - at[at.length - 2]
109 t.ok(last > first, 'the gap between attempts grew (' + first + 'ms → ' + last + 'ms)')
110})
111
112test('a successful pull clears the backoff', async (t) => {
113 let fail = true
114 const at = []
115 const pull = startReleasePull({
116 puller: fakePuller(async () => ({ ok: true, stats: { errors: [] } })),
117 originServerKey: 'd'.repeat(64),
118 debounceMs: 10,
119 connect: async () => {
120 at.push(Date.now())
121 if (fail) throw new Error('swarm-rpc: connection timeout')
122 return { call: async () => ({ files: [] }), close: async () => {} }
123 },
124 log: () => {}
125 })
126 t.teardown(() => pull.stop())
127
128 pull.notify({ repo: 'r1' })
129 await tick(300)
130 fail = false
131 await tick(400)
132
133 const before = at.length
134 pull.notify({ repo: 'r2' })
135 await tick(200)
136 t.ok(at.length > before, 'a later event is served promptly, not stuck behind the grown backoff')
137 t.is(pull.pending().length, 0, 'and nothing is left queued')
138})
139
140test('per-pull deltas are reported, not lifetime totals', async (t) => {
141 // ReleasePuller.stats is cumulative by design (fetched++, skipped +=,
142 // errors.push, never reset). The summary printed those cumulative fields per
143 // repo, so the live log read `[ifsrenderer-linux] fetched 61, skipped 1994`
144 // when that pull had done far less — counts that only ever climbed, across
145 // different repos.
146 const puller = new ReleasePuller({
147 releaseDir: '/nonexistent',
148 overlay: { fetchFile: async () => null }
149 })
150
151 puller.stats.fetched = 60
152 puller.stats.skipped = 1900
153 puller.stats.errors.push('an old error from a previous pull')
154
155 const res = await puller.pullFrom({ call: async () => ({ files: [] }) }, { repos: ['x'] })
156
157 t.ok(res.delta, 'a per-pull delta is returned')
158 t.is(res.delta.fetched, 0, 'delta counts only this pull, not the lifetime 60')
159 t.is(res.delta.skipped, 0, 'same for skipped')
160 t.is(res.delta.errors.length, 0, 'and the stale error is not re-reported')
161 t.is(res.stats.fetched, 60, 'the cumulative total is still available')
162})
163
164test('a new error is logged with its text, not just counted', async (t) => {
165 const lines = []
166 // A REAL dir: with a bogus releaseDir the fetch loop dies in mkdirSync and
167 // never reaches the "no holder" branch this test is about.
168 const dir = await mkdtemp(join(tmpdir(), 'relpull-'))
169 t.teardown(() => rm(dir, { recursive: true, force: true }))
170
171 const puller = new ReleasePuller({
172 releaseDir: dir,
173 overlay: { fetchFile: async () => null }, // null = "no holder"
174 log: (l) => lines.push(l)
175 })
176
177 await puller.pullFrom({
178 call: async () => ({
179 files: [{ path: 'demo/versions/1/app.bin', hash: 'f'.repeat(64), size: 10, repo: 'demo', version: '1' }]
180 })
181 })
182
183 t.is(puller.stats.errors.length, 1, 'the failure was recorded')
184 const logged = lines.filter((l) => l.includes('no holder'))
185 t.is(logged.length, 1, 'and its TEXT reached the log, not only the count')
186 t.ok(logged[0].includes('app.bin'), 'naming the artifact that failed')
187})