OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar commits / 50d82cc

0.2.64: store gc keeps each app's newest 2 releases; store-gc RPC; bundles pgh 0.1.158
Boot and daily store gc now pass keepReleases (PGH_KEEP_RELEASES, default 2) with the own registry's + warm replicas' releases, read in-process. The age gate alone kept 75 hyper-tps .pck copies (~44 GiB) on tabertron 2026-10-11. New store-gc RPC backs `pgh store gc --keep-releases N`: the full release list overflows the 16 MiB UDS frame, so the CLI cannot fetch it to run gc itself. test/store-gc-keep-releases.test.js (red without it: unknown method store-gc). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
50d82cc0147f64a907a55d9462a45c434ce4b8b2
by plankatron <fleeky@gmail.com> · 2026-10-11T21:37:46.000Z

Changed files (3) +99 -6

lib/index.js +39 -5
……@@ -57,6 +57,12 @@ export const DEFAULT_NAME_REGISTRY_STORE = path.join(homedir(), '.config', 'pgh'
5757
5858const SIDECAR_VERSION = '0.2.0'
5959
60// Store retention: each app's newest N releases stay; trees only older
61// releases use are swept whatever their age (pgh store.gc keepReleases).
62// The 14-day age gate alone kept 75 hyper-tps .pck copies (~44 GiB) on
63// tabertron, 2026-10-11 — that app publishes several times a day.
64const KEEP_RELEASES = Math.max(1, parseInt(process.env.PGH_KEEP_RELEASES, 10) || 2)
65
6066// Recursive disk size for an on-disk dir. Used for compact() before/after
6167// reporting and for storage sweep logs. Returns 0 on any error (e.g. dir
6268// doesn't exist) — sweep logging is best-effort, never load-bearing.
……@@ -269,16 +275,15 @@ export class Sidecar {
269275 // block boot. Re-run on a daily interval (see _gcTimer below) so a
270276 // long-lived sidecar stays bounded without needing a restart.
271277 try {
272 const r = this._treeStore.gc({ maxAgeDays: 14 })
273 if (r) console.log('[pgh-sidecar] store gc on boot: pruned ' + r.removed.length + ' superseded tree(s), kept ' + r.kept.length)
278 const r = await this._storeGc()
279 if (r) console.log('[pgh-sidecar] store gc on boot: pruned ' + r.removed.length + ' superseded tree(s), kept ' + r.kept.length + ' (keep ' + KEEP_RELEASES + ' releases/app)')
274280 } catch (err) {
275281 console.error('[pgh-sidecar] store gc (non-fatal):', err && err.message)
276282 }
277283 this._gcTimer = setInterval(() => {
278 try {
279 const r = this._treeStore.gc({ maxAgeDays: 14 })
284 this._storeGc().then((r) => {
280285 if (r && r.removed.length) console.log('[pgh-sidecar] periodic store gc: pruned ' + r.removed.length + ' tree(s)')
281 } catch (err) { console.error('[pgh-sidecar] periodic gc (non-fatal):', err && err.message) }
286 }, (err) => { console.error('[pgh-sidecar] periodic gc (non-fatal):', err && err.message) })
282287 }, 24 * 60 * 60 * 1000)
283288 if (this._gcTimer.unref) this._gcTimer.unref()
284289
……@@ -1111,6 +1116,20 @@ export class Sidecar {
11111116 * Drop any older than 14 days (preserves a window for operator
11121117 * recovery but doesn't accumulate forever).
11131118 */
1119 /**
1120 * Tree-store gc with release retention. Runs HERE, not in the CLI: the
1121 * release list carries every entry's full dep closure, far past hyper-svc's
1122 * 16 MiB frame, so only the process holding the registry can read it whole.
1123 * Own registry + warm replicas: a replica app's newest releases are kept too.
1124 */
1125 async _storeGc ({ dryRun = false, keepReleases = KEEP_RELEASES } = {}) {
1126 const releases = await this._registry.list()
1127 for (const r of this._releaseReplicas) {
1128 try { releases.push(...(await r.registry.list())) } catch {}
1129 }
1130 return this._treeStore.gc({ dryRun, maxAgeDays: 14, keepReleases, releases })
1131 }
1132
11141133 _sweepLeftoverState () {
11151134 const ONE_HOUR = 60 * 60 * 1000
11161135 const FOURTEEN_DAYS = 14 * 24 * ONE_HOUR
……@@ -1386,6 +1405,21 @@ export class Sidecar {
13861405 }
13871406 })
13881407
1408 // `pgh store gc --keep-releases N` lands here (see _storeGc for why).
1409 svc.handle('store-gc', async (ctx, params = {}) => {
1410 const keep = parseInt(params.keepReleases, 10)
1411 const r = await this._storeGc({
1412 dryRun: !!params.dryRun,
1413 keepReleases: Number.isFinite(keep) && keep > 0 ? keep : KEEP_RELEASES
1414 })
1415 return {
1416 candidates: r.candidates,
1417 removed: r.removed,
1418 kept: r.kept.length,
1419 blobsRemoved: r.blobsRemoved
1420 }
1421 })
1422
13891423 svc.handle('store-stats', async () => {
13901424 return this._treeStore.stats()
13911425 })
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh-sidecar",
3 "version": "0.2.63",
3 "version": "0.2.64",
44 "description": "Small optional user-level daemon for pgh \u2014 shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host",
55 "type": "module",
66 "main": "lib/index.js",
test/store-gc-keep-releases.test.js +59 -0
……@@ -0,0 +1,59 @@
1/**
2 * store-gc RPC — release retention runs inside the sidecar (it holds the
3 * registry; the full release list overflows the UDS frame for a CLI).
4 * 2026-10-11: the 14-day age gate kept 75 hyper-tps .pck copies (~44 GiB).
5 */
6
7import test from 'brittle'
8import fs from '#node:fs'
9import path from '#node:path'
10import os from '#node:os'
11
12import { Client } from 'hyper-svc'
13import { Sidecar } from '../lib/index.js'
14
15function mktemp (prefix) {
16 return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
17}
18
19const h = (c) => c.repeat(64)
20
21test('store-gc keeps the newest 2 releases per app and sweeps older ones, fresh or not', async (t) => {
22 const root = mktemp('sidecar-root')
23 const storeRoot = mktemp('sidecar-store')
24 const sidecar = new Sidecar({
25 root,
26 socketPath: path.join(root, 'svc.sock'),
27 storeRoot,
28 registryStore: mktemp('sidecar-registry'),
29 nameRegistryStore: mktemp('sidecar-names'),
30 releaseRegistryReplicasDir: mktemp('sidecar-replicas'),
31 enableSeed: false,
32 passphrase: 'test-passphrase'
33 })
34 await sidecar.start()
35 const client = await Client.connectUds(path.join(root, 'svc.sock'))
36
37 // Three releases of one app; every tree pulled just now (the age gate would keep all).
38 // v1 = root a + dep b; v2 = root c + dep b (shared); v3 = root d + prebuild e.
39 for (const c of ['a', 'b', 'c', 'd', 'e']) {
40 fs.mkdirSync(sidecar.treeStore.treePath(h(c)), { recursive: true })
41 sidecar.treeStore._writeMeta(h(c), { pulledAt: Date.now(), schema: 2 })
42 }
43 const rel = (version, publishedAt, treeHash, deps) =>
44 client.call('publish-release', { entry: { name: 'app', version, publishedAt, treeHash, deps } })
45 await rel('1', '2026-10-01T00:00:00.000Z', h('a'), [{ name: 'x', version: '1', tree: h('b') }])
46 await rel('2', '2026-10-02T00:00:00.000Z', h('c'), [{ name: 'x', version: '1', tree: h('b') }])
47 await rel('3', '2026-10-03T00:00:00.000Z', h('d'), [{ name: 'y', version: '1', tree: null, prebuilds: { 'linux-x64': h('e') } }])
48
49 const dry = await client.call('store-gc', { dryRun: true, keepReleases: 2 })
50 t.alike(dry.candidates, [h('a')], 'dry run: only v1\'s own root is a candidate (b is shared with v2)')
51 t.ok(sidecar.treeStore.hasTree(h('a')), 'dry run deletes nothing')
52
53 const real = await client.call('store-gc', { keepReleases: 2 })
54 t.alike(real.removed, [h('a')], 'v1 root removed')
55 for (const c of ['b', 'c', 'd', 'e']) t.ok(sidecar.treeStore.hasTree(h(c)), 'kept ' + c)
56
57 await client.close()
58 await sidecar.stop()
59})