@vzn/vx 0.0.643 → 0.0.645
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +15 -23
- package/package.json +5 -5
- package/schemas/info.json +0 -11
- package/schemas/init.json +5 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/cache/archive.ts +89 -2
- package/src/cache/cache.ts +396 -176
- package/src/cache/file-hashes.ts +2 -14
- package/src/cache/fold-memo.ts +7 -3
- package/src/cache/index.ts +4 -2
- package/src/cache/key-fold.ts +4 -1
- package/src/cache/layer.ts +14 -10
- package/src/cache/layered-cache.ts +157 -41
- package/src/cache/plugin-cache.ts +40 -44
- package/src/cache/remote-lane.ts +59 -25
- package/src/cache/scoped-cache.ts +3 -80
- package/src/cache/tar-stream.ts +12 -7
- package/src/cli/format.ts +1 -2
- package/src/cli/help.ts +10 -4
- package/src/cli/info.ts +2 -19
- package/src/cli/init.ts +121 -20
- package/src/cli/plan-format.ts +6 -2
- package/src/cli/plugin-commands.ts +4 -5
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +3 -1
- package/src/cli/upgrade.ts +39 -9
- package/src/config.ts +13 -10
- package/src/exec/sandbox-paths.ts +2 -2
- package/src/graph/scheduler.ts +3 -1
- package/src/graph/task-graph.ts +15 -11
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +0 -18
- package/src/orchestrator/doctor.ts +0 -17
- package/src/orchestrator/execute-task.ts +1 -1
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/options.ts +6 -8
- package/src/orchestrator/plan.ts +7 -1
- package/src/orchestrator/plugin-host.ts +6 -5
- package/src/orchestrator/plugin.ts +12 -5
- package/src/orchestrator/prepare.ts +16 -46
- package/src/orchestrator/projects.ts +8 -1
- package/src/orchestrator/remote-prefetch.ts +11 -4
- package/src/orchestrator/run-context.ts +48 -5
- package/src/orchestrator/run-settings.ts +3 -0
- package/src/orchestrator/run.ts +10 -26
- package/src/orchestrator/task-hash.ts +1 -3
- package/src/report/logger.ts +0 -4
- package/src/report/summary.ts +14 -13
- package/src/settings.ts +38 -15
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +42 -33
- package/src/util/size.ts +4 -5
- package/src/workspace/affected.ts +0 -39
- package/src/workspace/config-schema.ts +0 -36
- package/src/workspace/index.ts +0 -1
package/src/cache/cache.ts
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
utimesSync,
|
|
50
50
|
writeFileSync,
|
|
51
51
|
} from 'node:fs'
|
|
52
|
-
import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
52
|
+
import { chmod, lstat, mkdir, readdir, rm, stat, unlink, utimes, writeFile } from 'node:fs/promises'
|
|
53
53
|
import path from 'node:path'
|
|
54
54
|
import { createArtifactTables, createTables, inHashes, lazyStatement } from './schema.js'
|
|
55
55
|
import {
|
|
@@ -76,7 +76,11 @@ import {
|
|
|
76
76
|
packArtifactBytes,
|
|
77
77
|
packArtifactStream,
|
|
78
78
|
planArtifact,
|
|
79
|
+
planOfTree,
|
|
80
|
+
sealTree,
|
|
81
|
+
writeTree,
|
|
79
82
|
} from './archive.js'
|
|
83
|
+
import { readUmask, restoredMode } from './umask.js'
|
|
80
84
|
import {
|
|
81
85
|
ArtifactVanishedError,
|
|
82
86
|
type CacheEntry,
|
|
@@ -119,7 +123,7 @@ import { behindLink } from './linked-inputs.js'
|
|
|
119
123
|
* An artifact or temp file without an `entries` row is never reaped
|
|
120
124
|
* younger than this: a crashed save's temp is stale long before it. A
|
|
121
125
|
* row-less artifact may still be in use (another vx version's open dropped
|
|
122
|
-
* its row), so past this it is judged by the
|
|
126
|
+
* its row), so past this it is judged by the prune policy, its file
|
|
123
127
|
* time standing for `accessed_at`. A hit renews a file time older than
|
|
124
128
|
* this, so the file time trails the last use by at most this much, and
|
|
125
129
|
* the policy reads the file time plus this as the last use.
|
|
@@ -137,7 +141,7 @@ const ADOPT_STATS = 64
|
|
|
137
141
|
* A hit renews an entry's `accessed_at` only once it is older than this:
|
|
138
142
|
* renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
|
|
139
143
|
* close, ~30 ms of it. So `accessed_at` trails the last use by at most
|
|
140
|
-
* this much, and the
|
|
144
|
+
* this much, and the prune policy reads it plus this as the last use:
|
|
141
145
|
* an entry used within the limit is never pruned, and one may stay up to
|
|
142
146
|
* this long past it (owner, 2026-10-08).
|
|
143
147
|
*/
|
|
@@ -150,7 +154,7 @@ const ACCESS_REFRESH_MS = 60 * 60 * 1000
|
|
|
150
154
|
* directory), and the sweep once took any `*.tar.zst` an hour old there,
|
|
151
155
|
* the user's own release tarball included (item 968).
|
|
152
156
|
*/
|
|
153
|
-
const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
|
|
157
|
+
const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.(?:tar\.zst|tree)(\.tmp-\d+-\d+-[0-9a-z]*)?$/
|
|
154
158
|
|
|
155
159
|
/** What vx writes in a cache directory besides its artifacts. */
|
|
156
160
|
const VX_CACHE_NAMES = new Set([
|
|
@@ -186,8 +190,8 @@ export function vxCacheEntries(cacheDir: string): string[] {
|
|
|
186
190
|
})
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
/**
|
|
190
|
-
const
|
|
193
|
+
/** What `vx cache clean` keeps of run history and the hash memos. */
|
|
194
|
+
const HISTORY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
|
191
195
|
|
|
192
196
|
export interface SchemaReset {
|
|
193
197
|
from: string
|
|
@@ -295,7 +299,7 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
295
299
|
// output_files' ino + ctime_ms moved to the workspace's own
|
|
296
300
|
// output_stamps, output_dirs lost its foreign key, and the value
|
|
297
301
|
// salt moved to store_meta. The cache KEY is unchanged.
|
|
298
|
-
// v33: an artifact of at most
|
|
302
|
+
// v33: an artifact of at most 32 KiB compressed lives in the
|
|
299
303
|
// store's `artifacts` table, written in its rows' transaction, and
|
|
300
304
|
// every entry read joins it. The artifact bytes and the cache KEY are
|
|
301
305
|
// unchanged (CACHE_VERSION not bumped).
|
|
@@ -370,13 +374,6 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
|
|
|
370
374
|
*/
|
|
371
375
|
const ARTIFACT_LAYOUT = 'a2'
|
|
372
376
|
|
|
373
|
-
/**
|
|
374
|
-
* The largest compressed artifact kept in the index rather than in a file
|
|
375
|
-
* of its own: it is written in its rows' transaction, with no temp and no
|
|
376
|
-
* rename (docs/design/cache-save-cpu-2026-10.md).
|
|
377
|
-
*/
|
|
378
|
-
const INLINE_MAX = 32 * 1024
|
|
379
|
-
|
|
380
377
|
/**
|
|
381
378
|
* An index that records a schema other than this vx's: the one an opener
|
|
382
379
|
* that writes resets. Reads the recorded version alone and touches
|
|
@@ -509,8 +506,9 @@ async function scanOrphanFiles(
|
|
|
509
506
|
const file = path.join(cacheDir, name)
|
|
510
507
|
try {
|
|
511
508
|
const st = await stat(file)
|
|
512
|
-
if (st.isFile() && st.mtimeMs <= cutoff) {
|
|
513
|
-
|
|
509
|
+
if ((name.includes('.tree') ? st.isDirectory() : st.isFile()) && st.mtimeMs <= cutoff) {
|
|
510
|
+
const size = st.isDirectory() ? await treeBytes(file) : st.size
|
|
511
|
+
found.push({ file, size, mtimeMs: st.mtimeMs, temp })
|
|
514
512
|
}
|
|
515
513
|
} catch {
|
|
516
514
|
// Gone between readdir and stat: not an orphan any more.
|
|
@@ -520,6 +518,16 @@ async function scanOrphanFiles(
|
|
|
520
518
|
return found
|
|
521
519
|
}
|
|
522
520
|
|
|
521
|
+
/** The bytes a tree-form artifact's files hold. */
|
|
522
|
+
async function treeBytes(dir: string): Promise<number> {
|
|
523
|
+
let n = 0
|
|
524
|
+
for (const e of await readdir(dir, { recursive: true, withFileTypes: true }).catch(() => [])) {
|
|
525
|
+
if (e.isFile())
|
|
526
|
+
n += (await stat(path.join(e.parentPath, e.name)).catch(() => ({ size: 0 }))).size
|
|
527
|
+
}
|
|
528
|
+
return n
|
|
529
|
+
}
|
|
530
|
+
|
|
523
531
|
/**
|
|
524
532
|
* SQL predicate selecting `runs` rows that record an EXECUTION.
|
|
525
533
|
*
|
|
@@ -613,7 +621,7 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
|
|
|
613
621
|
* it there", and the ignore file is created exclusively instead of probed
|
|
614
622
|
* first. An existing cache costs three calls; it cost six.
|
|
615
623
|
*/
|
|
616
|
-
function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
624
|
+
function openCacheDir(cacheDir: string, workspaceRoot?: string, index = 'cache.db'): string | null {
|
|
617
625
|
const [ignore, ignoreText] = ignoreFileFor(cacheDir)
|
|
618
626
|
try {
|
|
619
627
|
accessSync(cacheDir, constants.W_OK)
|
|
@@ -624,7 +632,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
|
624
632
|
return null
|
|
625
633
|
}
|
|
626
634
|
try {
|
|
627
|
-
accessSync(path.join(cacheDir,
|
|
635
|
+
accessSync(path.join(cacheDir, index), constants.W_OK)
|
|
628
636
|
} catch (err) {
|
|
629
637
|
const code = (err as NodeJS.ErrnoException).code
|
|
630
638
|
// A FILE at `cacheDir` passed the `access` above; mkdir names it.
|
|
@@ -649,7 +657,7 @@ function storeBlocked(storeDir: string, storeRoot: string | null): string | null
|
|
|
649
657
|
if (refused !== null) return refused
|
|
650
658
|
}
|
|
651
659
|
}
|
|
652
|
-
return openCacheDir(storeDir)
|
|
660
|
+
return openCacheDir(storeDir, undefined, 'store.db')
|
|
653
661
|
} catch (err) {
|
|
654
662
|
return errorText(err)
|
|
655
663
|
}
|
|
@@ -788,6 +796,30 @@ function errorText(err: unknown): string {
|
|
|
788
796
|
return err instanceof Error ? err.message : String(err)
|
|
789
797
|
}
|
|
790
798
|
|
|
799
|
+
/** A tree the save wrote from its own outputs, with the scan its tar would give. */
|
|
800
|
+
interface SavedTree {
|
|
801
|
+
tree: string
|
|
802
|
+
scan: Awaited<ReturnType<typeof scanArtifact>>
|
|
803
|
+
bytes: number
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** How a save keeps an entry locally (`cache.store`). */
|
|
807
|
+
export interface StoreOptions {
|
|
808
|
+
/**
|
|
809
|
+
* The largest entry kept in the index, as one zstd tar (fewer files to
|
|
810
|
+
* make): written in its rows' transaction, with no temp and no rename
|
|
811
|
+
* (docs/design/cache-save-cpu-2026-10.md). 0 keeps every entry as files.
|
|
812
|
+
*/
|
|
813
|
+
embeddings: number
|
|
814
|
+
/** A file-backed entry is a `<hash>.tree` of plain files; off, one `<hash>.tar.zst`. */
|
|
815
|
+
decompress: boolean
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export const DEFAULT_STORE: StoreOptions = {
|
|
819
|
+
embeddings: 32 * 1024,
|
|
820
|
+
decompress: true,
|
|
821
|
+
}
|
|
822
|
+
|
|
791
823
|
/** What a run's background uploads moved: totals over the ones that landed. */
|
|
792
824
|
export interface UploadTally {
|
|
793
825
|
count: number
|
|
@@ -823,6 +855,8 @@ export class Cache implements CacheLayer {
|
|
|
823
855
|
* so the run can report them without a method on the layer seam.
|
|
824
856
|
*/
|
|
825
857
|
readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
|
|
858
|
+
/** The run's hits pulled from those remotes, and their bytes on the wire. */
|
|
859
|
+
readonly downloads: { count: number; bytes: number } = { count: 0, bytes: 0 }
|
|
826
860
|
/** The same remotes' traffic per key, filled by the same `LayeredCache`. */
|
|
827
861
|
readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
|
|
828
862
|
|
|
@@ -939,6 +973,7 @@ export class Cache implements CacheLayer {
|
|
|
939
973
|
* records none (deleted, or never written beside a run's store).
|
|
940
974
|
*/
|
|
941
975
|
storeRoot?: string | null,
|
|
976
|
+
private readonly store: StoreOptions = DEFAULT_STORE,
|
|
942
977
|
) {
|
|
943
978
|
this.inspecting = mode !== 'open'
|
|
944
979
|
this.read = localPolicy.read
|
|
@@ -1677,7 +1712,7 @@ export class Cache implements CacheLayer {
|
|
|
1677
1712
|
} else {
|
|
1678
1713
|
// Verify the artifact file actually exists. The DB and the
|
|
1679
1714
|
// filesystem can drift if someone manually deletes the cache dir.
|
|
1680
|
-
const st =
|
|
1715
|
+
const st = this.artifactStat(hash)
|
|
1681
1716
|
if (st === undefined) return null
|
|
1682
1717
|
if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
|
|
1683
1718
|
}
|
|
@@ -1717,7 +1752,7 @@ export class Cache implements CacheLayer {
|
|
|
1717
1752
|
names = []
|
|
1718
1753
|
}
|
|
1719
1754
|
this.artifactNames ??= new Set(names)
|
|
1720
|
-
return !names.some((n) => n.endsWith('.tar.zst'))
|
|
1755
|
+
return !names.some((n) => n.endsWith('.tar.zst') || n.endsWith('.tree'))
|
|
1721
1756
|
}
|
|
1722
1757
|
|
|
1723
1758
|
/**
|
|
@@ -1766,7 +1801,7 @@ export class Cache implements CacheLayer {
|
|
|
1766
1801
|
if (r.inline_at < graceStart) this.staleInline.add(r.hash)
|
|
1767
1802
|
return true
|
|
1768
1803
|
}
|
|
1769
|
-
const st =
|
|
1804
|
+
const st = this.artifactStat(r.hash)
|
|
1770
1805
|
if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
|
|
1771
1806
|
return st !== undefined
|
|
1772
1807
|
})
|
|
@@ -1811,7 +1846,7 @@ export class Cache implements CacheLayer {
|
|
|
1811
1846
|
private storedHere(hash: string): boolean {
|
|
1812
1847
|
const row = this.entryExists.get(hash) as { inline_at: number | null } | null
|
|
1813
1848
|
if (row === null) return false
|
|
1814
|
-
return row.inline_at !== null ||
|
|
1849
|
+
return row.inline_at !== null || this.artifactStat(hash) !== undefined
|
|
1815
1850
|
}
|
|
1816
1851
|
|
|
1817
1852
|
// Local cache has no slower layer to warm from — prefetch is a no-op.
|
|
@@ -1886,9 +1921,104 @@ export class Cache implements CacheLayer {
|
|
|
1886
1921
|
}
|
|
1887
1922
|
}
|
|
1888
1923
|
|
|
1924
|
+
/**
|
|
1925
|
+
* Restore a tree-form entry: its files copied (or cloned) out one by
|
|
1926
|
+
* one where the outputs are absent, as on every restore after a clean. Anything else (a tree to
|
|
1927
|
+
* restore into, a workspace output, a link) goes through the extractor
|
|
1928
|
+
* the tar form uses, fed the tree packed back into its tar.
|
|
1929
|
+
*/
|
|
1930
|
+
private async restoreTree(
|
|
1931
|
+
hash: string,
|
|
1932
|
+
tree: string,
|
|
1933
|
+
rows: readonly OutputFileRow[],
|
|
1934
|
+
projectDir: string,
|
|
1935
|
+
workspaceRoot: string | undefined,
|
|
1936
|
+
verify: (provided: ReadonlySet<string>) => void,
|
|
1937
|
+
accept: ((name: string) => boolean) | undefined,
|
|
1938
|
+
): Promise<void> {
|
|
1939
|
+
const files = rows.filter((r) => !r.path.endsWith('/') && r.mode !== LINK_MODE)
|
|
1940
|
+
let bytes = 0
|
|
1941
|
+
for (const r of files) bytes += r.size
|
|
1942
|
+
if (bytes > this.artifactCeiling) {
|
|
1943
|
+
throw new CorruptArtifactError(
|
|
1944
|
+
hash,
|
|
1945
|
+
`its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
|
|
1946
|
+
)
|
|
1947
|
+
}
|
|
1948
|
+
if (await this.copyTree(tree, rows, files, projectDir, accept)) return
|
|
1949
|
+
await extractArtifactStream(
|
|
1950
|
+
packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
|
|
1951
|
+
projectDir,
|
|
1952
|
+
workspaceRoot,
|
|
1953
|
+
verify,
|
|
1954
|
+
accept,
|
|
1955
|
+
workspaceRoot,
|
|
1956
|
+
)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** The copy-out: false, having written nothing, where it is not the case it serves. */
|
|
1960
|
+
private async copyTree(
|
|
1961
|
+
tree: string,
|
|
1962
|
+
rows: readonly OutputFileRow[],
|
|
1963
|
+
files: readonly OutputFileRow[],
|
|
1964
|
+
projectDir: string,
|
|
1965
|
+
accept: ((name: string) => boolean) | undefined,
|
|
1966
|
+
): Promise<boolean> {
|
|
1967
|
+
const tops = new Set<string>()
|
|
1968
|
+
for (const r of rows) {
|
|
1969
|
+
if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
|
|
1970
|
+
if (accept !== undefined && !r.path.endsWith('/') && !accept(`outputs/${r.path}`))
|
|
1971
|
+
return false
|
|
1972
|
+
tops.add(r.path.split('/')[0]!)
|
|
1973
|
+
}
|
|
1974
|
+
const held = await Promise.all(
|
|
1975
|
+
[...tops].map((t) =>
|
|
1976
|
+
lstat(path.join(projectDir, t)).then(
|
|
1977
|
+
() => true,
|
|
1978
|
+
() => false,
|
|
1979
|
+
),
|
|
1980
|
+
),
|
|
1981
|
+
)
|
|
1982
|
+
if (held.includes(true)) return false
|
|
1983
|
+
readUmask()
|
|
1984
|
+
const plain = restoredMode(0o666)
|
|
1985
|
+
try {
|
|
1986
|
+
const dirs = new Set<string>()
|
|
1987
|
+
for (const r of rows) {
|
|
1988
|
+
dirs.add(path.join(projectDir, r.path.endsWith('/') ? r.path : path.dirname(r.path)))
|
|
1989
|
+
}
|
|
1990
|
+
for (const d of dirs) await mkdir(d, { recursive: true })
|
|
1991
|
+
await Promise.all(
|
|
1992
|
+
files.map(async (r) => {
|
|
1993
|
+
const to = path.join(projectDir, r.path)
|
|
1994
|
+
await Bun.write(to, Bun.file(path.join(tree, treeName(r.path))), { createPath: false })
|
|
1995
|
+
const mode = restoredMode(r.mode)
|
|
1996
|
+
if (mode !== plain) await chmod(to, mode)
|
|
1997
|
+
const at = new Date(r.mtimeMs)
|
|
1998
|
+
await utimes(to, at, at)
|
|
1999
|
+
}),
|
|
2000
|
+
)
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
await Promise.all(
|
|
2003
|
+
[...tops].map((t) => rm(path.join(projectDir, t), { recursive: true, force: true })),
|
|
2004
|
+
)
|
|
2005
|
+
throw err
|
|
2006
|
+
}
|
|
2007
|
+
return true
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
private stdoutText(hash: string): string {
|
|
2011
|
+
const row = this.db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(hash) as {
|
|
2012
|
+
stdout: string | Uint8Array
|
|
2013
|
+
} | null
|
|
2014
|
+
if (row === null) return ''
|
|
2015
|
+
return typeof row.stdout === 'string' ? row.stdout : utf8Decode.decode(row.stdout)
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1889
2018
|
/** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
|
|
1890
2019
|
private async dropEntry(hash: string): Promise<void> {
|
|
1891
2020
|
await rm(this.tarPath(hash), { force: true })
|
|
2021
|
+
await rm(this.treePath(hash), { recursive: true, force: true })
|
|
1892
2022
|
const drop = this.db.transaction(() => {
|
|
1893
2023
|
this.deleteBlob.run(hash)
|
|
1894
2024
|
this.deleteEntryRow.run(hash)
|
|
@@ -1924,7 +2054,7 @@ export class Cache implements CacheLayer {
|
|
|
1924
2054
|
// outputs, so a restore that did not fail here would report a green cache
|
|
1925
2055
|
// hit over an emptied output tree. The artifact existed when `get()`
|
|
1926
2056
|
// probed it, so its absence now means something removed it underneath us
|
|
1927
|
-
// (a `vx cache clean` in another shell, or another workspace
|
|
2057
|
+
// (a `vx cache clean` in another shell, here or for another workspace
|
|
1928
2058
|
// on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
|
|
1929
2059
|
// turns into a miss: the task runs.
|
|
1930
2060
|
//
|
|
@@ -1986,6 +2116,8 @@ export class Cache implements CacheLayer {
|
|
|
1986
2116
|
// when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
|
|
1987
2117
|
// per 1 000). The local artifact was validated at ingest, so a missing
|
|
1988
2118
|
// declared size is allowed; the output ceiling applies to both.
|
|
2119
|
+
const tree =
|
|
2120
|
+
inline === undefined && existsSync(this.treePath(hash)) ? this.treePath(hash) : undefined
|
|
1989
2121
|
const endExtract = span('restore: extract')
|
|
1990
2122
|
try {
|
|
1991
2123
|
// Every step here is an async filesystem call on purpose: a
|
|
@@ -1993,15 +2125,13 @@ export class Cache implements CacheLayer {
|
|
|
1993
2125
|
// 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
|
|
1994
2126
|
// four workers overlap their round trips and a blocking one stalls
|
|
1995
2127
|
// the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
|
|
1996
|
-
const
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
verify,
|
|
2002
|
-
|
|
2003
|
-
workspaceRoot,
|
|
2004
|
-
)
|
|
2128
|
+
const accept = outputs === undefined ? undefined : declaredName(outputs)
|
|
2129
|
+
if (tree !== undefined) {
|
|
2130
|
+
await this.restoreTree(hash, tree, rows, projectDir, workspaceRoot, verify, accept)
|
|
2131
|
+
} else {
|
|
2132
|
+
const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
|
|
2133
|
+
await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
|
|
2134
|
+
}
|
|
2005
2135
|
} catch (err) {
|
|
2006
2136
|
// A UserError here is the extractor naming the tree's fault (an
|
|
2007
2137
|
// output directory that links out of the project).
|
|
@@ -2052,7 +2182,13 @@ export class Cache implements CacheLayer {
|
|
|
2052
2182
|
// artifact is intact, the tree is not the process's to change.
|
|
2053
2183
|
// A refusal to READ the artifact is the cache directory's, not the
|
|
2054
2184
|
// outputs': the old line sent the reader to the wrong tree (A-40).
|
|
2055
|
-
|
|
2185
|
+
// A tree copy's error can name its source; no read fails ENOSPC.
|
|
2186
|
+
if (
|
|
2187
|
+
isFsRefusal(err) &&
|
|
2188
|
+
code !== 'ENOSPC' &&
|
|
2189
|
+
code !== 'EDQUOT' &&
|
|
2190
|
+
(err.path === src || (tree !== undefined && inTree(err.path, tree)))
|
|
2191
|
+
) {
|
|
2056
2192
|
throw new UserError(
|
|
2057
2193
|
`restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
|
|
2058
2194
|
`Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
|
|
@@ -2164,23 +2300,21 @@ export class Cache implements CacheLayer {
|
|
|
2164
2300
|
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
2165
2301
|
// round trips for an EEXIST and a stat (item 630).
|
|
2166
2302
|
const endPack = span('save: pack')
|
|
2167
|
-
const packed = await this.
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
},
|
|
2178
|
-
)
|
|
2303
|
+
const packed = await this.packForSave(args).catch((err: unknown) => {
|
|
2304
|
+
// A save that could not open an output names the limit, not only
|
|
2305
|
+
// the file, which is fine (A-39).
|
|
2306
|
+
if (isOutOfFds(err)) {
|
|
2307
|
+
throw new UserError(
|
|
2308
|
+
`save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
|
|
2309
|
+
)
|
|
2310
|
+
}
|
|
2311
|
+
throw err
|
|
2312
|
+
})
|
|
2179
2313
|
endPack()
|
|
2180
2314
|
await this.guard(() =>
|
|
2181
2315
|
this.writeArtifactAndIndex(
|
|
2182
2316
|
args.hash,
|
|
2183
|
-
'tar' in packed ? packed.compressed : packed,
|
|
2317
|
+
'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
|
|
2184
2318
|
{
|
|
2185
2319
|
taskId: args.entry.taskId,
|
|
2186
2320
|
command: args.entry.command,
|
|
@@ -2220,11 +2354,30 @@ export class Cache implements CacheLayer {
|
|
|
2220
2354
|
* gone; `release` unlinks the name. An inline artifact is read once into
|
|
2221
2355
|
* the body, which no re-save can change.
|
|
2222
2356
|
*/
|
|
2223
|
-
pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
|
|
2357
|
+
async pinArtifact(hash: string): Promise<{ body: Blob; release: () => Promise<void> }> {
|
|
2224
2358
|
const blob = this.guard(() => this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
|
|
2225
2359
|
if (blob !== null) return { body: new Blob([blob.bytes]), release: async () => {} }
|
|
2226
2360
|
const pinned = this.tempPath(hash)
|
|
2227
|
-
|
|
2361
|
+
try {
|
|
2362
|
+
linkSync(this.tarPath(hash), pinned)
|
|
2363
|
+
} catch (err) {
|
|
2364
|
+
const tree = this.treePath(hash)
|
|
2365
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
|
|
2366
|
+
// A tree is packed into the artifact a remote expects here, in the
|
|
2367
|
+
// upload queue, off the save; a small one in memory.
|
|
2368
|
+
const plan = planOfTree(tree, this.stdoutText(hash))
|
|
2369
|
+
if (plan.size <= STREAM_DECODE_FROM) {
|
|
2370
|
+
const bytes = await zstdCompress(await packArtifactBytes(plan))
|
|
2371
|
+
return { body: new Blob([bytes]), release: async () => {} }
|
|
2372
|
+
}
|
|
2373
|
+
await Bun.write(
|
|
2374
|
+
pinned,
|
|
2375
|
+
new Response(packArtifactStream(plan).pipeThrough(zstdEncoder())),
|
|
2376
|
+
).catch(async (e: unknown) => {
|
|
2377
|
+
await unlink(pinned).catch(() => undefined)
|
|
2378
|
+
throw e
|
|
2379
|
+
})
|
|
2380
|
+
}
|
|
2228
2381
|
return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
|
|
2229
2382
|
}
|
|
2230
2383
|
|
|
@@ -2233,7 +2386,7 @@ export class Cache implements CacheLayer {
|
|
|
2233
2386
|
* `Response` and copies a file `Blob` without collecting either, so a
|
|
2234
2387
|
* pull never holds the artifact. A body that fails mid-stream (a dropped
|
|
2235
2388
|
* socket) leaves a partial temp behind it, removed here; validation
|
|
2236
|
-
* removes its own. A body of at most `
|
|
2389
|
+
* removes its own. A body of at most `cache.store.embeddings` bytes — by its size,
|
|
2237
2390
|
* or counted as it streams — is read to memory and stored inline.
|
|
2238
2391
|
*/
|
|
2239
2392
|
async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
@@ -2253,22 +2406,28 @@ export class Cache implements CacheLayer {
|
|
|
2253
2406
|
let counted: ReadableStream<Uint8Array> | undefined
|
|
2254
2407
|
let reader: ChunkReader | undefined
|
|
2255
2408
|
let small: Uint8Array | undefined
|
|
2409
|
+
// A store of plain files holds a body up to the bulk decode's bound:
|
|
2410
|
+
// fetched whole, it is decoded in one call straight into the tree, with
|
|
2411
|
+
// no temp archive between (`writeArtifactAndIndex`).
|
|
2412
|
+
const held = this.store.decompress
|
|
2413
|
+
? Math.max(this.store.embeddings, STREAM_DECODE_FROM)
|
|
2414
|
+
: this.store.embeddings
|
|
2256
2415
|
try {
|
|
2257
2416
|
const length =
|
|
2258
2417
|
body instanceof Blob ? body.size : Number(body.headers.get('content-length') ?? 0)
|
|
2259
2418
|
if (length > cap) throw past()
|
|
2260
2419
|
if (body instanceof Blob) {
|
|
2261
|
-
if (length <=
|
|
2420
|
+
if (length <= held && held > 0) small = await body.bytes()
|
|
2262
2421
|
else await Bun.write(tmpPath, body)
|
|
2263
2422
|
} else {
|
|
2264
2423
|
let source = body.body
|
|
2265
2424
|
if (source === null) small = new Uint8Array(0)
|
|
2266
|
-
else if (length <=
|
|
2425
|
+
else if (length <= held && held > 0) {
|
|
2267
2426
|
// Counted as it streams: a length the server did not send, or
|
|
2268
|
-
// one it sent short, cannot
|
|
2427
|
+
// one it sent short, cannot read a large body to memory.
|
|
2269
2428
|
const r: ChunkReader = source.getReader()
|
|
2270
2429
|
reader = r
|
|
2271
|
-
const head = await readUpTo(r,
|
|
2430
|
+
const head = await readUpTo(r, held)
|
|
2272
2431
|
if (head.done) small = head.bytes
|
|
2273
2432
|
else source = prepended(head.bytes, r)
|
|
2274
2433
|
}
|
|
@@ -2406,6 +2565,36 @@ export class Cache implements CacheLayer {
|
|
|
2406
2565
|
return { tmpPath }
|
|
2407
2566
|
}
|
|
2408
2567
|
|
|
2568
|
+
/**
|
|
2569
|
+
* The save's local form: a small entry packed for the index when the
|
|
2570
|
+
* store embeds, else a `.tar.zst` (`decompress: false`) or a tree of the outputs
|
|
2571
|
+
* copied as they are.
|
|
2572
|
+
*/
|
|
2573
|
+
private async packForSave(
|
|
2574
|
+
args: Parameters<Cache['packArtifact']>[0],
|
|
2575
|
+
): Promise<
|
|
2576
|
+
| { compressed: Uint8Array; tar: Uint8Array }
|
|
2577
|
+
| { tmpPath: string }
|
|
2578
|
+
| { plan: true; saved: SavedTree }
|
|
2579
|
+
> {
|
|
2580
|
+
if (!this.store.decompress) return this.packArtifactToTemp(this.tempPath(args.hash), args)
|
|
2581
|
+
const plan = await this.planWithin(args)
|
|
2582
|
+
if (this.store.embeddings > 0 && plan.size <= this.store.embeddings) {
|
|
2583
|
+
const tar = await packArtifactBytes(plan)
|
|
2584
|
+
return { compressed: await zstdCompress(tar), tar }
|
|
2585
|
+
}
|
|
2586
|
+
const tree = this.tempTreePath(args.hash)
|
|
2587
|
+
try {
|
|
2588
|
+
const scan = await writeTree(plan, tree)
|
|
2589
|
+
let bytes = 0
|
|
2590
|
+
for (const e of scan.entries) bytes += e.size
|
|
2591
|
+
return { plan: true, saved: { tree, scan, bytes } }
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
await rm(tree, { recursive: true, force: true })
|
|
2594
|
+
throw err
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2409
2598
|
/** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
|
|
2410
2599
|
* the same hash from the same process (or from two forked workers that
|
|
2411
2600
|
* happen to share a wall-clock ms) don't pick the same tmp filename and
|
|
@@ -2416,7 +2605,7 @@ export class Cache implements CacheLayer {
|
|
|
2416
2605
|
|
|
2417
2606
|
/**
|
|
2418
2607
|
* Atomically write `compressed` to `<hash>.tar.zst`, or inline into
|
|
2419
|
-
* `artifacts` when it is at most `
|
|
2608
|
+
* `artifacts` when it is at most `cache.store.embeddings` bytes, and (re)build the
|
|
2420
2609
|
* entries + output_files SQL rows from the archive itself. Shared by
|
|
2421
2610
|
* `save()` (we just packed the bytes) and `ingest()` (it streamed them
|
|
2422
2611
|
* from the remote layer into a temp) — both index the identical values, because both
|
|
@@ -2424,7 +2613,7 @@ export class Cache implements CacheLayer {
|
|
|
2424
2613
|
*/
|
|
2425
2614
|
private async writeArtifactAndIndex(
|
|
2426
2615
|
hash: string,
|
|
2427
|
-
|
|
2616
|
+
given: Uint8Array | { tmpPath: string } | SavedTree,
|
|
2428
2617
|
meta: IngestMeta,
|
|
2429
2618
|
/**
|
|
2430
2619
|
* The tar `compressed` was made from, when this process packed it: it is
|
|
@@ -2441,12 +2630,25 @@ export class Cache implements CacheLayer {
|
|
|
2441
2630
|
// restore, so the size/mode/mtime fingerprint we store matches
|
|
2442
2631
|
// what isOutputsCurrent will compare against post-restore.
|
|
2443
2632
|
const finalPath = this.tarPath(hash)
|
|
2633
|
+
const saved = 'tree' in given ? given : undefined
|
|
2634
|
+
const compressed = 'tree' in given ? new Uint8Array(0) : given
|
|
2444
2635
|
// An inline artifact is written in the rows' transaction below: no
|
|
2445
2636
|
// temp, no rename.
|
|
2446
2637
|
const inline =
|
|
2447
|
-
|
|
2638
|
+
saved === undefined &&
|
|
2639
|
+
this.store.embeddings > 0 &&
|
|
2640
|
+
compressed instanceof Uint8Array &&
|
|
2641
|
+
compressed.byteLength <= this.store.embeddings
|
|
2642
|
+
? compressed
|
|
2643
|
+
: null
|
|
2644
|
+
// A remote hit this store keeps as plain files is decoded once, into the
|
|
2645
|
+
// tree, and checked there: nothing names the tree until the rows go in.
|
|
2646
|
+
const oneTree =
|
|
2647
|
+
saved === undefined && inline === null && tar === undefined && this.store.decompress
|
|
2448
2648
|
let tmpPath: string | undefined
|
|
2449
|
-
if (
|
|
2649
|
+
if (saved !== undefined) {
|
|
2650
|
+
// The tree save wrote and scanned its own outputs.
|
|
2651
|
+
} else if (inline === null && compressed instanceof Uint8Array && !oneTree) {
|
|
2450
2652
|
const temp = this.tempPath(hash)
|
|
2451
2653
|
tmpPath = temp
|
|
2452
2654
|
// The temp is written BEFORE validation so a large artifact can be
|
|
@@ -2470,9 +2672,33 @@ export class Cache implements CacheLayer {
|
|
|
2470
2672
|
tmpPath = compressed.tmpPath
|
|
2471
2673
|
}
|
|
2472
2674
|
let scanned: Awaited<ReturnType<typeof scanArtifact>>
|
|
2675
|
+
let tree = saved?.tree
|
|
2473
2676
|
try {
|
|
2474
2677
|
const endScan = span('save: scan')
|
|
2475
|
-
if (
|
|
2678
|
+
if (saved !== undefined) {
|
|
2679
|
+
scanned = saved.scan
|
|
2680
|
+
} else if (oneTree) {
|
|
2681
|
+
const dir = this.tempTreePath(hash)
|
|
2682
|
+
tree = dir
|
|
2683
|
+
let scan: Awaited<ReturnType<typeof scanArtifact>> | undefined
|
|
2684
|
+
await extractArtifactStream(
|
|
2685
|
+
await decodedTar(
|
|
2686
|
+
compressed instanceof Uint8Array ? compressed : Bun.file(tmpPath!),
|
|
2687
|
+
hash,
|
|
2688
|
+
this.artifactCeiling,
|
|
2689
|
+
),
|
|
2690
|
+
path.join(dir, 'outputs'),
|
|
2691
|
+
path.join(dir, WORKSPACE_OUTPUT_PREFIX),
|
|
2692
|
+
undefined,
|
|
2693
|
+
undefined,
|
|
2694
|
+
undefined,
|
|
2695
|
+
(s) => {
|
|
2696
|
+
scan = s
|
|
2697
|
+
},
|
|
2698
|
+
)
|
|
2699
|
+
scanned = scan!
|
|
2700
|
+
await sealTree(dir, scanned)
|
|
2701
|
+
} else if (tar !== undefined) {
|
|
2476
2702
|
// Our own tar, in memory: read in place, on this thread.
|
|
2477
2703
|
scanned = scanTarBytes(tar)
|
|
2478
2704
|
} else {
|
|
@@ -2505,12 +2731,45 @@ export class Cache implements CacheLayer {
|
|
|
2505
2731
|
])
|
|
2506
2732
|
} catch (err) {
|
|
2507
2733
|
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2734
|
+
if (tree !== undefined) await rm(tree, { recursive: true, force: true })
|
|
2508
2735
|
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
2509
2736
|
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
2510
2737
|
}
|
|
2511
2738
|
const { entries } = scanned
|
|
2512
2739
|
const totalBytes =
|
|
2513
|
-
|
|
2740
|
+
saved !== undefined
|
|
2741
|
+
? saved.bytes
|
|
2742
|
+
: compressed instanceof Uint8Array
|
|
2743
|
+
? compressed.byteLength
|
|
2744
|
+
: Bun.file(tmpPath!).size
|
|
2745
|
+
// A remote hit this store keeps as plain files: the archive passed its
|
|
2746
|
+
// checks above, so it is unpacked once here and its temp goes.
|
|
2747
|
+
if (oneTree && tmpPath !== undefined) {
|
|
2748
|
+
await unlink(tmpPath).catch(() => undefined)
|
|
2749
|
+
tmpPath = undefined
|
|
2750
|
+
}
|
|
2751
|
+
if (tree === undefined && inline === null && this.store.decompress) {
|
|
2752
|
+
const dir = this.tempTreePath(hash)
|
|
2753
|
+
try {
|
|
2754
|
+
const source =
|
|
2755
|
+
compressed instanceof Uint8Array && compressed.byteLength > 0
|
|
2756
|
+
? compressed
|
|
2757
|
+
: Bun.file(tmpPath!)
|
|
2758
|
+
await extractArtifactStream(
|
|
2759
|
+
await decodedTar(source, hash, this.artifactCeiling),
|
|
2760
|
+
path.join(dir, 'outputs'),
|
|
2761
|
+
path.join(dir, WORKSPACE_OUTPUT_PREFIX),
|
|
2762
|
+
)
|
|
2763
|
+
await sealTree(dir, scanned)
|
|
2764
|
+
} catch (err) {
|
|
2765
|
+
await rm(dir, { recursive: true, force: true })
|
|
2766
|
+
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2767
|
+
throw err
|
|
2768
|
+
}
|
|
2769
|
+
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2770
|
+
tmpPath = undefined
|
|
2771
|
+
tree = dir
|
|
2772
|
+
}
|
|
2514
2773
|
const outputFileRows: Array<[string, number, number, number]> = []
|
|
2515
2774
|
// Per-output-file fingerprint rows feed the skip-restore check.
|
|
2516
2775
|
// Row paths: project entries store the bare rel (`outputs/`
|
|
@@ -2600,19 +2859,27 @@ export class Cache implements CacheLayer {
|
|
|
2600
2859
|
// bytes are in one place. An inline save moves aside whatever file the
|
|
2601
2860
|
// key has, as a file save does: one rename that finds nothing on a
|
|
2602
2861
|
// first save, where asking the index first was a statement per save.
|
|
2603
|
-
let aside: string
|
|
2604
|
-
let renamed
|
|
2862
|
+
let aside: string[] = []
|
|
2863
|
+
let renamed: string | undefined
|
|
2605
2864
|
const upsertBlob = this.upsertBlob
|
|
2606
2865
|
const deleteBlob = this.deleteBlob
|
|
2607
|
-
const
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2866
|
+
const treePath = this.treePath(hash)
|
|
2867
|
+
// Both file forms are moved aside: a key re-saved under another
|
|
2868
|
+
// `cache.store` leaves one live artifact.
|
|
2869
|
+
const moveAside = (): string[] => {
|
|
2870
|
+
const moved: string[] = []
|
|
2871
|
+
for (const [from, to] of [
|
|
2872
|
+
[finalPath, this.tempPath(hash)],
|
|
2873
|
+
[treePath, this.tempTreePath(hash)],
|
|
2874
|
+
] as const) {
|
|
2875
|
+
try {
|
|
2876
|
+
renameSync(from, to)
|
|
2877
|
+
moved.push(to)
|
|
2878
|
+
} catch (err) {
|
|
2879
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
2880
|
+
}
|
|
2615
2881
|
}
|
|
2882
|
+
return moved
|
|
2616
2883
|
}
|
|
2617
2884
|
const tx = this.db.transaction(() => {
|
|
2618
2885
|
if (inline !== null) {
|
|
@@ -2620,9 +2887,10 @@ export class Cache implements CacheLayer {
|
|
|
2620
2887
|
upsertBlob.run(hash, now, inline)
|
|
2621
2888
|
} else {
|
|
2622
2889
|
aside = moveAside()
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2890
|
+
const live = tree !== undefined ? treePath : finalPath
|
|
2891
|
+
renameSync(tree ?? tmpPath!, live)
|
|
2892
|
+
this.artifactNames?.add(path.basename(live))
|
|
2893
|
+
renamed = live
|
|
2626
2894
|
deleteBlob.run(hash)
|
|
2627
2895
|
}
|
|
2628
2896
|
insertEntry.run(
|
|
@@ -2665,13 +2933,17 @@ export class Cache implements CacheLayer {
|
|
|
2665
2933
|
tx.immediate()
|
|
2666
2934
|
} catch (err) {
|
|
2667
2935
|
endTx()
|
|
2668
|
-
const left = renamed
|
|
2669
|
-
if (left !== undefined) await
|
|
2670
|
-
|
|
2936
|
+
const left = renamed ?? tree ?? tmpPath
|
|
2937
|
+
if (left !== undefined) await rm(left, { recursive: true, force: true })
|
|
2938
|
+
for (const a of aside) await rm(a, { recursive: true, force: true })
|
|
2671
2939
|
throw err
|
|
2672
2940
|
}
|
|
2673
2941
|
endTx()
|
|
2674
|
-
|
|
2942
|
+
for (const a of aside) await rm(a, { recursive: true, force: true })
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
private tempTreePath(hash: string): string {
|
|
2946
|
+
return `${this.treePath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
|
|
2675
2947
|
}
|
|
2676
2948
|
|
|
2677
2949
|
/** Apply the deferred accessed_at bumps in one statement. */
|
|
@@ -2688,7 +2960,7 @@ export class Cache implements CacheLayer {
|
|
|
2688
2960
|
const at = new Date(now)
|
|
2689
2961
|
for (const hash of this.staleTimes) {
|
|
2690
2962
|
try {
|
|
2691
|
-
utimesSync(this.tarPath(hash), at, at)
|
|
2963
|
+
utimesSync(this.artifactStat(hash)?.path ?? this.tarPath(hash), at, at)
|
|
2692
2964
|
} catch {
|
|
2693
2965
|
// Gone, or another user's file: a sweep judges its old time.
|
|
2694
2966
|
}
|
|
@@ -2777,67 +3049,6 @@ export class Cache implements CacheLayer {
|
|
|
2777
3049
|
}
|
|
2778
3050
|
}
|
|
2779
3051
|
|
|
2780
|
-
/**
|
|
2781
|
-
* The workspace's `cacheRetention`, applied at the end of a run: `prune()`
|
|
2782
|
-
* with the same policy, but only when it would evict something, else the
|
|
2783
|
-
* orphan sweep alone when an hour has passed since the last one. A run
|
|
2784
|
-
* with nothing due pays the accessed-at flush it owed at close anyway, one
|
|
2785
|
-
* scan of the index and one read of the sweep's clock — never the
|
|
2786
|
-
* sweep's readdir. Null when nothing was due or the directory is not this
|
|
2787
|
-
* handle's to write. The local WRITE axis does not gate it: under
|
|
2788
|
-
* `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
|
|
2789
|
-
* hit is still ingested (`ingest`, ungated by design), and gated on that
|
|
2790
|
-
* axis the retention never ran while the store grew hit by hit (A-7).
|
|
2791
|
-
*/
|
|
2792
|
-
async evictIfDue(
|
|
2793
|
-
policy: { maxAgeMs?: number; maxBytes?: number },
|
|
2794
|
-
now: number = Date.now(),
|
|
2795
|
-
): Promise<PruneResult | null> {
|
|
2796
|
-
return this.guard(() => this.evictIfDueNow(policy, now))
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
|
-
private async evictIfDueNow(
|
|
2800
|
-
policy: { maxAgeMs?: number; maxBytes?: number },
|
|
2801
|
-
now: number,
|
|
2802
|
-
): Promise<PruneResult | null> {
|
|
2803
|
-
if (this.writeBlocked !== null || this.inspecting) return null
|
|
2804
|
-
// First: an entry this run restored still carries its old `accessed_at`
|
|
2805
|
-
// until the deferred bump lands, and would read as due for eviction.
|
|
2806
|
-
this.flushAccessed()
|
|
2807
|
-
const olderThanMs = policy.maxAgeMs === undefined ? undefined : now - policy.maxAgeMs
|
|
2808
|
-
const { oldest, bytes } = this.db
|
|
2809
|
-
.prepare(
|
|
2810
|
-
'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
|
|
2811
|
-
)
|
|
2812
|
-
.get() as { oldest: number | null; bytes: number }
|
|
2813
|
-
const ageDue =
|
|
2814
|
-
olderThanMs !== undefined && oldest !== null && oldest + ACCESS_REFRESH_MS < olderThanMs
|
|
2815
|
-
const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
|
|
2816
|
-
if (ageDue || sizeDue) {
|
|
2817
|
-
return this.prune({
|
|
2818
|
-
...(olderThanMs !== undefined ? { olderThanMs } : {}),
|
|
2819
|
-
...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
|
|
2820
|
-
})
|
|
2821
|
-
}
|
|
2822
|
-
// Row-less artifacts are bytes the index cannot count, so the policy
|
|
2823
|
-
// above never sees them (upstream survey, nx#35483: 9 MiB of them sat
|
|
2824
|
-
// under a 1 MB limit). Listing the directory to find them costs 0.5 ms
|
|
2825
|
-
// per 1,000 entries (4.9 ms at 10,000), every run; the sweep's own
|
|
2826
|
-
// clock in `schema_meta` costs one indexed read. The sweep is the
|
|
2827
|
-
// policy's own prune, with the row-less files counted.
|
|
2828
|
-
const swept = this.db.prepare('SELECT value FROM schema_meta WHERE key = ?').get(SWEPT_AT) as {
|
|
2829
|
-
value: string
|
|
2830
|
-
} | null
|
|
2831
|
-
if (swept !== null && now - Number(swept.value) < ORPHAN_GRACE_MS) return null
|
|
2832
|
-
return this.pruneIndex(
|
|
2833
|
-
{
|
|
2834
|
-
...(olderThanMs !== undefined ? { olderThanMs } : {}),
|
|
2835
|
-
...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
|
|
2836
|
-
},
|
|
2837
|
-
now,
|
|
2838
|
-
)
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
3052
|
async prune(options: PruneOptions): Promise<PruneResult> {
|
|
2842
3053
|
return this.guard(() => this.pruneIndex(options))
|
|
2843
3054
|
}
|
|
@@ -2891,7 +3102,12 @@ export class Cache implements CacheLayer {
|
|
|
2891
3102
|
// and the one verb meant to free a full disk freed nothing (A-14). An
|
|
2892
3103
|
// inline artifact goes with its rows, in their transaction.
|
|
2893
3104
|
await Promise.all(
|
|
2894
|
-
hashes
|
|
3105
|
+
hashes
|
|
3106
|
+
.filter((h) => !inline.has(h))
|
|
3107
|
+
.flatMap((h) => [
|
|
3108
|
+
rm(this.tarPath(h), { force: true }),
|
|
3109
|
+
rm(this.treePath(h), { recursive: true, force: true }),
|
|
3110
|
+
]),
|
|
2895
3111
|
)
|
|
2896
3112
|
const { test, params } = inHashes(rows)
|
|
2897
3113
|
const deleteRows = this.db.transaction(() => {
|
|
@@ -2914,7 +3130,15 @@ export class Cache implements CacheLayer {
|
|
|
2914
3130
|
} catch (err) {
|
|
2915
3131
|
if (!isIndexFull(err)) throw err
|
|
2916
3132
|
}
|
|
2917
|
-
|
|
3133
|
+
// History and memo rows a month old go with a prune, never a run's
|
|
3134
|
+
// close: five DELETEs on every run's exit was its slowest step (owner,
|
|
3135
|
+
// 2026-10-18). `invocations` shares the window with `runs`, or `vx last`
|
|
3136
|
+
// would list a run whose task rows are gone.
|
|
3137
|
+
const cutoff = now - HISTORY_RETENTION_MS
|
|
3138
|
+
this.history.pruneOlderThan(cutoff)
|
|
3139
|
+
this.files.pruneOlderThan(cutoff)
|
|
3140
|
+
this.folds.prune(cutoff)
|
|
3141
|
+
const orphans = await this.reapOrphans(picked.files)
|
|
2918
3142
|
return { evicted: victims.size, bytesFreed, ...orphans }
|
|
2919
3143
|
}
|
|
2920
3144
|
|
|
@@ -2929,16 +3153,7 @@ export class Cache implements CacheLayer {
|
|
|
2929
3153
|
*/
|
|
2930
3154
|
private async reapOrphans(
|
|
2931
3155
|
taken: readonly RowlessFile[],
|
|
2932
|
-
now: number,
|
|
2933
3156
|
): Promise<{ orphans: number; orphanBytes: number }> {
|
|
2934
|
-
try {
|
|
2935
|
-
this.db
|
|
2936
|
-
.prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
|
|
2937
|
-
.run(SWEPT_AT, String(now))
|
|
2938
|
-
} catch (err) {
|
|
2939
|
-
// The sweep's clock: on a full disk the sweep runs again next time.
|
|
2940
|
-
if (!isIndexFull(err)) throw err
|
|
2941
|
-
}
|
|
2942
3157
|
let orphans = 0
|
|
2943
3158
|
let orphanBytes = 0
|
|
2944
3159
|
const blobs = taken.filter((o) => o.blob !== undefined)
|
|
@@ -2967,7 +3182,7 @@ export class Cache implements CacheLayer {
|
|
|
2967
3182
|
try {
|
|
2968
3183
|
// unlink, not `rm({ force })`: force swallows ENOENT, and a file a
|
|
2969
3184
|
// concurrent prune took first must not be counted as ours.
|
|
2970
|
-
await
|
|
3185
|
+
await rm(o.file, { recursive: true })
|
|
2971
3186
|
orphans += 1
|
|
2972
3187
|
orphanBytes += o.size
|
|
2973
3188
|
} catch {
|
|
@@ -3016,7 +3231,8 @@ export class Cache implements CacheLayer {
|
|
|
3016
3231
|
const phantoms = new Map<string, number>()
|
|
3017
3232
|
const stale: string[] = []
|
|
3018
3233
|
for (const r of rows) {
|
|
3019
|
-
if (r.inline === 1 || present.has(`${r.hash}.tar.zst`))
|
|
3234
|
+
if (r.inline === 1 || present.has(`${r.hash}.tar.zst`) || present.has(`${r.hash}.tree`))
|
|
3235
|
+
continue
|
|
3020
3236
|
phantoms.set(r.hash, r.size_bytes)
|
|
3021
3237
|
if (r.accessed_at < graceStart) stale.push(r.hash)
|
|
3022
3238
|
}
|
|
@@ -3068,34 +3284,12 @@ export class Cache implements CacheLayer {
|
|
|
3068
3284
|
}
|
|
3069
3285
|
|
|
3070
3286
|
close(): void {
|
|
3071
|
-
// Run-history retention: the runs table grows by one row per
|
|
3072
|
-
// executed task per invocation — a 2000-task repo accretes ~20k
|
|
3073
|
-
// rows in days, inflating insert and checkpoint cost forever.
|
|
3074
|
-
// 30 days comfortably covers `vx info` (24 h windows) and any
|
|
3075
|
-
// CI-side analytics consumers. The `invocations` header table (one
|
|
3076
|
-
// row per `vx run`) is pruned on the SAME window — otherwise a
|
|
3077
|
-
// header would outlive its `runs` rows, so `vx info`/`vx last` would
|
|
3078
|
-
// list an invocation whose task detail is already gone, and the
|
|
3079
|
-
// table would grow unbounded on a long-lived checkout.
|
|
3080
|
-
// A reading verb and a dry run delete nothing: `vx last --list` over
|
|
3081
|
-
// month-old runs emptied the history it listed, and `vx cache clean
|
|
3082
|
-
// --dry-run` pruned it (item 1004).
|
|
3083
|
-
try {
|
|
3084
|
-
if (!this.inspecting) {
|
|
3085
|
-
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
3086
|
-
this.history.pruneOlderThan(cutoff)
|
|
3087
|
-
if (this.write) this.files.pruneOlderThan(cutoff)
|
|
3088
|
-
}
|
|
3089
|
-
} catch {
|
|
3090
|
-
// Retention is best-effort; never block closing the handle.
|
|
3091
|
-
}
|
|
3092
3287
|
try {
|
|
3093
3288
|
this.flushAccessed()
|
|
3094
3289
|
this.outputs.flushOutputDirs()
|
|
3095
|
-
this.folds.flush(
|
|
3290
|
+
this.folds.flush()
|
|
3096
3291
|
} catch {
|
|
3097
|
-
//
|
|
3098
|
-
// guarded while this sibling was not: `accessed_at` is LRU
|
|
3292
|
+
// `accessed_at` is LRU
|
|
3099
3293
|
// bookkeeping, never correctness, and the run's results are
|
|
3100
3294
|
// already recorded by the time we get here. Letting it throw also
|
|
3101
3295
|
// SKIPPED `db.close()` below, leaking the handle. Reachable when
|
|
@@ -3109,6 +3303,21 @@ export class Cache implements CacheLayer {
|
|
|
3109
3303
|
private tarPath(hash: string): string {
|
|
3110
3304
|
return path.join(this.artifactDir, `${hash}.tar.zst`)
|
|
3111
3305
|
}
|
|
3306
|
+
|
|
3307
|
+
private treePath(hash: string): string {
|
|
3308
|
+
return path.join(this.artifactDir, `${hash}.tree`)
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
/** The key's file-backed artifact (tar or tree), the form this store writes asked first. */
|
|
3312
|
+
private artifactStat(hash: string): { path: string; tree: boolean; mtimeMs: number } | undefined {
|
|
3313
|
+
const order = this.store.decompress ? [true, false] : [false, true]
|
|
3314
|
+
for (const tree of order) {
|
|
3315
|
+
const at = tree ? this.treePath(hash) : this.tarPath(hash)
|
|
3316
|
+
const st = statSync(at, { throwIfNoEntry: false })
|
|
3317
|
+
if (st !== undefined) return { path: at, tree, mtimeMs: st.mtimeMs }
|
|
3318
|
+
}
|
|
3319
|
+
return undefined
|
|
3320
|
+
}
|
|
3112
3321
|
}
|
|
3113
3322
|
|
|
3114
3323
|
/**
|
|
@@ -3181,6 +3390,17 @@ function assertArtifactNames(
|
|
|
3181
3390
|
}
|
|
3182
3391
|
}
|
|
3183
3392
|
|
|
3393
|
+
/** A row's path as the tree names its file. */
|
|
3394
|
+
function treeName(rowPath: string): string {
|
|
3395
|
+
return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
function inTree(p: string | undefined, tree: string): boolean {
|
|
3399
|
+
return p !== undefined && (p === tree || p.startsWith(`${tree}/`))
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
const utf8Decode = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
3403
|
+
|
|
3184
3404
|
/**
|
|
3185
3405
|
* Whether an artifact entry names one of the task's declared outputs, as
|
|
3186
3406
|
* the save could have packed it (`restorableOutput`: a `!` take-back, vx's
|