@vzn/vx 0.0.642 → 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 +417 -183
- package/src/cache/file-hashes.ts +2 -14
- package/src/cache/fold-memo.ts +7 -3
- package/src/cache/index.ts +10 -2
- package/src/cache/key-fold.ts +4 -1
- package/src/cache/layer.ts +11 -4
- package/src/cache/layered-cache.ts +82 -8
- package/src/cache/plugin-cache.ts +51 -43
- package/src/cache/remote-lane.ts +18 -13
- package/src/cache/run-store.ts +4 -1
- package/src/cache/scoped-cache.ts +74 -239
- package/src/cache/tar-stream.ts +12 -7
- package/src/cli/format.ts +1 -2
- package/src/cli/help.ts +8 -4
- package/src/cli/info.ts +2 -19
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +7 -12
- package/src/cli/select.ts +7 -9
- package/src/cli/upgrade.ts +38 -8
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +22 -26
- 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 +5 -1
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/doctor.ts +0 -17
- package/src/orchestrator/execute-task.ts +1 -1
- package/src/orchestrator/index.ts +4 -0
- package/src/orchestrator/options.ts +15 -1
- package/src/orchestrator/plugin-host.ts +11 -4
- package/src/orchestrator/plugin.ts +23 -2
- package/src/orchestrator/prepare.ts +81 -18
- package/src/orchestrator/projects.ts +26 -5
- package/src/orchestrator/remote-prefetch.ts +11 -4
- package/src/orchestrator/run-context.ts +85 -0
- package/src/orchestrator/run-settings.ts +3 -0
- package/src/orchestrator/run.ts +9 -26
- package/src/orchestrator/task-hash.ts +6 -0
- package/src/report/logger.ts +0 -4
- package/src/report/summary.ts +3 -11
- package/src/settings.ts +54 -25
- 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 +120 -63
- package/src/workspace/config-schema.ts +44 -52
- package/src/workspace/index.ts +3 -0
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
|
|
@@ -812,12 +848,6 @@ export interface RemoteTraffic {
|
|
|
812
848
|
/** Uploads queued or running across every remote. */
|
|
813
849
|
inFlight: number
|
|
814
850
|
readonly keys: Map<string, RemoteKeyTraffic>
|
|
815
|
-
/**
|
|
816
|
-
* A key a remote is asked under that is not the task's own (a scope's)
|
|
817
|
-
* → the task key it answers for: its traffic counts under the task key,
|
|
818
|
-
* which the footer and the run's rows look up.
|
|
819
|
-
*/
|
|
820
|
-
readonly aliases: Map<string, string>
|
|
821
851
|
}
|
|
822
852
|
|
|
823
853
|
export class Cache implements CacheLayer {
|
|
@@ -830,7 +860,7 @@ export class Cache implements CacheLayer {
|
|
|
830
860
|
*/
|
|
831
861
|
readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
|
|
832
862
|
/** The same remotes' traffic per key, filled by the same `LayeredCache`. */
|
|
833
|
-
readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map()
|
|
863
|
+
readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
|
|
834
864
|
|
|
835
865
|
private readonly db: Database
|
|
836
866
|
private readonly insertEntry: ReturnType<Database['prepare']>
|
|
@@ -945,6 +975,7 @@ export class Cache implements CacheLayer {
|
|
|
945
975
|
* records none (deleted, or never written beside a run's store).
|
|
946
976
|
*/
|
|
947
977
|
storeRoot?: string | null,
|
|
978
|
+
private readonly store: StoreOptions = DEFAULT_STORE,
|
|
948
979
|
) {
|
|
949
980
|
this.inspecting = mode !== 'open'
|
|
950
981
|
this.read = localPolicy.read
|
|
@@ -1683,7 +1714,7 @@ export class Cache implements CacheLayer {
|
|
|
1683
1714
|
} else {
|
|
1684
1715
|
// Verify the artifact file actually exists. The DB and the
|
|
1685
1716
|
// filesystem can drift if someone manually deletes the cache dir.
|
|
1686
|
-
const st =
|
|
1717
|
+
const st = this.artifactStat(hash)
|
|
1687
1718
|
if (st === undefined) return null
|
|
1688
1719
|
if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
|
|
1689
1720
|
}
|
|
@@ -1723,7 +1754,7 @@ export class Cache implements CacheLayer {
|
|
|
1723
1754
|
names = []
|
|
1724
1755
|
}
|
|
1725
1756
|
this.artifactNames ??= new Set(names)
|
|
1726
|
-
return !names.some((n) => n.endsWith('.tar.zst'))
|
|
1757
|
+
return !names.some((n) => n.endsWith('.tar.zst') || n.endsWith('.tree'))
|
|
1727
1758
|
}
|
|
1728
1759
|
|
|
1729
1760
|
/**
|
|
@@ -1772,7 +1803,7 @@ export class Cache implements CacheLayer {
|
|
|
1772
1803
|
if (r.inline_at < graceStart) this.staleInline.add(r.hash)
|
|
1773
1804
|
return true
|
|
1774
1805
|
}
|
|
1775
|
-
const st =
|
|
1806
|
+
const st = this.artifactStat(r.hash)
|
|
1776
1807
|
if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
|
|
1777
1808
|
return st !== undefined
|
|
1778
1809
|
})
|
|
@@ -1817,7 +1848,7 @@ export class Cache implements CacheLayer {
|
|
|
1817
1848
|
private storedHere(hash: string): boolean {
|
|
1818
1849
|
const row = this.entryExists.get(hash) as { inline_at: number | null } | null
|
|
1819
1850
|
if (row === null) return false
|
|
1820
|
-
return row.inline_at !== null ||
|
|
1851
|
+
return row.inline_at !== null || this.artifactStat(hash) !== undefined
|
|
1821
1852
|
}
|
|
1822
1853
|
|
|
1823
1854
|
// Local cache has no slower layer to warm from — prefetch is a no-op.
|
|
@@ -1892,9 +1923,120 @@ export class Cache implements CacheLayer {
|
|
|
1892
1923
|
}
|
|
1893
1924
|
}
|
|
1894
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
|
+
|
|
1895
2036
|
/** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
|
|
1896
2037
|
private async dropEntry(hash: string): Promise<void> {
|
|
1897
2038
|
await rm(this.tarPath(hash), { force: true })
|
|
2039
|
+
await rm(this.treePath(hash), { recursive: true, force: true })
|
|
1898
2040
|
const drop = this.db.transaction(() => {
|
|
1899
2041
|
this.deleteBlob.run(hash)
|
|
1900
2042
|
this.deleteEntryRow.run(hash)
|
|
@@ -1930,7 +2072,7 @@ export class Cache implements CacheLayer {
|
|
|
1930
2072
|
// outputs, so a restore that did not fail here would report a green cache
|
|
1931
2073
|
// hit over an emptied output tree. The artifact existed when `get()`
|
|
1932
2074
|
// probed it, so its absence now means something removed it underneath us
|
|
1933
|
-
// (a `vx cache clean` in another shell, or another workspace
|
|
2075
|
+
// (a `vx cache clean` in another shell, here or for another workspace
|
|
1934
2076
|
// on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
|
|
1935
2077
|
// turns into a miss: the task runs.
|
|
1936
2078
|
//
|
|
@@ -1992,6 +2134,8 @@ export class Cache implements CacheLayer {
|
|
|
1992
2134
|
// when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
|
|
1993
2135
|
// per 1 000). The local artifact was validated at ingest, so a missing
|
|
1994
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
|
|
1995
2139
|
const endExtract = span('restore: extract')
|
|
1996
2140
|
try {
|
|
1997
2141
|
// Every step here is an async filesystem call on purpose: a
|
|
@@ -1999,15 +2143,13 @@ export class Cache implements CacheLayer {
|
|
|
1999
2143
|
// 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
|
|
2000
2144
|
// four workers overlap their round trips and a blocking one stalls
|
|
2001
2145
|
// the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
|
|
2002
|
-
const
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
verify,
|
|
2008
|
-
|
|
2009
|
-
workspaceRoot,
|
|
2010
|
-
)
|
|
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
|
+
}
|
|
2011
2153
|
} catch (err) {
|
|
2012
2154
|
// A UserError here is the extractor naming the tree's fault (an
|
|
2013
2155
|
// output directory that links out of the project).
|
|
@@ -2058,7 +2200,13 @@ export class Cache implements CacheLayer {
|
|
|
2058
2200
|
// artifact is intact, the tree is not the process's to change.
|
|
2059
2201
|
// A refusal to READ the artifact is the cache directory's, not the
|
|
2060
2202
|
// outputs': the old line sent the reader to the wrong tree (A-40).
|
|
2061
|
-
|
|
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
|
+
) {
|
|
2062
2210
|
throw new UserError(
|
|
2063
2211
|
`restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
|
|
2064
2212
|
`Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
|
|
@@ -2170,23 +2318,21 @@ export class Cache implements CacheLayer {
|
|
|
2170
2318
|
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
2171
2319
|
// round trips for an EEXIST and a stat (item 630).
|
|
2172
2320
|
const endPack = span('save: pack')
|
|
2173
|
-
const packed = await this.
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
},
|
|
2184
|
-
)
|
|
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
|
+
})
|
|
2185
2331
|
endPack()
|
|
2186
2332
|
await this.guard(() =>
|
|
2187
2333
|
this.writeArtifactAndIndex(
|
|
2188
2334
|
args.hash,
|
|
2189
|
-
'tar' in packed ? packed.compressed : packed,
|
|
2335
|
+
'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
|
|
2190
2336
|
{
|
|
2191
2337
|
taskId: args.entry.taskId,
|
|
2192
2338
|
command: args.entry.command,
|
|
@@ -2226,11 +2372,30 @@ export class Cache implements CacheLayer {
|
|
|
2226
2372
|
* gone; `release` unlinks the name. An inline artifact is read once into
|
|
2227
2373
|
* the body, which no re-save can change.
|
|
2228
2374
|
*/
|
|
2229
|
-
pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
|
|
2375
|
+
async pinArtifact(hash: string): Promise<{ body: Blob; release: () => Promise<void> }> {
|
|
2230
2376
|
const blob = this.guard(() => this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
|
|
2231
2377
|
if (blob !== null) return { body: new Blob([blob.bytes]), release: async () => {} }
|
|
2232
2378
|
const pinned = this.tempPath(hash)
|
|
2233
|
-
|
|
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
|
+
}
|
|
2234
2399
|
return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
|
|
2235
2400
|
}
|
|
2236
2401
|
|
|
@@ -2239,7 +2404,7 @@ export class Cache implements CacheLayer {
|
|
|
2239
2404
|
* `Response` and copies a file `Blob` without collecting either, so a
|
|
2240
2405
|
* pull never holds the artifact. A body that fails mid-stream (a dropped
|
|
2241
2406
|
* socket) leaves a partial temp behind it, removed here; validation
|
|
2242
|
-
* removes its own. A body of at most `
|
|
2407
|
+
* removes its own. A body of at most `cache.store.embeddings` bytes — by its size,
|
|
2243
2408
|
* or counted as it streams — is read to memory and stored inline.
|
|
2244
2409
|
*/
|
|
2245
2410
|
async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
@@ -2259,22 +2424,28 @@ export class Cache implements CacheLayer {
|
|
|
2259
2424
|
let counted: ReadableStream<Uint8Array> | undefined
|
|
2260
2425
|
let reader: ChunkReader | undefined
|
|
2261
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
|
|
2262
2433
|
try {
|
|
2263
2434
|
const length =
|
|
2264
2435
|
body instanceof Blob ? body.size : Number(body.headers.get('content-length') ?? 0)
|
|
2265
2436
|
if (length > cap) throw past()
|
|
2266
2437
|
if (body instanceof Blob) {
|
|
2267
|
-
if (length <=
|
|
2438
|
+
if (length <= held && held > 0) small = await body.bytes()
|
|
2268
2439
|
else await Bun.write(tmpPath, body)
|
|
2269
2440
|
} else {
|
|
2270
2441
|
let source = body.body
|
|
2271
2442
|
if (source === null) small = new Uint8Array(0)
|
|
2272
|
-
else if (length <=
|
|
2443
|
+
else if (length <= held && held > 0) {
|
|
2273
2444
|
// Counted as it streams: a length the server did not send, or
|
|
2274
|
-
// one it sent short, cannot
|
|
2445
|
+
// one it sent short, cannot read a large body to memory.
|
|
2275
2446
|
const r: ChunkReader = source.getReader()
|
|
2276
2447
|
reader = r
|
|
2277
|
-
const head = await readUpTo(r,
|
|
2448
|
+
const head = await readUpTo(r, held)
|
|
2278
2449
|
if (head.done) small = head.bytes
|
|
2279
2450
|
else source = prepended(head.bytes, r)
|
|
2280
2451
|
}
|
|
@@ -2412,6 +2583,36 @@ export class Cache implements CacheLayer {
|
|
|
2412
2583
|
return { tmpPath }
|
|
2413
2584
|
}
|
|
2414
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
|
+
|
|
2415
2616
|
/** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
|
|
2416
2617
|
* the same hash from the same process (or from two forked workers that
|
|
2417
2618
|
* happen to share a wall-clock ms) don't pick the same tmp filename and
|
|
@@ -2422,7 +2623,7 @@ export class Cache implements CacheLayer {
|
|
|
2422
2623
|
|
|
2423
2624
|
/**
|
|
2424
2625
|
* Atomically write `compressed` to `<hash>.tar.zst`, or inline into
|
|
2425
|
-
* `artifacts` when it is at most `
|
|
2626
|
+
* `artifacts` when it is at most `cache.store.embeddings` bytes, and (re)build the
|
|
2426
2627
|
* entries + output_files SQL rows from the archive itself. Shared by
|
|
2427
2628
|
* `save()` (we just packed the bytes) and `ingest()` (it streamed them
|
|
2428
2629
|
* from the remote layer into a temp) — both index the identical values, because both
|
|
@@ -2430,7 +2631,7 @@ export class Cache implements CacheLayer {
|
|
|
2430
2631
|
*/
|
|
2431
2632
|
private async writeArtifactAndIndex(
|
|
2432
2633
|
hash: string,
|
|
2433
|
-
|
|
2634
|
+
given: Uint8Array | { tmpPath: string } | SavedTree,
|
|
2434
2635
|
meta: IngestMeta,
|
|
2435
2636
|
/**
|
|
2436
2637
|
* The tar `compressed` was made from, when this process packed it: it is
|
|
@@ -2447,12 +2648,25 @@ export class Cache implements CacheLayer {
|
|
|
2447
2648
|
// restore, so the size/mode/mtime fingerprint we store matches
|
|
2448
2649
|
// what isOutputsCurrent will compare against post-restore.
|
|
2449
2650
|
const finalPath = this.tarPath(hash)
|
|
2651
|
+
const saved = 'tree' in given ? given : undefined
|
|
2652
|
+
const compressed = 'tree' in given ? new Uint8Array(0) : given
|
|
2450
2653
|
// An inline artifact is written in the rows' transaction below: no
|
|
2451
2654
|
// temp, no rename.
|
|
2452
2655
|
const inline =
|
|
2453
|
-
|
|
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
|
|
2454
2666
|
let tmpPath: string | undefined
|
|
2455
|
-
if (
|
|
2667
|
+
if (saved !== undefined) {
|
|
2668
|
+
// The tree save wrote and scanned its own outputs.
|
|
2669
|
+
} else if (inline === null && compressed instanceof Uint8Array && !oneTree) {
|
|
2456
2670
|
const temp = this.tempPath(hash)
|
|
2457
2671
|
tmpPath = temp
|
|
2458
2672
|
// The temp is written BEFORE validation so a large artifact can be
|
|
@@ -2476,9 +2690,35 @@ export class Cache implements CacheLayer {
|
|
|
2476
2690
|
tmpPath = compressed.tmpPath
|
|
2477
2691
|
}
|
|
2478
2692
|
let scanned: Awaited<ReturnType<typeof scanArtifact>>
|
|
2693
|
+
let tree = saved?.tree
|
|
2479
2694
|
try {
|
|
2480
2695
|
const endScan = span('save: scan')
|
|
2481
|
-
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) {
|
|
2482
2722
|
// Our own tar, in memory: read in place, on this thread.
|
|
2483
2723
|
scanned = scanTarBytes(tar)
|
|
2484
2724
|
} else {
|
|
@@ -2511,12 +2751,45 @@ export class Cache implements CacheLayer {
|
|
|
2511
2751
|
])
|
|
2512
2752
|
} catch (err) {
|
|
2513
2753
|
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2754
|
+
if (tree !== undefined) await rm(tree, { recursive: true, force: true })
|
|
2514
2755
|
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
2515
2756
|
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
2516
2757
|
}
|
|
2517
2758
|
const { entries } = scanned
|
|
2518
2759
|
const totalBytes =
|
|
2519
|
-
|
|
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
|
+
}
|
|
2520
2793
|
const outputFileRows: Array<[string, number, number, number]> = []
|
|
2521
2794
|
// Per-output-file fingerprint rows feed the skip-restore check.
|
|
2522
2795
|
// Row paths: project entries store the bare rel (`outputs/`
|
|
@@ -2606,19 +2879,27 @@ export class Cache implements CacheLayer {
|
|
|
2606
2879
|
// bytes are in one place. An inline save moves aside whatever file the
|
|
2607
2880
|
// key has, as a file save does: one rename that finds nothing on a
|
|
2608
2881
|
// first save, where asking the index first was a statement per save.
|
|
2609
|
-
let aside: string
|
|
2610
|
-
let renamed
|
|
2882
|
+
let aside: string[] = []
|
|
2883
|
+
let renamed: string | undefined
|
|
2611
2884
|
const upsertBlob = this.upsertBlob
|
|
2612
2885
|
const deleteBlob = this.deleteBlob
|
|
2613
|
-
const
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
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
|
+
}
|
|
2621
2901
|
}
|
|
2902
|
+
return moved
|
|
2622
2903
|
}
|
|
2623
2904
|
const tx = this.db.transaction(() => {
|
|
2624
2905
|
if (inline !== null) {
|
|
@@ -2626,9 +2907,10 @@ export class Cache implements CacheLayer {
|
|
|
2626
2907
|
upsertBlob.run(hash, now, inline)
|
|
2627
2908
|
} else {
|
|
2628
2909
|
aside = moveAside()
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2910
|
+
const live = tree !== undefined ? treePath : finalPath
|
|
2911
|
+
renameSync(tree ?? tmpPath!, live)
|
|
2912
|
+
this.artifactNames?.add(path.basename(live))
|
|
2913
|
+
renamed = live
|
|
2632
2914
|
deleteBlob.run(hash)
|
|
2633
2915
|
}
|
|
2634
2916
|
insertEntry.run(
|
|
@@ -2671,13 +2953,17 @@ export class Cache implements CacheLayer {
|
|
|
2671
2953
|
tx.immediate()
|
|
2672
2954
|
} catch (err) {
|
|
2673
2955
|
endTx()
|
|
2674
|
-
const left = renamed
|
|
2675
|
-
if (left !== undefined) await
|
|
2676
|
-
|
|
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 })
|
|
2677
2959
|
throw err
|
|
2678
2960
|
}
|
|
2679
2961
|
endTx()
|
|
2680
|
-
|
|
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)}`
|
|
2681
2967
|
}
|
|
2682
2968
|
|
|
2683
2969
|
/** Apply the deferred accessed_at bumps in one statement. */
|
|
@@ -2694,7 +2980,7 @@ export class Cache implements CacheLayer {
|
|
|
2694
2980
|
const at = new Date(now)
|
|
2695
2981
|
for (const hash of this.staleTimes) {
|
|
2696
2982
|
try {
|
|
2697
|
-
utimesSync(this.tarPath(hash), at, at)
|
|
2983
|
+
utimesSync(this.artifactStat(hash)?.path ?? this.tarPath(hash), at, at)
|
|
2698
2984
|
} catch {
|
|
2699
2985
|
// Gone, or another user's file: a sweep judges its old time.
|
|
2700
2986
|
}
|
|
@@ -2783,67 +3069,6 @@ export class Cache implements CacheLayer {
|
|
|
2783
3069
|
}
|
|
2784
3070
|
}
|
|
2785
3071
|
|
|
2786
|
-
/**
|
|
2787
|
-
* The workspace's `cacheRetention`, applied at the end of a run: `prune()`
|
|
2788
|
-
* with the same policy, but only when it would evict something, else the
|
|
2789
|
-
* orphan sweep alone when an hour has passed since the last one. A run
|
|
2790
|
-
* with nothing due pays the accessed-at flush it owed at close anyway, one
|
|
2791
|
-
* scan of the index and one read of the sweep's clock — never the
|
|
2792
|
-
* sweep's readdir. Null when nothing was due or the directory is not this
|
|
2793
|
-
* handle's to write. The local WRITE axis does not gate it: under
|
|
2794
|
-
* `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
|
|
2795
|
-
* hit is still ingested (`ingest`, ungated by design), and gated on that
|
|
2796
|
-
* axis the retention never ran while the store grew hit by hit (A-7).
|
|
2797
|
-
*/
|
|
2798
|
-
async evictIfDue(
|
|
2799
|
-
policy: { maxAgeMs?: number; maxBytes?: number },
|
|
2800
|
-
now: number = Date.now(),
|
|
2801
|
-
): Promise<PruneResult | null> {
|
|
2802
|
-
return this.guard(() => this.evictIfDueNow(policy, now))
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
private async evictIfDueNow(
|
|
2806
|
-
policy: { maxAgeMs?: number; maxBytes?: number },
|
|
2807
|
-
now: number,
|
|
2808
|
-
): Promise<PruneResult | null> {
|
|
2809
|
-
if (this.writeBlocked !== null || this.inspecting) return null
|
|
2810
|
-
// First: an entry this run restored still carries its old `accessed_at`
|
|
2811
|
-
// until the deferred bump lands, and would read as due for eviction.
|
|
2812
|
-
this.flushAccessed()
|
|
2813
|
-
const olderThanMs = policy.maxAgeMs === undefined ? undefined : now - policy.maxAgeMs
|
|
2814
|
-
const { oldest, bytes } = this.db
|
|
2815
|
-
.prepare(
|
|
2816
|
-
'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
|
|
2817
|
-
)
|
|
2818
|
-
.get() as { oldest: number | null; bytes: number }
|
|
2819
|
-
const ageDue =
|
|
2820
|
-
olderThanMs !== undefined && oldest !== null && oldest + ACCESS_REFRESH_MS < olderThanMs
|
|
2821
|
-
const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
|
|
2822
|
-
if (ageDue || sizeDue) {
|
|
2823
|
-
return this.prune({
|
|
2824
|
-
...(olderThanMs !== undefined ? { olderThanMs } : {}),
|
|
2825
|
-
...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
|
|
2826
|
-
})
|
|
2827
|
-
}
|
|
2828
|
-
// Row-less artifacts are bytes the index cannot count, so the policy
|
|
2829
|
-
// above never sees them (upstream survey, nx#35483: 9 MiB of them sat
|
|
2830
|
-
// under a 1 MB limit). Listing the directory to find them costs 0.5 ms
|
|
2831
|
-
// per 1,000 entries (4.9 ms at 10,000), every run; the sweep's own
|
|
2832
|
-
// clock in `schema_meta` costs one indexed read. The sweep is the
|
|
2833
|
-
// policy's own prune, with the row-less files counted.
|
|
2834
|
-
const swept = this.db.prepare('SELECT value FROM schema_meta WHERE key = ?').get(SWEPT_AT) as {
|
|
2835
|
-
value: string
|
|
2836
|
-
} | null
|
|
2837
|
-
if (swept !== null && now - Number(swept.value) < ORPHAN_GRACE_MS) return null
|
|
2838
|
-
return this.pruneIndex(
|
|
2839
|
-
{
|
|
2840
|
-
...(olderThanMs !== undefined ? { olderThanMs } : {}),
|
|
2841
|
-
...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
|
|
2842
|
-
},
|
|
2843
|
-
now,
|
|
2844
|
-
)
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
3072
|
async prune(options: PruneOptions): Promise<PruneResult> {
|
|
2848
3073
|
return this.guard(() => this.pruneIndex(options))
|
|
2849
3074
|
}
|
|
@@ -2897,7 +3122,12 @@ export class Cache implements CacheLayer {
|
|
|
2897
3122
|
// and the one verb meant to free a full disk freed nothing (A-14). An
|
|
2898
3123
|
// inline artifact goes with its rows, in their transaction.
|
|
2899
3124
|
await Promise.all(
|
|
2900
|
-
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
|
+
]),
|
|
2901
3131
|
)
|
|
2902
3132
|
const { test, params } = inHashes(rows)
|
|
2903
3133
|
const deleteRows = this.db.transaction(() => {
|
|
@@ -2920,7 +3150,15 @@ export class Cache implements CacheLayer {
|
|
|
2920
3150
|
} catch (err) {
|
|
2921
3151
|
if (!isIndexFull(err)) throw err
|
|
2922
3152
|
}
|
|
2923
|
-
|
|
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)
|
|
2924
3162
|
return { evicted: victims.size, bytesFreed, ...orphans }
|
|
2925
3163
|
}
|
|
2926
3164
|
|
|
@@ -2935,16 +3173,7 @@ export class Cache implements CacheLayer {
|
|
|
2935
3173
|
*/
|
|
2936
3174
|
private async reapOrphans(
|
|
2937
3175
|
taken: readonly RowlessFile[],
|
|
2938
|
-
now: number,
|
|
2939
3176
|
): Promise<{ orphans: number; orphanBytes: number }> {
|
|
2940
|
-
try {
|
|
2941
|
-
this.db
|
|
2942
|
-
.prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
|
|
2943
|
-
.run(SWEPT_AT, String(now))
|
|
2944
|
-
} catch (err) {
|
|
2945
|
-
// The sweep's clock: on a full disk the sweep runs again next time.
|
|
2946
|
-
if (!isIndexFull(err)) throw err
|
|
2947
|
-
}
|
|
2948
3177
|
let orphans = 0
|
|
2949
3178
|
let orphanBytes = 0
|
|
2950
3179
|
const blobs = taken.filter((o) => o.blob !== undefined)
|
|
@@ -2973,7 +3202,7 @@ export class Cache implements CacheLayer {
|
|
|
2973
3202
|
try {
|
|
2974
3203
|
// unlink, not `rm({ force })`: force swallows ENOENT, and a file a
|
|
2975
3204
|
// concurrent prune took first must not be counted as ours.
|
|
2976
|
-
await
|
|
3205
|
+
await rm(o.file, { recursive: true })
|
|
2977
3206
|
orphans += 1
|
|
2978
3207
|
orphanBytes += o.size
|
|
2979
3208
|
} catch {
|
|
@@ -3022,7 +3251,8 @@ export class Cache implements CacheLayer {
|
|
|
3022
3251
|
const phantoms = new Map<string, number>()
|
|
3023
3252
|
const stale: string[] = []
|
|
3024
3253
|
for (const r of rows) {
|
|
3025
|
-
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
|
|
3026
3256
|
phantoms.set(r.hash, r.size_bytes)
|
|
3027
3257
|
if (r.accessed_at < graceStart) stale.push(r.hash)
|
|
3028
3258
|
}
|
|
@@ -3074,34 +3304,12 @@ export class Cache implements CacheLayer {
|
|
|
3074
3304
|
}
|
|
3075
3305
|
|
|
3076
3306
|
close(): void {
|
|
3077
|
-
// Run-history retention: the runs table grows by one row per
|
|
3078
|
-
// executed task per invocation — a 2000-task repo accretes ~20k
|
|
3079
|
-
// rows in days, inflating insert and checkpoint cost forever.
|
|
3080
|
-
// 30 days comfortably covers `vx info` (24 h windows) and any
|
|
3081
|
-
// CI-side analytics consumers. The `invocations` header table (one
|
|
3082
|
-
// row per `vx run`) is pruned on the SAME window — otherwise a
|
|
3083
|
-
// header would outlive its `runs` rows, so `vx info`/`vx last` would
|
|
3084
|
-
// list an invocation whose task detail is already gone, and the
|
|
3085
|
-
// table would grow unbounded on a long-lived checkout.
|
|
3086
|
-
// A reading verb and a dry run delete nothing: `vx last --list` over
|
|
3087
|
-
// month-old runs emptied the history it listed, and `vx cache clean
|
|
3088
|
-
// --dry-run` pruned it (item 1004).
|
|
3089
|
-
try {
|
|
3090
|
-
if (!this.inspecting) {
|
|
3091
|
-
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
3092
|
-
this.history.pruneOlderThan(cutoff)
|
|
3093
|
-
if (this.write) this.files.pruneOlderThan(cutoff)
|
|
3094
|
-
}
|
|
3095
|
-
} catch {
|
|
3096
|
-
// Retention is best-effort; never block closing the handle.
|
|
3097
|
-
}
|
|
3098
3307
|
try {
|
|
3099
3308
|
this.flushAccessed()
|
|
3100
3309
|
this.outputs.flushOutputDirs()
|
|
3101
|
-
this.folds.flush(
|
|
3310
|
+
this.folds.flush()
|
|
3102
3311
|
} catch {
|
|
3103
|
-
//
|
|
3104
|
-
// guarded while this sibling was not: `accessed_at` is LRU
|
|
3312
|
+
// `accessed_at` is LRU
|
|
3105
3313
|
// bookkeeping, never correctness, and the run's results are
|
|
3106
3314
|
// already recorded by the time we get here. Letting it throw also
|
|
3107
3315
|
// SKIPPED `db.close()` below, leaking the handle. Reachable when
|
|
@@ -3115,6 +3323,21 @@ export class Cache implements CacheLayer {
|
|
|
3115
3323
|
private tarPath(hash: string): string {
|
|
3116
3324
|
return path.join(this.artifactDir, `${hash}.tar.zst`)
|
|
3117
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
|
+
}
|
|
3118
3341
|
}
|
|
3119
3342
|
|
|
3120
3343
|
/**
|
|
@@ -3187,6 +3410,17 @@ function assertArtifactNames(
|
|
|
3187
3410
|
}
|
|
3188
3411
|
}
|
|
3189
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
|
+
|
|
3190
3424
|
/**
|
|
3191
3425
|
* Whether an artifact entry names one of the task's declared outputs, as
|
|
3192
3426
|
* the save could have packed it (`restorableOutput`: a `!` take-back, vx's
|