OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 9e1161b

Drop a fetched bundle once its objects are in the repo
materializeObject fetched each bundle in the chain, unbundled it, and left the file in the overlay's destDir forever. On stortron — the active public backend — that is 17GB: 736 of the 744 files in attachments-fetch are git bundles, going back to June 3. Nothing else wanted them. A bundle is a transfer artifact: after `git bundle unbundle` the objects are in the bare repo, and destDir is not a syncBaseDir, so the file is never re-served from there. The dangerous half is what must NOT be unlinked. hyper-overlay short-circuits a content hash it already holds and returns where those bytes actually live — this node's own producer spool, say — so an unconditional unlink would delete canonical data. That is the trap the release-puller hit in 5590c1d. discardFetchedBundle refuses on `res.local`, refuses anything not named `blob-<hash>-<ts>-<rand>` (which also excludes content-addressed mirror-spool paths), and never lets a failed unlink break a fetch that already succeeded. Verified the test fails with the call commented out (4/5) and passes with it (5/5), under both node and bare. gip/bundle/reconciler suites 31/31. Does not touch the 11GB producer spool on tabertron — that needs anchor-on-adoption plus a retention policy, and those hashes are committed in the autobase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9e1161bec17def2796af30f24ae4e132f41d3543
by plankatron <fleeky@gmail.com> · 2026-08-24T17:31:35.000Z

Changed files (2) +144 -0

