@vzn/vx 0.0.643 → 0.0.644
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/package.json +5 -5
- package/schemas/info.json +0 -11
- package/skills/vx/SKILL.md +1 -1
- package/src/cache/archive.ts +153 -2
- package/src/cache/cache.ts +416 -176
- package/src/cache/file-hashes.ts +2 -14
- package/src/cache/fold-memo.ts +7 -3
- package/src/cache/index.ts +3 -0
- package/src/cache/key-fold.ts +4 -1
- package/src/cache/layer.ts +9 -4
- package/src/cache/layered-cache.ts +55 -3
- package/src/cache/plugin-cache.ts +33 -32
- package/src/cache/remote-lane.ts +12 -7
- package/src/cache/tar-stream.ts +12 -7
- package/src/cli/format.ts +1 -2
- package/src/cli/help.ts +6 -0
- package/src/cli/info.ts +2 -19
- package/src/cli/plugin-commands.ts +1 -1
- package/src/cli/run.ts +3 -0
- package/src/cli/upgrade.ts +38 -8
- package/src/config.ts +15 -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 +1 -1
- package/src/orchestrator/doctor.ts +0 -17
- package/src/orchestrator/execute-task.ts +1 -1
- package/src/orchestrator/options.ts +6 -0
- package/src/orchestrator/plugin-host.ts +6 -2
- package/src/orchestrator/plugin.ts +4 -1
- package/src/orchestrator/prepare.ts +16 -3
- package/src/orchestrator/projects.ts +8 -1
- package/src/orchestrator/remote-prefetch.ts +11 -4
- package/src/orchestrator/run-context.ts +10 -5
- package/src/orchestrator/run-settings.ts +3 -0
- package/src/orchestrator/run.ts +8 -26
- package/src/report/logger.ts +0 -4
- package/src/report/summary.ts +0 -11
- package/src/settings.ts +43 -14
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +42 -33
- package/src/util/size.ts +4 -5
- package/src/workspace/config-schema.ts +0 -36
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,12 @@ import {
|
|
|
76
76
|
packArtifactBytes,
|
|
77
77
|
packArtifactStream,
|
|
78
78
|
planArtifact,
|
|
79
|
+
planOfTree,
|
|
80
|
+
sealTree,
|
|
81
|
+
treeIntact,
|
|
82
|
+
writeTree,
|
|
79
83
|
} from './archive.js'
|
|
84
|
+
import { readUmask, restoredMode } from './umask.js'
|
|
80
85
|
import {
|
|
81
86
|
ArtifactVanishedError,
|
|
82
87
|
type CacheEntry,
|
|
@@ -119,7 +124,7 @@ import { behindLink } from './linked-inputs.js'
|
|
|
119
124
|
* An artifact or temp file without an `entries` row is never reaped
|
|
120
125
|
* younger than this: a crashed save's temp is stale long before it. A
|
|
121
126
|
* 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
|
|
127
|
+
* its row), so past this it is judged by the prune policy, its file
|
|
123
128
|
* time standing for `accessed_at`. A hit renews a file time older than
|
|
124
129
|
* this, so the file time trails the last use by at most this much, and
|
|
125
130
|
* the policy reads the file time plus this as the last use.
|
|
@@ -137,7 +142,7 @@ const ADOPT_STATS = 64
|
|
|
137
142
|
* A hit renews an entry's `accessed_at` only once it is older than this:
|
|
138
143
|
* renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
|
|
139
144
|
* close, ~30 ms of it. So `accessed_at` trails the last use by at most
|
|
140
|
-
* this much, and the
|
|
145
|
+
* this much, and the prune policy reads it plus this as the last use:
|
|
141
146
|
* an entry used within the limit is never pruned, and one may stay up to
|
|
142
147
|
* this long past it (owner, 2026-10-08).
|
|
143
148
|
*/
|
|
@@ -150,7 +155,7 @@ const ACCESS_REFRESH_MS = 60 * 60 * 1000
|
|
|
150
155
|
* directory), and the sweep once took any `*.tar.zst` an hour old there,
|
|
151
156
|
* the user's own release tarball included (item 968).
|
|
152
157
|
*/
|
|
153
|
-
const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
|
|
158
|
+
const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.(?:tar\.zst|tree)(\.tmp-\d+-\d+-[0-9a-z]*)?$/
|
|
154
159
|
|
|
155
160
|
/** What vx writes in a cache directory besides its artifacts. */
|
|
156
161
|
const VX_CACHE_NAMES = new Set([
|
|
@@ -186,8 +191,8 @@ export function vxCacheEntries(cacheDir: string): string[] {
|
|
|
186
191
|
})
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
/**
|
|
190
|
-
const
|
|
194
|
+
/** What `vx cache clean` keeps of run history and the hash memos. */
|
|
195
|
+
const HISTORY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
|
191
196
|
|
|
192
197
|
export interface SchemaReset {
|
|
193
198
|
from: string
|
|
@@ -295,7 +300,7 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
295
300
|
// output_files' ino + ctime_ms moved to the workspace's own
|
|
296
301
|
// output_stamps, output_dirs lost its foreign key, and the value
|
|
297
302
|
// salt moved to store_meta. The cache KEY is unchanged.
|
|
298
|
-
// v33: an artifact of at most
|
|
303
|
+
// v33: an artifact of at most 32 KiB compressed lives in the
|
|
299
304
|
// store's `artifacts` table, written in its rows' transaction, and
|
|
300
305
|
// every entry read joins it. The artifact bytes and the cache KEY are
|
|
301
306
|
// unchanged (CACHE_VERSION not bumped).
|
|
@@ -370,13 +375,6 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
|
|
|
370
375
|
*/
|
|
371
376
|
const ARTIFACT_LAYOUT = 'a2'
|
|
372
377
|
|
|
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
378
|
/**
|
|
381
379
|
* An index that records a schema other than this vx's: the one an opener
|
|
382
380
|
* that writes resets. Reads the recorded version alone and touches
|
|
@@ -509,8 +507,9 @@ async function scanOrphanFiles(
|
|
|
509
507
|
const file = path.join(cacheDir, name)
|
|
510
508
|
try {
|
|
511
509
|
const st = await stat(file)
|
|
512
|
-
if (st.isFile() && st.mtimeMs <= cutoff) {
|
|
513
|
-
|
|
510
|
+
if ((name.includes('.tree') ? st.isDirectory() : st.isFile()) && st.mtimeMs <= cutoff) {
|
|
511
|
+
const size = st.isDirectory() ? await treeBytes(file) : st.size
|
|
512
|
+
found.push({ file, size, mtimeMs: st.mtimeMs, temp })
|
|
514
513
|
}
|
|
515
514
|
} catch {
|
|
516
515
|
// Gone between readdir and stat: not an orphan any more.
|
|
@@ -520,6 +519,16 @@ async function scanOrphanFiles(
|
|
|
520
519
|
return found
|
|
521
520
|
}
|
|
522
521
|
|
|
522
|
+
/** The bytes a tree-form artifact's files hold. */
|
|
523
|
+
async function treeBytes(dir: string): Promise<number> {
|
|
524
|
+
let n = 0
|
|
525
|
+
for (const e of await readdir(dir, { recursive: true, withFileTypes: true }).catch(() => [])) {
|
|
526
|
+
if (e.isFile())
|
|
527
|
+
n += (await stat(path.join(e.parentPath, e.name)).catch(() => ({ size: 0 }))).size
|
|
528
|
+
}
|
|
529
|
+
return n
|
|
530
|
+
}
|
|
531
|
+
|
|
523
532
|
/**
|
|
524
533
|
* SQL predicate selecting `runs` rows that record an EXECUTION.
|
|
525
534
|
*
|
|
@@ -613,7 +622,7 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
|
|
|
613
622
|
* it there", and the ignore file is created exclusively instead of probed
|
|
614
623
|
* first. An existing cache costs three calls; it cost six.
|
|
615
624
|
*/
|
|
616
|
-
function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
625
|
+
function openCacheDir(cacheDir: string, workspaceRoot?: string, index = 'cache.db'): string | null {
|
|
617
626
|
const [ignore, ignoreText] = ignoreFileFor(cacheDir)
|
|
618
627
|
try {
|
|
619
628
|
accessSync(cacheDir, constants.W_OK)
|
|
@@ -624,7 +633,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
|
624
633
|
return null
|
|
625
634
|
}
|
|
626
635
|
try {
|
|
627
|
-
accessSync(path.join(cacheDir,
|
|
636
|
+
accessSync(path.join(cacheDir, index), constants.W_OK)
|
|
628
637
|
} catch (err) {
|
|
629
638
|
const code = (err as NodeJS.ErrnoException).code
|
|
630
639
|
// A FILE at `cacheDir` passed the `access` above; mkdir names it.
|
|
@@ -649,7 +658,7 @@ function storeBlocked(storeDir: string, storeRoot: string | null): string | null
|
|
|
649
658
|
if (refused !== null) return refused
|
|
650
659
|
}
|
|
651
660
|
}
|
|
652
|
-
return openCacheDir(storeDir)
|
|
661
|
+
return openCacheDir(storeDir, undefined, 'store.db')
|
|
653
662
|
} catch (err) {
|
|
654
663
|
return errorText(err)
|
|
655
664
|
}
|
|
@@ -788,6 +797,33 @@ function errorText(err: unknown): string {
|
|
|
788
797
|
return err instanceof Error ? err.message : String(err)
|
|
789
798
|
}
|
|
790
799
|
|
|
800
|
+
/** A tree the save wrote from its own outputs, with the scan its tar would give. */
|
|
801
|
+
interface SavedTree {
|
|
802
|
+
tree: string
|
|
803
|
+
scan: Awaited<ReturnType<typeof scanArtifact>>
|
|
804
|
+
bytes: number
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** How a save keeps an entry locally (`cache.store`). */
|
|
808
|
+
export interface StoreOptions {
|
|
809
|
+
/**
|
|
810
|
+
* The largest entry kept in the index, as one zstd tar (fewer files to
|
|
811
|
+
* make): written in its rows' transaction, with no temp and no rename
|
|
812
|
+
* (docs/design/cache-save-cpu-2026-10.md). 0 keeps every entry as files.
|
|
813
|
+
*/
|
|
814
|
+
embeddings: number
|
|
815
|
+
/** A file-backed entry is a `<hash>.tree` of plain files; off, one `<hash>.tar.zst`. */
|
|
816
|
+
decompress: boolean
|
|
817
|
+
/** What a tree restore checks first: each file's size and mtime, every byte, or nothing. */
|
|
818
|
+
verify: 'stat' | 'hash' | 'none'
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export const DEFAULT_STORE: StoreOptions = {
|
|
822
|
+
embeddings: 32 * 1024,
|
|
823
|
+
decompress: true,
|
|
824
|
+
verify: 'stat',
|
|
825
|
+
}
|
|
826
|
+
|
|
791
827
|
/** What a run's background uploads moved: totals over the ones that landed. */
|
|
792
828
|
export interface UploadTally {
|
|
793
829
|
count: number
|
|
@@ -939,6 +975,7 @@ export class Cache implements CacheLayer {
|
|
|
939
975
|
* records none (deleted, or never written beside a run's store).
|
|
940
976
|
*/
|
|
941
977
|
storeRoot?: string | null,
|
|
978
|
+
private readonly store: StoreOptions = DEFAULT_STORE,
|
|
942
979
|
) {
|
|
943
980
|
this.inspecting = mode !== 'open'
|
|
944
981
|
this.read = localPolicy.read
|
|
@@ -1677,7 +1714,7 @@ export class Cache implements CacheLayer {
|
|
|
1677
1714
|
} else {
|
|
1678
1715
|
// Verify the artifact file actually exists. The DB and the
|
|
1679
1716
|
// filesystem can drift if someone manually deletes the cache dir.
|
|
1680
|
-
const st =
|
|
1717
|
+
const st = this.artifactStat(hash)
|
|
1681
1718
|
if (st === undefined) return null
|
|
1682
1719
|
if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
|
|
1683
1720
|
}
|
|
@@ -1717,7 +1754,7 @@ export class Cache implements CacheLayer {
|
|
|
1717
1754
|
names = []
|
|
1718
1755
|
}
|
|
1719
1756
|
this.artifactNames ??= new Set(names)
|
|
1720
|
-
return !names.some((n) => n.endsWith('.tar.zst'))
|
|
1757
|
+
return !names.some((n) => n.endsWith('.tar.zst') || n.endsWith('.tree'))
|
|
1721
1758
|
}
|
|
1722
1759
|
|
|
1723
1760
|
/**
|
|
@@ -1766,7 +1803,7 @@ export class Cache implements CacheLayer {
|
|
|
1766
1803
|
if (r.inline_at < graceStart) this.staleInline.add(r.hash)
|
|
1767
1804
|
return true
|
|
1768
1805
|
}
|
|
1769
|
-
const st =
|
|
1806
|
+
const st = this.artifactStat(r.hash)
|
|
1770
1807
|
if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
|
|
1771
1808
|
return st !== undefined
|
|
1772
1809
|
})
|
|
@@ -1811,7 +1848,7 @@ export class Cache implements CacheLayer {
|
|
|
1811
1848
|
private storedHere(hash: string): boolean {
|
|
1812
1849
|
const row = this.entryExists.get(hash) as { inline_at: number | null } | null
|
|
1813
1850
|
if (row === null) return false
|
|
1814
|
-
return row.inline_at !== null ||
|
|
1851
|
+
return row.inline_at !== null || this.artifactStat(hash) !== undefined
|
|
1815
1852
|
}
|
|
1816
1853
|
|
|
1817
1854
|
// Local cache has no slower layer to warm from — prefetch is a no-op.
|
|
@@ -1886,9 +1923,120 @@ export class Cache implements CacheLayer {
|
|
|
1886
1923
|
}
|
|
1887
1924
|
}
|
|
1888
1925
|
|
|
1926
|
+
/**
|
|
1927
|
+
* Restore a tree-form entry: its files checked as `cache.store.verify`
|
|
1928
|
+
* says, then copied (or cloned) out one by one where the outputs are
|
|
1929
|
+
* absent, as on every restore after a clean. Anything else (a tree to
|
|
1930
|
+
* restore into, a workspace output, a link) goes through the extractor
|
|
1931
|
+
* the tar form uses, fed the tree packed back into its tar.
|
|
1932
|
+
*/
|
|
1933
|
+
private async restoreTree(
|
|
1934
|
+
hash: string,
|
|
1935
|
+
tree: string,
|
|
1936
|
+
rows: readonly OutputFileRow[],
|
|
1937
|
+
projectDir: string,
|
|
1938
|
+
workspaceRoot: string | undefined,
|
|
1939
|
+
verify: (provided: ReadonlySet<string>) => void,
|
|
1940
|
+
accept: ((name: string) => boolean) | undefined,
|
|
1941
|
+
): Promise<void> {
|
|
1942
|
+
const files = rows.filter((r) => !r.path.endsWith('/') && r.mode !== LINK_MODE)
|
|
1943
|
+
let bytes = 0
|
|
1944
|
+
for (const r of files) bytes += r.size
|
|
1945
|
+
if (bytes > this.artifactCeiling) {
|
|
1946
|
+
throw new CorruptArtifactError(
|
|
1947
|
+
hash,
|
|
1948
|
+
`its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
|
|
1949
|
+
)
|
|
1950
|
+
}
|
|
1951
|
+
if (this.store.verify === 'stat') {
|
|
1952
|
+
const changed = await Promise.all(
|
|
1953
|
+
files.map((r) =>
|
|
1954
|
+
stat(path.join(tree, treeName(r.path))).then(
|
|
1955
|
+
(st) => st.size !== r.size || Math.floor(st.mtimeMs) !== r.mtimeMs,
|
|
1956
|
+
() => true,
|
|
1957
|
+
),
|
|
1958
|
+
),
|
|
1959
|
+
)
|
|
1960
|
+
if (changed.includes(true)) {
|
|
1961
|
+
throw new CorruptArtifactError(hash, 'a stored file changed since it was saved')
|
|
1962
|
+
}
|
|
1963
|
+
} else if (this.store.verify === 'hash' && !(await treeIntact(tree, this.stdoutText(hash)))) {
|
|
1964
|
+
throw new CorruptArtifactError(hash, 'the stored files no longer sum to what was saved')
|
|
1965
|
+
}
|
|
1966
|
+
if (await this.copyTree(tree, rows, files, projectDir, accept)) return
|
|
1967
|
+
await extractArtifactStream(
|
|
1968
|
+
packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
|
|
1969
|
+
projectDir,
|
|
1970
|
+
workspaceRoot,
|
|
1971
|
+
verify,
|
|
1972
|
+
accept,
|
|
1973
|
+
workspaceRoot,
|
|
1974
|
+
)
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** The copy-out: false, having written nothing, where it is not the case it serves. */
|
|
1978
|
+
private async copyTree(
|
|
1979
|
+
tree: string,
|
|
1980
|
+
rows: readonly OutputFileRow[],
|
|
1981
|
+
files: readonly OutputFileRow[],
|
|
1982
|
+
projectDir: string,
|
|
1983
|
+
accept: ((name: string) => boolean) | undefined,
|
|
1984
|
+
): Promise<boolean> {
|
|
1985
|
+
const tops = new Set<string>()
|
|
1986
|
+
for (const r of rows) {
|
|
1987
|
+
if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
|
|
1988
|
+
if (accept !== undefined && !r.path.endsWith('/') && !accept(`outputs/${r.path}`))
|
|
1989
|
+
return false
|
|
1990
|
+
tops.add(r.path.split('/')[0]!)
|
|
1991
|
+
}
|
|
1992
|
+
const held = await Promise.all(
|
|
1993
|
+
[...tops].map((t) =>
|
|
1994
|
+
lstat(path.join(projectDir, t)).then(
|
|
1995
|
+
() => true,
|
|
1996
|
+
() => false,
|
|
1997
|
+
),
|
|
1998
|
+
),
|
|
1999
|
+
)
|
|
2000
|
+
if (held.includes(true)) return false
|
|
2001
|
+
readUmask()
|
|
2002
|
+
const plain = restoredMode(0o666)
|
|
2003
|
+
try {
|
|
2004
|
+
const dirs = new Set<string>()
|
|
2005
|
+
for (const r of rows) {
|
|
2006
|
+
dirs.add(path.join(projectDir, r.path.endsWith('/') ? r.path : path.dirname(r.path)))
|
|
2007
|
+
}
|
|
2008
|
+
for (const d of dirs) await mkdir(d, { recursive: true })
|
|
2009
|
+
await Promise.all(
|
|
2010
|
+
files.map(async (r) => {
|
|
2011
|
+
const to = path.join(projectDir, r.path)
|
|
2012
|
+
await Bun.write(to, Bun.file(path.join(tree, treeName(r.path))), { createPath: false })
|
|
2013
|
+
const mode = restoredMode(r.mode)
|
|
2014
|
+
if (mode !== plain) await chmod(to, mode)
|
|
2015
|
+
const at = new Date(r.mtimeMs)
|
|
2016
|
+
await utimes(to, at, at)
|
|
2017
|
+
}),
|
|
2018
|
+
)
|
|
2019
|
+
} catch (err) {
|
|
2020
|
+
await Promise.all(
|
|
2021
|
+
[...tops].map((t) => rm(path.join(projectDir, t), { recursive: true, force: true })),
|
|
2022
|
+
)
|
|
2023
|
+
throw err
|
|
2024
|
+
}
|
|
2025
|
+
return true
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
private stdoutText(hash: string): string {
|
|
2029
|
+
const row = this.db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(hash) as {
|
|
2030
|
+
stdout: string | Uint8Array
|
|
2031
|
+
} | null
|
|
2032
|
+
if (row === null) return ''
|
|
2033
|
+
return typeof row.stdout === 'string' ? row.stdout : utf8Decode.decode(row.stdout)
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1889
2036
|
/** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
|
|
1890
2037
|
private async dropEntry(hash: string): Promise<void> {
|
|
1891
2038
|
await rm(this.tarPath(hash), { force: true })
|
|
2039
|
+
await rm(this.treePath(hash), { recursive: true, force: true })
|
|
1892
2040
|
const drop = this.db.transaction(() => {
|
|
1893
2041
|
this.deleteBlob.run(hash)
|
|
1894
2042
|
this.deleteEntryRow.run(hash)
|
|
@@ -1924,7 +2072,7 @@ export class Cache implements CacheLayer {
|
|
|
1924
2072
|
// outputs, so a restore that did not fail here would report a green cache
|
|
1925
2073
|
// hit over an emptied output tree. The artifact existed when `get()`
|
|
1926
2074
|
// probed it, so its absence now means something removed it underneath us
|
|
1927
|
-
// (a `vx cache clean` in another shell, or another workspace
|
|
2075
|
+
// (a `vx cache clean` in another shell, here or for another workspace
|
|
1928
2076
|
// on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
|
|
1929
2077
|
// turns into a miss: the task runs.
|
|
1930
2078
|
//
|
|
@@ -1986,6 +2134,8 @@ export class Cache implements CacheLayer {
|
|
|
1986
2134
|
// when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
|
|
1987
2135
|
// per 1 000). The local artifact was validated at ingest, so a missing
|
|
1988
2136
|
// declared size is allowed; the output ceiling applies to both.
|
|
2137
|
+
const tree =
|
|
2138
|
+
inline === undefined && existsSync(this.treePath(hash)) ? this.treePath(hash) : undefined
|
|
1989
2139
|
const endExtract = span('restore: extract')
|
|
1990
2140
|
try {
|
|
1991
2141
|
// Every step here is an async filesystem call on purpose: a
|
|
@@ -1993,15 +2143,13 @@ export class Cache implements CacheLayer {
|
|
|
1993
2143
|
// 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
|
|
1994
2144
|
// four workers overlap their round trips and a blocking one stalls
|
|
1995
2145
|
// 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
|
-
)
|
|
2146
|
+
const accept = outputs === undefined ? undefined : declaredName(outputs)
|
|
2147
|
+
if (tree !== undefined) {
|
|
2148
|
+
await this.restoreTree(hash, tree, rows, projectDir, workspaceRoot, verify, accept)
|
|
2149
|
+
} else {
|
|
2150
|
+
const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
|
|
2151
|
+
await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
|
|
2152
|
+
}
|
|
2005
2153
|
} catch (err) {
|
|
2006
2154
|
// A UserError here is the extractor naming the tree's fault (an
|
|
2007
2155
|
// output directory that links out of the project).
|
|
@@ -2052,7 +2200,13 @@ export class Cache implements CacheLayer {
|
|
|
2052
2200
|
// artifact is intact, the tree is not the process's to change.
|
|
2053
2201
|
// A refusal to READ the artifact is the cache directory's, not the
|
|
2054
2202
|
// outputs': the old line sent the reader to the wrong tree (A-40).
|
|
2055
|
-
|
|
2203
|
+
// A tree copy's error can name its source; no read fails ENOSPC.
|
|
2204
|
+
if (
|
|
2205
|
+
isFsRefusal(err) &&
|
|
2206
|
+
code !== 'ENOSPC' &&
|
|
2207
|
+
code !== 'EDQUOT' &&
|
|
2208
|
+
(err.path === src || (tree !== undefined && inTree(err.path, tree)))
|
|
2209
|
+
) {
|
|
2056
2210
|
throw new UserError(
|
|
2057
2211
|
`restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
|
|
2058
2212
|
`Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
|
|
@@ -2164,23 +2318,21 @@ export class Cache implements CacheLayer {
|
|
|
2164
2318
|
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
2165
2319
|
// round trips for an EEXIST and a stat (item 630).
|
|
2166
2320
|
const endPack = span('save: pack')
|
|
2167
|
-
const packed = await this.
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
},
|
|
2178
|
-
)
|
|
2321
|
+
const packed = await this.packForSave(args).catch((err: unknown) => {
|
|
2322
|
+
// A save that could not open an output names the limit, not only
|
|
2323
|
+
// the file, which is fine (A-39).
|
|
2324
|
+
if (isOutOfFds(err)) {
|
|
2325
|
+
throw new UserError(
|
|
2326
|
+
`save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
|
|
2327
|
+
)
|
|
2328
|
+
}
|
|
2329
|
+
throw err
|
|
2330
|
+
})
|
|
2179
2331
|
endPack()
|
|
2180
2332
|
await this.guard(() =>
|
|
2181
2333
|
this.writeArtifactAndIndex(
|
|
2182
2334
|
args.hash,
|
|
2183
|
-
'tar' in packed ? packed.compressed : packed,
|
|
2335
|
+
'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
|
|
2184
2336
|
{
|
|
2185
2337
|
taskId: args.entry.taskId,
|
|
2186
2338
|
command: args.entry.command,
|
|
@@ -2220,11 +2372,30 @@ export class Cache implements CacheLayer {
|
|
|
2220
2372
|
* gone; `release` unlinks the name. An inline artifact is read once into
|
|
2221
2373
|
* the body, which no re-save can change.
|
|
2222
2374
|
*/
|
|
2223
|
-
pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
|
|
2375
|
+
async pinArtifact(hash: string): Promise<{ body: Blob; release: () => Promise<void> }> {
|
|
2224
2376
|
const blob = this.guard(() => this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
|
|
2225
2377
|
if (blob !== null) return { body: new Blob([blob.bytes]), release: async () => {} }
|
|
2226
2378
|
const pinned = this.tempPath(hash)
|
|
2227
|
-
|
|
2379
|
+
try {
|
|
2380
|
+
linkSync(this.tarPath(hash), pinned)
|
|
2381
|
+
} catch (err) {
|
|
2382
|
+
const tree = this.treePath(hash)
|
|
2383
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
|
|
2384
|
+
// A tree is packed into the artifact a remote expects here, in the
|
|
2385
|
+
// upload queue, off the save; a small one in memory.
|
|
2386
|
+
const plan = planOfTree(tree, this.stdoutText(hash))
|
|
2387
|
+
if (plan.size <= STREAM_DECODE_FROM) {
|
|
2388
|
+
const bytes = await zstdCompress(await packArtifactBytes(plan))
|
|
2389
|
+
return { body: new Blob([bytes]), release: async () => {} }
|
|
2390
|
+
}
|
|
2391
|
+
await Bun.write(
|
|
2392
|
+
pinned,
|
|
2393
|
+
new Response(packArtifactStream(plan).pipeThrough(zstdEncoder())),
|
|
2394
|
+
).catch(async (e: unknown) => {
|
|
2395
|
+
await unlink(pinned).catch(() => undefined)
|
|
2396
|
+
throw e
|
|
2397
|
+
})
|
|
2398
|
+
}
|
|
2228
2399
|
return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
|
|
2229
2400
|
}
|
|
2230
2401
|
|
|
@@ -2233,7 +2404,7 @@ export class Cache implements CacheLayer {
|
|
|
2233
2404
|
* `Response` and copies a file `Blob` without collecting either, so a
|
|
2234
2405
|
* pull never holds the artifact. A body that fails mid-stream (a dropped
|
|
2235
2406
|
* socket) leaves a partial temp behind it, removed here; validation
|
|
2236
|
-
* removes its own. A body of at most `
|
|
2407
|
+
* removes its own. A body of at most `cache.store.embeddings` bytes — by its size,
|
|
2237
2408
|
* or counted as it streams — is read to memory and stored inline.
|
|
2238
2409
|
*/
|
|
2239
2410
|
async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
@@ -2253,22 +2424,28 @@ export class Cache implements CacheLayer {
|
|
|
2253
2424
|
let counted: ReadableStream<Uint8Array> | undefined
|
|
2254
2425
|
let reader: ChunkReader | undefined
|
|
2255
2426
|
let small: Uint8Array | undefined
|
|
2427
|
+
// A store of plain files holds a body up to the bulk decode's bound:
|
|
2428
|
+
// fetched whole, it is decoded in one call straight into the tree, with
|
|
2429
|
+
// no temp archive between (`writeArtifactAndIndex`).
|
|
2430
|
+
const held = this.store.decompress
|
|
2431
|
+
? Math.max(this.store.embeddings, STREAM_DECODE_FROM)
|
|
2432
|
+
: this.store.embeddings
|
|
2256
2433
|
try {
|
|
2257
2434
|
const length =
|
|
2258
2435
|
body instanceof Blob ? body.size : Number(body.headers.get('content-length') ?? 0)
|
|
2259
2436
|
if (length > cap) throw past()
|
|
2260
2437
|
if (body instanceof Blob) {
|
|
2261
|
-
if (length <=
|
|
2438
|
+
if (length <= held && held > 0) small = await body.bytes()
|
|
2262
2439
|
else await Bun.write(tmpPath, body)
|
|
2263
2440
|
} else {
|
|
2264
2441
|
let source = body.body
|
|
2265
2442
|
if (source === null) small = new Uint8Array(0)
|
|
2266
|
-
else if (length <=
|
|
2443
|
+
else if (length <= held && held > 0) {
|
|
2267
2444
|
// Counted as it streams: a length the server did not send, or
|
|
2268
|
-
// one it sent short, cannot
|
|
2445
|
+
// one it sent short, cannot read a large body to memory.
|
|
2269
2446
|
const r: ChunkReader = source.getReader()
|
|
2270
2447
|
reader = r
|
|
2271
|
-
const head = await readUpTo(r,
|
|
2448
|
+
const head = await readUpTo(r, held)
|
|
2272
2449
|
if (head.done) small = head.bytes
|
|
2273
2450
|
else source = prepended(head.bytes, r)
|
|
2274
2451
|
}
|
|
@@ -2406,6 +2583,36 @@ export class Cache implements CacheLayer {
|
|
|
2406
2583
|
return { tmpPath }
|
|
2407
2584
|
}
|
|
2408
2585
|
|
|
2586
|
+
/**
|
|
2587
|
+
* The save's local form: a small entry packed for the index when the
|
|
2588
|
+
* store embeds, else a `.tar.zst` (`decompress: false`) or a tree of the outputs
|
|
2589
|
+
* copied as they are.
|
|
2590
|
+
*/
|
|
2591
|
+
private async packForSave(
|
|
2592
|
+
args: Parameters<Cache['packArtifact']>[0],
|
|
2593
|
+
): Promise<
|
|
2594
|
+
| { compressed: Uint8Array; tar: Uint8Array }
|
|
2595
|
+
| { tmpPath: string }
|
|
2596
|
+
| { plan: true; saved: SavedTree }
|
|
2597
|
+
> {
|
|
2598
|
+
if (!this.store.decompress) return this.packArtifactToTemp(this.tempPath(args.hash), args)
|
|
2599
|
+
const plan = await this.planWithin(args)
|
|
2600
|
+
if (this.store.embeddings > 0 && plan.size <= this.store.embeddings) {
|
|
2601
|
+
const tar = await packArtifactBytes(plan)
|
|
2602
|
+
return { compressed: await zstdCompress(tar), tar }
|
|
2603
|
+
}
|
|
2604
|
+
const tree = this.tempTreePath(args.hash)
|
|
2605
|
+
try {
|
|
2606
|
+
const scan = await writeTree(plan, tree)
|
|
2607
|
+
let bytes = 0
|
|
2608
|
+
for (const e of scan.entries) bytes += e.size
|
|
2609
|
+
return { plan: true, saved: { tree, scan, bytes } }
|
|
2610
|
+
} catch (err) {
|
|
2611
|
+
await rm(tree, { recursive: true, force: true })
|
|
2612
|
+
throw err
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2409
2616
|
/** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
|
|
2410
2617
|
* the same hash from the same process (or from two forked workers that
|
|
2411
2618
|
* happen to share a wall-clock ms) don't pick the same tmp filename and
|
|
@@ -2416,7 +2623,7 @@ export class Cache implements CacheLayer {
|
|
|
2416
2623
|
|
|
2417
2624
|
/**
|
|
2418
2625
|
* Atomically write `compressed` to `<hash>.tar.zst`, or inline into
|
|
2419
|
-
* `artifacts` when it is at most `
|
|
2626
|
+
* `artifacts` when it is at most `cache.store.embeddings` bytes, and (re)build the
|
|
2420
2627
|
* entries + output_files SQL rows from the archive itself. Shared by
|
|
2421
2628
|
* `save()` (we just packed the bytes) and `ingest()` (it streamed them
|
|
2422
2629
|
* from the remote layer into a temp) — both index the identical values, because both
|
|
@@ -2424,7 +2631,7 @@ export class Cache implements CacheLayer {
|
|
|
2424
2631
|
*/
|
|
2425
2632
|
private async writeArtifactAndIndex(
|
|
2426
2633
|
hash: string,
|
|
2427
|
-
|
|
2634
|
+
given: Uint8Array | { tmpPath: string } | SavedTree,
|
|
2428
2635
|
meta: IngestMeta,
|
|
2429
2636
|
/**
|
|
2430
2637
|
* The tar `compressed` was made from, when this process packed it: it is
|
|
@@ -2441,12 +2648,25 @@ export class Cache implements CacheLayer {
|
|
|
2441
2648
|
// restore, so the size/mode/mtime fingerprint we store matches
|
|
2442
2649
|
// what isOutputsCurrent will compare against post-restore.
|
|
2443
2650
|
const finalPath = this.tarPath(hash)
|
|
2651
|
+
const saved = 'tree' in given ? given : undefined
|
|
2652
|
+
const compressed = 'tree' in given ? new Uint8Array(0) : given
|
|
2444
2653
|
// An inline artifact is written in the rows' transaction below: no
|
|
2445
2654
|
// temp, no rename.
|
|
2446
2655
|
const inline =
|
|
2447
|
-
|
|
2656
|
+
saved === undefined &&
|
|
2657
|
+
this.store.embeddings > 0 &&
|
|
2658
|
+
compressed instanceof Uint8Array &&
|
|
2659
|
+
compressed.byteLength <= this.store.embeddings
|
|
2660
|
+
? compressed
|
|
2661
|
+
: null
|
|
2662
|
+
// A remote hit this store keeps as plain files is decoded once, into the
|
|
2663
|
+
// tree, and checked there: nothing names the tree until the rows go in.
|
|
2664
|
+
const oneTree =
|
|
2665
|
+
saved === undefined && inline === null && tar === undefined && this.store.decompress
|
|
2448
2666
|
let tmpPath: string | undefined
|
|
2449
|
-
if (
|
|
2667
|
+
if (saved !== undefined) {
|
|
2668
|
+
// The tree save wrote and scanned its own outputs.
|
|
2669
|
+
} else if (inline === null && compressed instanceof Uint8Array && !oneTree) {
|
|
2450
2670
|
const temp = this.tempPath(hash)
|
|
2451
2671
|
tmpPath = temp
|
|
2452
2672
|
// The temp is written BEFORE validation so a large artifact can be
|
|
@@ -2470,9 +2690,35 @@ export class Cache implements CacheLayer {
|
|
|
2470
2690
|
tmpPath = compressed.tmpPath
|
|
2471
2691
|
}
|
|
2472
2692
|
let scanned: Awaited<ReturnType<typeof scanArtifact>>
|
|
2693
|
+
let tree = saved?.tree
|
|
2473
2694
|
try {
|
|
2474
2695
|
const endScan = span('save: scan')
|
|
2475
|
-
if (
|
|
2696
|
+
if (saved !== undefined) {
|
|
2697
|
+
scanned = saved.scan
|
|
2698
|
+
} else if (oneTree) {
|
|
2699
|
+
const dir = this.tempTreePath(hash)
|
|
2700
|
+
tree = dir
|
|
2701
|
+
let scan: Awaited<ReturnType<typeof scanArtifact>> | undefined
|
|
2702
|
+
let raw: { meta: string; sum: string } | undefined
|
|
2703
|
+
await extractArtifactStream(
|
|
2704
|
+
await decodedTar(
|
|
2705
|
+
compressed instanceof Uint8Array ? compressed : Bun.file(tmpPath!),
|
|
2706
|
+
hash,
|
|
2707
|
+
this.artifactCeiling,
|
|
2708
|
+
),
|
|
2709
|
+
path.join(dir, 'outputs'),
|
|
2710
|
+
path.join(dir, WORKSPACE_OUTPUT_PREFIX),
|
|
2711
|
+
undefined,
|
|
2712
|
+
undefined,
|
|
2713
|
+
undefined,
|
|
2714
|
+
(s, r) => {
|
|
2715
|
+
scan = s
|
|
2716
|
+
raw = r
|
|
2717
|
+
},
|
|
2718
|
+
)
|
|
2719
|
+
scanned = scan!
|
|
2720
|
+
await sealTree(dir, scanned, raw)
|
|
2721
|
+
} else if (tar !== undefined) {
|
|
2476
2722
|
// Our own tar, in memory: read in place, on this thread.
|
|
2477
2723
|
scanned = scanTarBytes(tar)
|
|
2478
2724
|
} else {
|
|
@@ -2505,12 +2751,45 @@ export class Cache implements CacheLayer {
|
|
|
2505
2751
|
])
|
|
2506
2752
|
} catch (err) {
|
|
2507
2753
|
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2754
|
+
if (tree !== undefined) await rm(tree, { recursive: true, force: true })
|
|
2508
2755
|
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
2509
2756
|
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
2510
2757
|
}
|
|
2511
2758
|
const { entries } = scanned
|
|
2512
2759
|
const totalBytes =
|
|
2513
|
-
|
|
2760
|
+
saved !== undefined
|
|
2761
|
+
? saved.bytes
|
|
2762
|
+
: compressed instanceof Uint8Array
|
|
2763
|
+
? compressed.byteLength
|
|
2764
|
+
: Bun.file(tmpPath!).size
|
|
2765
|
+
// A remote hit this store keeps as plain files: the archive passed its
|
|
2766
|
+
// checks above, so it is unpacked once here and its temp goes.
|
|
2767
|
+
if (oneTree && tmpPath !== undefined) {
|
|
2768
|
+
await unlink(tmpPath).catch(() => undefined)
|
|
2769
|
+
tmpPath = undefined
|
|
2770
|
+
}
|
|
2771
|
+
if (tree === undefined && inline === null && this.store.decompress) {
|
|
2772
|
+
const dir = this.tempTreePath(hash)
|
|
2773
|
+
try {
|
|
2774
|
+
const source =
|
|
2775
|
+
compressed instanceof Uint8Array && compressed.byteLength > 0
|
|
2776
|
+
? compressed
|
|
2777
|
+
: Bun.file(tmpPath!)
|
|
2778
|
+
await extractArtifactStream(
|
|
2779
|
+
await decodedTar(source, hash, this.artifactCeiling),
|
|
2780
|
+
path.join(dir, 'outputs'),
|
|
2781
|
+
path.join(dir, WORKSPACE_OUTPUT_PREFIX),
|
|
2782
|
+
)
|
|
2783
|
+
await sealTree(dir, scanned)
|
|
2784
|
+
} catch (err) {
|
|
2785
|
+
await rm(dir, { recursive: true, force: true })
|
|
2786
|
+
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2787
|
+
throw err
|
|
2788
|
+
}
|
|
2789
|
+
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2790
|
+
tmpPath = undefined
|
|
2791
|
+
tree = dir
|
|
2792
|
+
}
|
|
2514
2793
|
const outputFileRows: Array<[string, number, number, number]> = []
|
|
2515
2794
|
// Per-output-file fingerprint rows feed the skip-restore check.
|
|
2516
2795
|
// Row paths: project entries store the bare rel (`outputs/`
|
|
@@ -2600,19 +2879,27 @@ export class Cache implements CacheLayer {
|
|
|
2600
2879
|
// bytes are in one place. An inline save moves aside whatever file the
|
|
2601
2880
|
// key has, as a file save does: one rename that finds nothing on a
|
|
2602
2881
|
// first save, where asking the index first was a statement per save.
|
|
2603
|
-
let aside: string
|
|
2604
|
-
let renamed
|
|
2882
|
+
let aside: string[] = []
|
|
2883
|
+
let renamed: string | undefined
|
|
2605
2884
|
const upsertBlob = this.upsertBlob
|
|
2606
2885
|
const deleteBlob = this.deleteBlob
|
|
2607
|
-
const
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2886
|
+
const treePath = this.treePath(hash)
|
|
2887
|
+
// Both file forms are moved aside: a key re-saved under another
|
|
2888
|
+
// `cache.store` leaves one live artifact.
|
|
2889
|
+
const moveAside = (): string[] => {
|
|
2890
|
+
const moved: string[] = []
|
|
2891
|
+
for (const [from, to] of [
|
|
2892
|
+
[finalPath, this.tempPath(hash)],
|
|
2893
|
+
[treePath, this.tempTreePath(hash)],
|
|
2894
|
+
] as const) {
|
|
2895
|
+
try {
|
|
2896
|
+
renameSync(from, to)
|
|
2897
|
+
moved.push(to)
|
|
2898
|
+
} catch (err) {
|
|
2899
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
2900
|
+
}
|
|
2615
2901
|
}
|
|
2902
|
+
return moved
|
|
2616
2903
|
}
|
|
2617
2904
|
const tx = this.db.transaction(() => {
|
|
2618
2905
|
if (inline !== null) {
|
|
@@ -2620,9 +2907,10 @@ export class Cache implements CacheLayer {
|
|
|
2620
2907
|
upsertBlob.run(hash, now, inline)
|
|
2621
2908
|
} else {
|
|
2622
2909
|
aside = moveAside()
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2910
|
+
const live = tree !== undefined ? treePath : finalPath
|
|
2911
|
+
renameSync(tree ?? tmpPath!, live)
|
|
2912
|
+
this.artifactNames?.add(path.basename(live))
|
|
2913
|
+
renamed = live
|
|
2626
2914
|
deleteBlob.run(hash)
|
|
2627
2915
|
}
|
|
2628
2916
|
insertEntry.run(
|
|
@@ -2665,13 +2953,17 @@ export class Cache implements CacheLayer {
|
|
|
2665
2953
|
tx.immediate()
|
|
2666
2954
|
} catch (err) {
|
|
2667
2955
|
endTx()
|
|
2668
|
-
const left = renamed
|
|
2669
|
-
if (left !== undefined) await
|
|
2670
|
-
|
|
2956
|
+
const left = renamed ?? tree ?? tmpPath
|
|
2957
|
+
if (left !== undefined) await rm(left, { recursive: true, force: true })
|
|
2958
|
+
for (const a of aside) await rm(a, { recursive: true, force: true })
|
|
2671
2959
|
throw err
|
|
2672
2960
|
}
|
|
2673
2961
|
endTx()
|
|
2674
|
-
|
|
2962
|
+
for (const a of aside) await rm(a, { recursive: true, force: true })
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
private tempTreePath(hash: string): string {
|
|
2966
|
+
return `${this.treePath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
|
|
2675
2967
|
}
|
|
2676
2968
|
|
|
2677
2969
|
/** Apply the deferred accessed_at bumps in one statement. */
|
|
@@ -2688,7 +2980,7 @@ export class Cache implements CacheLayer {
|
|
|
2688
2980
|
const at = new Date(now)
|
|
2689
2981
|
for (const hash of this.staleTimes) {
|
|
2690
2982
|
try {
|
|
2691
|
-
utimesSync(this.tarPath(hash), at, at)
|
|
2983
|
+
utimesSync(this.artifactStat(hash)?.path ?? this.tarPath(hash), at, at)
|
|
2692
2984
|
} catch {
|
|
2693
2985
|
// Gone, or another user's file: a sweep judges its old time.
|
|
2694
2986
|
}
|
|
@@ -2777,67 +3069,6 @@ export class Cache implements CacheLayer {
|
|
|
2777
3069
|
}
|
|
2778
3070
|
}
|
|
2779
3071
|
|
|
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
3072
|
async prune(options: PruneOptions): Promise<PruneResult> {
|
|
2842
3073
|
return this.guard(() => this.pruneIndex(options))
|
|
2843
3074
|
}
|
|
@@ -2891,7 +3122,12 @@ export class Cache implements CacheLayer {
|
|
|
2891
3122
|
// and the one verb meant to free a full disk freed nothing (A-14). An
|
|
2892
3123
|
// inline artifact goes with its rows, in their transaction.
|
|
2893
3124
|
await Promise.all(
|
|
2894
|
-
hashes
|
|
3125
|
+
hashes
|
|
3126
|
+
.filter((h) => !inline.has(h))
|
|
3127
|
+
.flatMap((h) => [
|
|
3128
|
+
rm(this.tarPath(h), { force: true }),
|
|
3129
|
+
rm(this.treePath(h), { recursive: true, force: true }),
|
|
3130
|
+
]),
|
|
2895
3131
|
)
|
|
2896
3132
|
const { test, params } = inHashes(rows)
|
|
2897
3133
|
const deleteRows = this.db.transaction(() => {
|
|
@@ -2914,7 +3150,15 @@ export class Cache implements CacheLayer {
|
|
|
2914
3150
|
} catch (err) {
|
|
2915
3151
|
if (!isIndexFull(err)) throw err
|
|
2916
3152
|
}
|
|
2917
|
-
|
|
3153
|
+
// History and memo rows a month old go with a prune, never a run's
|
|
3154
|
+
// close: five DELETEs on every run's exit was its slowest step (owner,
|
|
3155
|
+
// 2026-10-18). `invocations` shares the window with `runs`, or `vx last`
|
|
3156
|
+
// would list a run whose task rows are gone.
|
|
3157
|
+
const cutoff = now - HISTORY_RETENTION_MS
|
|
3158
|
+
this.history.pruneOlderThan(cutoff)
|
|
3159
|
+
this.files.pruneOlderThan(cutoff)
|
|
3160
|
+
this.folds.prune(cutoff)
|
|
3161
|
+
const orphans = await this.reapOrphans(picked.files)
|
|
2918
3162
|
return { evicted: victims.size, bytesFreed, ...orphans }
|
|
2919
3163
|
}
|
|
2920
3164
|
|
|
@@ -2929,16 +3173,7 @@ export class Cache implements CacheLayer {
|
|
|
2929
3173
|
*/
|
|
2930
3174
|
private async reapOrphans(
|
|
2931
3175
|
taken: readonly RowlessFile[],
|
|
2932
|
-
now: number,
|
|
2933
3176
|
): 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
3177
|
let orphans = 0
|
|
2943
3178
|
let orphanBytes = 0
|
|
2944
3179
|
const blobs = taken.filter((o) => o.blob !== undefined)
|
|
@@ -2967,7 +3202,7 @@ export class Cache implements CacheLayer {
|
|
|
2967
3202
|
try {
|
|
2968
3203
|
// unlink, not `rm({ force })`: force swallows ENOENT, and a file a
|
|
2969
3204
|
// concurrent prune took first must not be counted as ours.
|
|
2970
|
-
await
|
|
3205
|
+
await rm(o.file, { recursive: true })
|
|
2971
3206
|
orphans += 1
|
|
2972
3207
|
orphanBytes += o.size
|
|
2973
3208
|
} catch {
|
|
@@ -3016,7 +3251,8 @@ export class Cache implements CacheLayer {
|
|
|
3016
3251
|
const phantoms = new Map<string, number>()
|
|
3017
3252
|
const stale: string[] = []
|
|
3018
3253
|
for (const r of rows) {
|
|
3019
|
-
if (r.inline === 1 || present.has(`${r.hash}.tar.zst`))
|
|
3254
|
+
if (r.inline === 1 || present.has(`${r.hash}.tar.zst`) || present.has(`${r.hash}.tree`))
|
|
3255
|
+
continue
|
|
3020
3256
|
phantoms.set(r.hash, r.size_bytes)
|
|
3021
3257
|
if (r.accessed_at < graceStart) stale.push(r.hash)
|
|
3022
3258
|
}
|
|
@@ -3068,34 +3304,12 @@ export class Cache implements CacheLayer {
|
|
|
3068
3304
|
}
|
|
3069
3305
|
|
|
3070
3306
|
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
3307
|
try {
|
|
3093
3308
|
this.flushAccessed()
|
|
3094
3309
|
this.outputs.flushOutputDirs()
|
|
3095
|
-
this.folds.flush(
|
|
3310
|
+
this.folds.flush()
|
|
3096
3311
|
} catch {
|
|
3097
|
-
//
|
|
3098
|
-
// guarded while this sibling was not: `accessed_at` is LRU
|
|
3312
|
+
// `accessed_at` is LRU
|
|
3099
3313
|
// bookkeeping, never correctness, and the run's results are
|
|
3100
3314
|
// already recorded by the time we get here. Letting it throw also
|
|
3101
3315
|
// SKIPPED `db.close()` below, leaking the handle. Reachable when
|
|
@@ -3109,6 +3323,21 @@ export class Cache implements CacheLayer {
|
|
|
3109
3323
|
private tarPath(hash: string): string {
|
|
3110
3324
|
return path.join(this.artifactDir, `${hash}.tar.zst`)
|
|
3111
3325
|
}
|
|
3326
|
+
|
|
3327
|
+
private treePath(hash: string): string {
|
|
3328
|
+
return path.join(this.artifactDir, `${hash}.tree`)
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/** The key's file-backed artifact (tar or tree), the form this store writes asked first. */
|
|
3332
|
+
private artifactStat(hash: string): { path: string; tree: boolean; mtimeMs: number } | undefined {
|
|
3333
|
+
const order = this.store.decompress ? [true, false] : [false, true]
|
|
3334
|
+
for (const tree of order) {
|
|
3335
|
+
const at = tree ? this.treePath(hash) : this.tarPath(hash)
|
|
3336
|
+
const st = statSync(at, { throwIfNoEntry: false })
|
|
3337
|
+
if (st !== undefined) return { path: at, tree, mtimeMs: st.mtimeMs }
|
|
3338
|
+
}
|
|
3339
|
+
return undefined
|
|
3340
|
+
}
|
|
3112
3341
|
}
|
|
3113
3342
|
|
|
3114
3343
|
/**
|
|
@@ -3181,6 +3410,17 @@ function assertArtifactNames(
|
|
|
3181
3410
|
}
|
|
3182
3411
|
}
|
|
3183
3412
|
|
|
3413
|
+
/** A row's path as the tree names its file. */
|
|
3414
|
+
function treeName(rowPath: string): string {
|
|
3415
|
+
return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
function inTree(p: string | undefined, tree: string): boolean {
|
|
3419
|
+
return p !== undefined && (p === tree || p.startsWith(`${tree}/`))
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
const utf8Decode = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
3423
|
+
|
|
3184
3424
|
/**
|
|
3185
3425
|
* Whether an artifact entry names one of the task's declared outputs, as
|
|
3186
3426
|
* the save could have packed it (`restorableOutput`: a `!` take-back, vx's
|