lib/git-overlay-fetch.js +34 -0
……@@ -14,10 +14,43 @@
1414 * production).
1515 */
1616
17import fs from '#node:fs'
18import path from '#node:path'
19
1720import { execFileSync } from './_compat-exec.js'
1821import { applyBundle } from './git-bundle.js'
1922import { ZERO_OID } from 'hyper-issues'
2023
24/**
25 * Drop a fetched bundle once its objects are in the bare repo.
26 *
27 * A bundle is a TRANSFER artifact, not state: after `unbundle` the objects live
28 * in the repo, and the overlay's `destDir` is not a `syncBaseDir`, so the file is
29 * never re-served from there. Keeping it leaked 17 GB on stortron — every bundle
30 * applied since June, still on disk.
31 *
32 * Two paths must NOT be unlinked, because they are canonical bytes rather than a
33 * throwaway copy:
34 * - `res.local` — hyper-overlay short-circuits a content hash it already holds
35 * and hands back where those bytes actually live (this node's own producer
36 * spool, say). Deleting that is the trap the release-puller hit.
37 * - a mirror-spool path — content-addressed and durably re-served.
38 * Both are excluded by requiring the throwaway `blob-<hash>-<ts>-<rand>` name
39 * that `_destDir` fetches use.
40 */
41export function discardFetchedBundle (res, log) {
42 if (!res || !res.destPath || res.local === true) return false
43 if (!path.basename(res.destPath).startsWith('blob-')) return false
44 try {
45 fs.unlinkSync(res.destPath)
46 return true
47 } catch (err) {
48 // Never let cleanup break a fetch that already succeeded.
49 if (log && log.warn) log.warn('bundle cleanup failed: ' + (err && err.message))
50 return false
51 }
52}
53
2154export function hasObject (repoPath, oid, git) {
2255 try {
2356 git('git', ['--git-dir', repoPath, 'cat-file', '-e', oid + '^{object}'], { encoding: 'utf8' })
……@@ -65,6 +98,7 @@ export async function materializeObject (o = {}) {
6598 }
6699 await applyBundle({ repoPath, bundlePath: res.destPath, git })
67100 log.info('applied bundle ' + entry.contentHash + ' -> ' + entry.oid)
101 discardFetchedBundle(res, log)
68102 }
69103
70104 if (!hasObject(repoPath, oid, git)) {
test/gip-fetch-cleanup.test.js +110 -0
……@@ -0,0 +1,110 @@
1/**
2 * A fetched bundle is a transfer artifact — once unbundled, drop it.
3 *
4 * Regression: the fetcher applied bundles and left every one of them in the
5 * overlay's destDir. On stortron that reached 17 GB, 736 files, back to June.
6 *
7 * The dangerous half is what must NOT be deleted: hyper-overlay short-circuits
8 * a hash it already holds and returns where those bytes really live, so an
9 * unconditional unlink would destroy this node's own producer spool.
10 */
11import test from 'brittle'
12import fs from '#node:fs'
13import fsp from '#node:fs/promises'
14import path from '#node:path'
15import os from '#node:os'
16
17import { discardFetchedBundle, materializeObject } from '../lib/git-overlay-fetch.js'
18
19async function tmpdir (name) {
20 return fsp.mkdtemp(path.join(os.tmpdir(), 'pgv3-gipclean-' + name + '-'))
21}
22
23test('a throwaway fetch is deleted', async (t) => {
24 const dir = await tmpdir('throwaway')
25 const destPath = path.join(dir, 'blob-abc123-1700000000000-xyz789')
26 fs.writeFileSync(destPath, 'bundle bytes')
27
28 t.is(discardFetchedBundle({ destPath }, null), true)
29 t.absent(fs.existsSync(destPath), 'file removed')
30})
31
32test('a local short-circuit is NEVER deleted', async (t) => {
33 const dir = await tmpdir('local')
34 // what the producer spool looks like: canonical bytes, natural name
35 const destPath = path.join(dir, 'deadbeef.bundle')
36 fs.writeFileSync(destPath, 'canonical bytes')
37
38 t.is(discardFetchedBundle({ destPath, local: true }, null), false)
39 t.ok(fs.existsSync(destPath), 'canonical file kept')
40})
41
42test('a mirror-spool (content-addressed) path is NEVER deleted', async (t) => {
43 const dir = await tmpdir('spool')
44 const destPath = path.join(dir, 'a'.repeat(64))
45 fs.writeFileSync(destPath, 'durably re-served bytes')
46
47 t.is(discardFetchedBundle({ destPath }, null), false)
48 t.ok(fs.existsSync(destPath), 'spool file kept')
49})
50
51test('cleanup failure never breaks a successful fetch', async (t) => {
52 const warnings = []
53 const res = { destPath: '/nonexistent/blob-abc-1-2' }
54 t.is(discardFetchedBundle(res, { warn: (m) => warnings.push(m) }), false)
55 t.is(warnings.length, 1, 'warned rather than threw')
56})
57
58test('materializeObject drops each bundle after applying it', async (t) => {
59 const dir = await tmpdir('materialize')
60 const repoPath = path.join(dir, 'repo.git')
61 fs.mkdirSync(repoPath)
62
63 // two-link chain: base then head
64 const fetched = []
65 function bundleFile (name) {
66 const p = path.join(dir, 'blob-' + name + '-1700000000000-aaaaaa')
67 fs.writeFileSync(p, '# v2 git bundle\n')
68 fetched.push(p)
69 return p
70 }
71
72 const applied = []
73 const present = new Set()
74
75 const tracker = {
76 async getBundle (oid) {
77 if (oid === 'head') return { contentHash: 'hash-head', baseOid: 'base' }
78 if (oid === 'base') return { contentHash: 'hash-base', baseOid: null }
79 return null
80 }
81 }
82 const overlay = {
83 async fetchFile (hash) {
84 return { destPath: bundleFile(hash) }
85 }
86 }
87 // stand in for git: cat-file -e answers from `present`, unbundle records
88 const git = (cmd, args) => {
89 if (args.includes('cat-file')) {
90 const oid = args[args.indexOf('-e') + 1].replace('^{object}', '')
91 if (!present.has(oid)) throw new Error('missing')
92 return Buffer.from('')
93 }
94 if (args.includes('unbundle')) {
95 applied.push(args[args.length - 1])
96 // applying the head bundle satisfies the target
97 if (applied.length === 2) present.add('head')
98 return Buffer.from('abc refs/heads/main\n')
99 }
100 return Buffer.from('')
101 }
102
103 await materializeObject({ oid: 'head', tracker, overlay, repoPath, git })
104
105 t.is(applied.length, 2, 'applied base then head')
106 t.is(fetched.length, 2, 'fetched two bundles')
107 for (const f of fetched) t.absent(fs.existsSync(f), 'fetched bundle removed: ' + path.basename(f))
108
109 await fsp.rm(dir, { recursive: true, force: true })
110})