@vzn/vx 0.0.649 → 0.0.651
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/README.md +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +225 -51
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/fold-memo.ts +8 -1
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +12 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +120 -0
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-hits.ts +9 -1
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/run-notes.ts +123 -0
- package/src/cache/schema.ts +54 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +224 -223
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +10 -3
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +126 -92
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +12 -2
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +39 -2
- package/src/history/index.ts +3 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +12 -3
- package/src/orchestrator/doctor.ts +3 -3
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +3 -0
- package/src/orchestrator/index.ts +3 -1
- package/src/orchestrator/live-usage.ts +98 -0
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +176 -102
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +49 -4
- package/src/orchestrator/prepare.ts +18 -2
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +35 -114
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +89 -26
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/forecast.ts +12 -33
- package/src/report/framed-output.ts +388 -161
- package/src/report/index.ts +8 -2
- package/src/report/logger.ts +100 -36
- package/src/report/status-line.ts +81 -365
- package/src/report/summary.ts +104 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/hidden-prompt.ts +8 -2
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/secrets.ts +3 -0
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/orchestrator/run-steps.ts +0 -93
- package/src/util/colors.ts +0 -130
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
package/README.md
CHANGED
|
@@ -142,9 +142,8 @@ not a way to run the repo. The benchmark below uses native vx config.
|
|
|
142
142
|
| Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
|
|
143
143
|
| Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node |
|
|
144
144
|
|
|
145
|
-
Every gap
|
|
146
|
-
[
|
|
147
|
-
[vx, Turborepo, Nx, Bazel](https://vznjs.github.io/vx/compare/).
|
|
145
|
+
Every gap: [comparison](packages/vx/docs/comparison.md) ·
|
|
146
|
+
every tool on one suite of cases: [conformance](https://vznjs.github.io/vx/conformance/).
|
|
148
147
|
|
|
149
148
|
## Fast, without trading correctness
|
|
150
149
|
|
|
@@ -152,23 +151,9 @@ Every gap, and when another tool is the better pick:
|
|
|
152
151
|
- **No source-file reads to hash:** on a clean tree, keys come from git's index; each project's `package.json` and the lockfile are still read.
|
|
153
152
|
- **One pass:** one git walk per run; each stat and lookup happens once.
|
|
154
153
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
| ---------------------------------------------------------- | ---------- | ---------- | ---------- | --------- |
|
|
159
|
-
| Nothing changed | **799 ms** | 927 ms | 24.15 s | 12.07 s |
|
|
160
|
-
| Nothing changed, outputs restored | **1.28 s** | 2.01 s | 24.45 s | 11.86 s |
|
|
161
|
-
| Cold build | **7.57 s** | 9.63 s | 3 min 6 s | 9.01 s |
|
|
162
|
-
| Cold build: CPU the runner burns | 36.64 s | 1 min 12 s | 5 min 34 s | 25.32 s |
|
|
163
|
-
|
|
164
|
-
Time each tool adds over the ideal run; lower is better.
|
|
165
|
-
|
|
166
|
-
Benchmark workload: a synthetic monorepo of 1,601 projects and 9,603 tasks in 30 dependency levels, five core libraries a quarter of the projects use; build 300 ms, test and typecheck 150 ms, lint 75 ms, publish 30 ms; real repos with uneven task times will differ.
|
|
167
|
-
Run 2026-10-25 on linux x64, 4 cores: vx at commit 9abdfb52c, Turborepo 2.11.10, Nx 24.0.1, Vite Task (vite-plus) 1.4.0.
|
|
168
|
-
|
|
169
|
-
Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
|
|
170
|
-
|
|
171
|
-
<!-- bench:end -->
|
|
154
|
+
The head-to-head against Turborepo, Nx and Vite Task, each in its own
|
|
155
|
+
native config, is re-measured on every change to vx and published on the
|
|
156
|
+
site: [benchmarks](https://vznjs.github.io/vx/benchmarks/).
|
|
172
157
|
|
|
173
158
|
## Status
|
|
174
159
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.651",
|
|
4
4
|
"description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.651",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.651",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.651",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.651"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/schemas/last.json
CHANGED
|
@@ -242,7 +242,10 @@
|
|
|
242
242
|
"peakRssBytes",
|
|
243
243
|
"peakCores",
|
|
244
244
|
"avgRssBytes",
|
|
245
|
+
"queuedMs",
|
|
245
246
|
"placement",
|
|
247
|
+
"outputBytes",
|
|
248
|
+
"blocks",
|
|
246
249
|
"wallclockStartNs",
|
|
247
250
|
"wallclockEndNs",
|
|
248
251
|
"blockedBy",
|
|
@@ -288,10 +291,22 @@
|
|
|
288
291
|
"type": ["integer", "null"],
|
|
289
292
|
"description": "mean resident memory over the samples; null when nothing sampled"
|
|
290
293
|
},
|
|
294
|
+
"queuedMs": {
|
|
295
|
+
"type": ["integer", "null"],
|
|
296
|
+
"description": "milliseconds it waited, ready, for a slot; null when it never queued"
|
|
297
|
+
},
|
|
291
298
|
"placement": {
|
|
292
299
|
"type": ["string", "null"],
|
|
293
300
|
"description": "where an executor ran it (small#1); null when here"
|
|
294
301
|
},
|
|
302
|
+
"outputBytes": {
|
|
303
|
+
"type": ["integer", "null"],
|
|
304
|
+
"description": "bytes of its outputs: what its save stored, or its hit restored"
|
|
305
|
+
},
|
|
306
|
+
"blocks": {
|
|
307
|
+
"type": ["integer", "null"],
|
|
308
|
+
"description": "how many tasks were downstream of it"
|
|
309
|
+
},
|
|
295
310
|
"wallclockStartNs": {
|
|
296
311
|
"type": ["string", "null"],
|
|
297
312
|
"description": "decimal nanoseconds from the run's start"
|
package/src/bin.ts
CHANGED
|
@@ -21,32 +21,14 @@ async function main(): Promise<void> {
|
|
|
21
21
|
// `vx --version` needs one leaf module; the dispatcher and the util
|
|
22
22
|
// barrel it pulled in were 20 ms of its 28 (2026-10-01).
|
|
23
23
|
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === 'version')) {
|
|
24
|
-
// `Bun.stdout`, not `process.stdout`: the Node stream's first touch is
|
|
25
|
-
// ~7 ms, half of this verb (2026-10-02). A reader gone (EPIPE) is no
|
|
26
|
-
// failure of the line.
|
|
27
24
|
// Handed off, it says which vx ran and which one handed it the call.
|
|
28
25
|
const from = process.env['VX_HANDED_OFF'] || undefined
|
|
29
26
|
const where = from === undefined ? '' : ` (${vxPath()}, handed off from ${from})`
|
|
30
|
-
|
|
31
|
-
// Decided here, not by util's `detectColors`: the util barrel was the
|
|
32
|
-
// 20 ms above, and `node:tty` 17 ms; fd 1 as a character device is the
|
|
33
|
-
// TTY answer. A CI's log is not painted for this one line.
|
|
34
|
-
const [{ VERSION }, { fstatSync }] = await Promise.all([
|
|
27
|
+
const [{ VERSION }, { printVersion }] = await Promise.all([
|
|
35
28
|
import('./version.js'),
|
|
36
|
-
import('
|
|
29
|
+
import('./ui/version-line.js'),
|
|
37
30
|
])
|
|
38
|
-
|
|
39
|
-
const on = env['NO_COLOR']
|
|
40
|
-
? false
|
|
41
|
-
: env['FORCE_COLOR'] !== undefined
|
|
42
|
-
? env['FORCE_COLOR'] !== '0' && env['FORCE_COLOR'] !== 'false'
|
|
43
|
-
: env['TERM'] !== 'dumb' && fstatSync(1).isCharacterDevice()
|
|
44
|
-
const sgr = (code: string, text: string): string =>
|
|
45
|
-
on ? `\x1b[${code}m${text}\x1b[0m` : text
|
|
46
|
-
await Bun.write(
|
|
47
|
-
Bun.stdout,
|
|
48
|
-
`${sgr('1', 'vx')} ${sgr('38;2;6;182;212', VERSION)}${where === '' ? '' : sgr('2', where)}\n`,
|
|
49
|
-
).catch(() => {})
|
|
31
|
+
await printVersion(VERSION, where)
|
|
50
32
|
settled = true
|
|
51
33
|
return
|
|
52
34
|
}
|
|
@@ -64,8 +46,9 @@ async function main(): Promise<void> {
|
|
|
64
46
|
// is set, and `exitOnceFlushed` exits after both streams have ended.
|
|
65
47
|
process.exitCode = code
|
|
66
48
|
} catch (err) {
|
|
49
|
+
const ui = await import('./ui/index.js')
|
|
50
|
+
const { refusalNotice } = ui
|
|
67
51
|
const {
|
|
68
|
-
detectColors,
|
|
69
52
|
errorCode,
|
|
70
53
|
errorDocument,
|
|
71
54
|
fsRefusalHint,
|
|
@@ -74,17 +57,12 @@ async function main(): Promise<void> {
|
|
|
74
57
|
isUserError,
|
|
75
58
|
maskedLine,
|
|
76
59
|
OUT_OF_FDS_HINT,
|
|
77
|
-
refusalNotice,
|
|
78
|
-
ui,
|
|
79
60
|
wantsJson,
|
|
80
61
|
} = await import('./util/index.js')
|
|
81
62
|
// A plugin's failure quotes what it was told (a remote's reply, a
|
|
82
63
|
// header), and a config's own throw is its author's text: a secret in
|
|
83
64
|
// either reached stderr whole (L-11).
|
|
84
|
-
const say = (line: string): void =>
|
|
85
|
-
void process.stderr.write(
|
|
86
|
-
`${ui.tagged('error', maskedLine(line), detectColors(process.stderr))}\n`,
|
|
87
|
-
)
|
|
65
|
+
const say = (line: string): void => ui.say('error', maskedLine(line))
|
|
88
66
|
// UserError (workspace not found, cycle, config invalid, ...) —
|
|
89
67
|
// print the message only; the stack is noise the user can't act
|
|
90
68
|
// on. Everything else gets the full stack so internal bugs are
|
|
@@ -100,7 +78,7 @@ async function main(): Promise<void> {
|
|
|
100
78
|
named === null ? err.message : err.message.slice(named[0].length),
|
|
101
79
|
err.parts,
|
|
102
80
|
)
|
|
103
|
-
|
|
81
|
+
ui.data('err', maskedLine(ui.render([block], ui.colorsOf('err'))))
|
|
104
82
|
} else if (isOutOfFds(err)) {
|
|
105
83
|
say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
|
|
106
84
|
} else if (isFsRefusal(err)) {
|
|
@@ -114,7 +92,7 @@ async function main(): Promise<void> {
|
|
|
114
92
|
// stdout got nothing to parse, and only prose on stderr to branch on.
|
|
115
93
|
if (wantsJson(argv)) {
|
|
116
94
|
const message = err instanceof Error ? err.message : String(err)
|
|
117
|
-
|
|
95
|
+
ui.data('out', errorDocument(errorCode(err), maskedLine(message)))
|
|
118
96
|
}
|
|
119
97
|
// Same reason as the success path above: a large stderr is truncated by
|
|
120
98
|
// `process.exit` too, and an error message cut in half is the one a
|
|
@@ -174,7 +152,7 @@ function withCauses(err: unknown): string {
|
|
|
174
152
|
process.on('beforeExit', () => {
|
|
175
153
|
if (settled) return
|
|
176
154
|
settled = true
|
|
177
|
-
;(require('./
|
|
155
|
+
;(require('./ui/index.js') as typeof import('./ui/index.js')).say(
|
|
178
156
|
'error',
|
|
179
157
|
'vx: the run stopped before it finished: something it awaited can never settle (a plugin hook?)',
|
|
180
158
|
)
|
package/src/cache/cache.ts
CHANGED
|
@@ -117,8 +117,8 @@ import { FileHashStore } from './file-hashes.js'
|
|
|
117
117
|
import { DIR_MODE, LINK_MODE, OutputIndex } from './output-index.js'
|
|
118
118
|
import { RunHistory } from './run-history.js'
|
|
119
119
|
import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
120
|
-
import { FoldMemo } from './fold-memo.js'
|
|
121
|
-
import { RemoteHits } from './remote-hits.js'
|
|
120
|
+
import { type FoldMemo, IndexFoldMemo } from './fold-memo.js'
|
|
121
|
+
import { IndexRemoteHits, type RemoteHits } from './remote-hits.js'
|
|
122
122
|
import { behindLink } from './linked-inputs.js'
|
|
123
123
|
|
|
124
124
|
/**
|
|
@@ -130,6 +130,9 @@ import { behindLink } from './linked-inputs.js'
|
|
|
130
130
|
* this, so the file time trails the last use by at most this much, and
|
|
131
131
|
* the policy reads the file time plus this as the last use.
|
|
132
132
|
*/
|
|
133
|
+
// About 1.3 ms of inserts per turn: a task's exit waits at most that.
|
|
134
|
+
const HELD_SLICE = 250
|
|
135
|
+
|
|
133
136
|
const ORPHAN_GRACE_MS = 60 * 60 * 1000
|
|
134
137
|
|
|
135
138
|
/**
|
|
@@ -164,13 +167,8 @@ const VX_CACHE_NAMES = new Set([
|
|
|
164
167
|
'cache.db-wal',
|
|
165
168
|
'cache.db-shm',
|
|
166
169
|
'cache.db-journal',
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// `.vx/cache/compiled`, so a `runs` or `compiled` here is the user's.
|
|
170
|
-
'failures',
|
|
171
|
-
'steps',
|
|
172
|
-
'warnings',
|
|
173
|
-
'lockfile-claims',
|
|
170
|
+
// Every record vx keeps lives in cache.db, and the config bundle always
|
|
171
|
+
// in `.vx/cache/compiled`, so any directory here is the user's.
|
|
174
172
|
])
|
|
175
173
|
|
|
176
174
|
/**
|
|
@@ -357,7 +355,13 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
|
|
|
357
355
|
// v49: runs.cpu_share — the share of a core each runnable thread got
|
|
358
356
|
// where the task ran, so a crowded run's CPU reads as need. The
|
|
359
357
|
// cache KEY is unchanged.
|
|
360
|
-
|
|
358
|
+
// v50: steps, warnings, failures and lockfile_memos — what a run said
|
|
359
|
+
// and a lockfile claimant parsed, files under the cache dir until
|
|
360
|
+
// now (if it can be in the DB, it is). The cache KEY is unchanged.
|
|
361
|
+
// v51: runs.queued_ms, output_bytes, blocks, flaky_passes and
|
|
362
|
+
// flaky_failures — the rest of what a results row shows, so `vx
|
|
363
|
+
// last --sql` reads it. The cache KEY is unchanged.
|
|
364
|
+
export const SCHEMA_VERSION = 'v51'
|
|
361
365
|
|
|
362
366
|
/**
|
|
363
367
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -944,11 +948,22 @@ export class Cache implements CacheLayer {
|
|
|
944
948
|
private adoptStats = 0
|
|
945
949
|
/** The artifact directory's names, listed once past `ADOPT_STATS`; this process's writes kept in it. */
|
|
946
950
|
private artifactNames: Set<string> | undefined
|
|
951
|
+
private heldBlobSizes: Array<[string, number]> | undefined
|
|
952
|
+
private heldTimer: ReturnType<typeof setTimeout> | undefined
|
|
953
|
+
/** Saves' index writes waiting for this turn's one transaction (`indexInBatch`). */
|
|
954
|
+
private indexQueue: Array<{ write: () => void; settle: (err?: unknown) => void }> = []
|
|
955
|
+
private heldRest: () => Array<[string, number]> = () => []
|
|
947
956
|
private readonly insertEntryInput: ReturnType<Database['prepare']>
|
|
948
957
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
949
958
|
private readonly files: FileHashStore
|
|
950
959
|
private readonly outputs: OutputIndex
|
|
951
|
-
|
|
960
|
+
/**
|
|
961
|
+
* Each entry `save()` wrote: its files' bytes, summed as the rows went in,
|
|
962
|
+
* for the miss row's `outputs` (a read back was one statement a miss;
|
|
963
|
+
* principle 9). Taken once, by `outputBytes`.
|
|
964
|
+
*/
|
|
965
|
+
private readonly savedBytes = new Map<string, number>()
|
|
966
|
+
private readonly folds: IndexFoldMemo
|
|
952
967
|
private readonly history: RunHistory
|
|
953
968
|
/** What the remotes said they hold, across runs (remote-hits.ts). */
|
|
954
969
|
readonly remoteHits: RemoteHits
|
|
@@ -1172,7 +1187,7 @@ export class Cache implements CacheLayer {
|
|
|
1172
1187
|
// keeps it so).
|
|
1173
1188
|
if (found !== undefined) {
|
|
1174
1189
|
this.db.exec(
|
|
1175
|
-
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits;',
|
|
1190
|
+
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits; DROP TABLE IF EXISTS main.steps; DROP TABLE IF EXISTS main.warnings; DROP TABLE IF EXISTS main.failures; DROP TABLE IF EXISTS main.lockfile_memos;',
|
|
1176
1191
|
)
|
|
1177
1192
|
}
|
|
1178
1193
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1237,12 +1252,13 @@ export class Cache implements CacheLayer {
|
|
|
1237
1252
|
}
|
|
1238
1253
|
}
|
|
1239
1254
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1255
|
+
// An index that held its entries itself (no store yet: a repository
|
|
1256
|
+
// before its first commit) hands them to the store below.
|
|
1257
|
+
const holds =
|
|
1258
|
+
storeDir !== undefined &&
|
|
1259
|
+
mode === 'open' &&
|
|
1260
|
+
this.writeBlocked === null &&
|
|
1261
|
+
readable(
|
|
1246
1262
|
() =>
|
|
1247
1263
|
this.db
|
|
1248
1264
|
.prepare(
|
|
@@ -1250,16 +1266,6 @@ export class Cache implements CacheLayer {
|
|
|
1250
1266
|
)
|
|
1251
1267
|
.get() != null,
|
|
1252
1268
|
)
|
|
1253
|
-
if (holds) {
|
|
1254
|
-
this.db
|
|
1255
|
-
.transaction(() => {
|
|
1256
|
-
for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
|
|
1257
|
-
this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
|
|
1258
|
-
}
|
|
1259
|
-
})
|
|
1260
|
-
.immediate()
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
1269
|
this.storeDir = storeDir
|
|
1264
1270
|
this.artifactDir = storeDir ?? cacheDir
|
|
1265
1271
|
if (storeDir !== undefined) {
|
|
@@ -1273,6 +1279,7 @@ export class Cache implements CacheLayer {
|
|
|
1273
1279
|
if (mode === 'open' && this.writeBlocked === null && (foreign || storeDir !== recorded)) {
|
|
1274
1280
|
readable(() => this.matchArtifactLayout(storeDir === undefined ? 'main' : 'store'))
|
|
1275
1281
|
}
|
|
1282
|
+
if (holds) this.carryEntries(cacheDir, storeDir!)
|
|
1276
1283
|
if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
|
|
1277
1284
|
if (storeDir === undefined)
|
|
1278
1285
|
this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
|
|
@@ -1336,9 +1343,9 @@ export class Cache implements CacheLayer {
|
|
|
1336
1343
|
// the schema above is the one place every table is declared.
|
|
1337
1344
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1338
1345
|
this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1339
|
-
this.folds = new
|
|
1346
|
+
this.folds = new IndexFoldMemo(this.db, this.write && !this.inspecting)
|
|
1340
1347
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1341
|
-
this.remoteHits = new
|
|
1348
|
+
this.remoteHits = new IndexRemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1342
1349
|
|
|
1343
1350
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
1344
1351
|
// after an upgrade misses everything. Roadmap 3.3: that is announced,
|
|
@@ -1398,6 +1405,68 @@ export class Cache implements CacheLayer {
|
|
|
1398
1405
|
}
|
|
1399
1406
|
}
|
|
1400
1407
|
|
|
1408
|
+
/**
|
|
1409
|
+
* The entries an index held itself, moved into the store it now opens:
|
|
1410
|
+
* the same keys over the same bytes, so the first run after a
|
|
1411
|
+
* repository's first commit replays what the run before it saved. Then
|
|
1412
|
+
* dropped from the index, where an unqualified name would shadow the
|
|
1413
|
+
* store's. An artifact file that cannot be renamed there (another
|
|
1414
|
+
* device) is left for `prune`, and its entry is a miss.
|
|
1415
|
+
*/
|
|
1416
|
+
private carryEntries(cacheDir: string, storeDir: string): void {
|
|
1417
|
+
const has = (schema: 'main' | 'store', table: string): boolean =>
|
|
1418
|
+
this.db
|
|
1419
|
+
.prepare(`SELECT 1 FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
|
|
1420
|
+
.get(table) != null
|
|
1421
|
+
const layout = (schema: 'main' | 'store'): string | undefined =>
|
|
1422
|
+
has(schema, 'artifacts_meta')
|
|
1423
|
+
? (
|
|
1424
|
+
this.db
|
|
1425
|
+
.prepare(`SELECT value FROM ${schema}.artifacts_meta WHERE key = 'layout'`)
|
|
1426
|
+
.get() as { value: string } | null
|
|
1427
|
+
)?.value
|
|
1428
|
+
: undefined
|
|
1429
|
+
if (storeDir !== cacheDir) {
|
|
1430
|
+
let names: string[] = []
|
|
1431
|
+
try {
|
|
1432
|
+
names = readdirSync(cacheDir)
|
|
1433
|
+
} catch {
|
|
1434
|
+
// No directory: nothing to carry.
|
|
1435
|
+
}
|
|
1436
|
+
for (const name of names) {
|
|
1437
|
+
if (!name.endsWith('.tar.zst') && !name.endsWith('.tree')) continue
|
|
1438
|
+
const to = path.join(storeDir, name)
|
|
1439
|
+
if (existsSync(to)) continue
|
|
1440
|
+
try {
|
|
1441
|
+
renameSync(path.join(cacheDir, name), to)
|
|
1442
|
+
} catch {
|
|
1443
|
+
// Another device: the artifact stays an orphan here.
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
this.db
|
|
1448
|
+
.transaction(() => {
|
|
1449
|
+
// `entries` first: the other tables' rows reference it.
|
|
1450
|
+
for (const t of STORE_TABLES.toReversed()) {
|
|
1451
|
+
if (t !== 'store_meta' && has('main', t) && has('store', t)) {
|
|
1452
|
+
this.db.exec(`INSERT OR IGNORE INTO store.${t} SELECT * FROM main.${t}`)
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
if (
|
|
1456
|
+
has('main', 'artifacts') &&
|
|
1457
|
+
has('store', 'artifacts') &&
|
|
1458
|
+
layout('main') === ARTIFACT_LAYOUT &&
|
|
1459
|
+
layout('store') === ARTIFACT_LAYOUT
|
|
1460
|
+
) {
|
|
1461
|
+
this.db.exec('INSERT OR IGNORE INTO store.artifacts SELECT * FROM main.artifacts')
|
|
1462
|
+
}
|
|
1463
|
+
for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
|
|
1464
|
+
this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
|
|
1465
|
+
}
|
|
1466
|
+
})
|
|
1467
|
+
.immediate()
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1401
1470
|
/**
|
|
1402
1471
|
* Attach the shared store as `store`, with the pragmas the index takes:
|
|
1403
1472
|
* WAL for both, so a workspace's run and another's share the store as
|
|
@@ -1582,7 +1651,32 @@ export class Cache implements CacheLayer {
|
|
|
1582
1651
|
}
|
|
1583
1652
|
/** `BlobSizeMemo`: remember blob sizes, honouring the local WRITE axis. */
|
|
1584
1653
|
rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
|
|
1585
|
-
this.
|
|
1654
|
+
if (this.heldBlobSizes !== undefined) for (const e of sizes) this.heldBlobSizes.push(e)
|
|
1655
|
+
else this.guard(() => this.files.rememberBlobSizes(sizes))
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Hold the blob sizes remembered from now on until `storeHeldBlobSizes`
|
|
1659
|
+
* or `close`: a run learns them before its first task, where storing
|
|
1660
|
+
* 40,030 cost a cold 9,603-task run 217 ms (2026-11-01).
|
|
1661
|
+
*/
|
|
1662
|
+
holdBlobSizes(): void {
|
|
1663
|
+
this.heldBlobSizes ??= []
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Store the held blob sizes `HELD_SLICE` per event-loop turn from the
|
|
1667
|
+
* next turn on, while the run's tasks run; `close` stores the rest.
|
|
1668
|
+
*/
|
|
1669
|
+
storeHeldBlobSizes(): void {
|
|
1670
|
+
const held = this.heldBlobSizes
|
|
1671
|
+
this.heldBlobSizes = undefined
|
|
1672
|
+
if (held === undefined || held.length === 0) return
|
|
1673
|
+
let at = 0
|
|
1674
|
+
const step = (): void => {
|
|
1675
|
+
this.guard(() => this.files.rememberBlobSizes(new Map(held.slice(at, (at += HELD_SLICE)))))
|
|
1676
|
+
this.heldTimer = at < held.length ? setTimeout(step, 0) : undefined
|
|
1677
|
+
}
|
|
1678
|
+
this.heldRest = () => held.slice(at)
|
|
1679
|
+
this.heldTimer = setTimeout(step, 0)
|
|
1586
1680
|
}
|
|
1587
1681
|
/** `BlobSizeMemo`: the paths an index with this digest distrusts, when known. */
|
|
1588
1682
|
blobVerdict(digest: string): string[] | undefined {
|
|
@@ -1757,7 +1851,7 @@ export class Cache implements CacheLayer {
|
|
|
1757
1851
|
}
|
|
1758
1852
|
|
|
1759
1853
|
/** An adoption's verdict: an artifact that fails the checks is a miss. */
|
|
1760
|
-
private async adopted(indexing: Promise<
|
|
1854
|
+
private async adopted(indexing: Promise<unknown>): Promise<boolean> {
|
|
1761
1855
|
try {
|
|
1762
1856
|
await indexing
|
|
1763
1857
|
return true
|
|
@@ -1955,6 +2049,11 @@ export class Cache implements CacheLayer {
|
|
|
1955
2049
|
outputsPath(hash: string): string {
|
|
1956
2050
|
return this.tarPath(hash)
|
|
1957
2051
|
}
|
|
2052
|
+
outputBytes(hash: string): number | undefined {
|
|
2053
|
+
const bytes = this.savedBytes.get(hash)
|
|
2054
|
+
this.savedBytes.delete(hash)
|
|
2055
|
+
return bytes
|
|
2056
|
+
}
|
|
1958
2057
|
artifactSize(hash: string): number | undefined {
|
|
1959
2058
|
return this.guard(
|
|
1960
2059
|
() =>
|
|
@@ -2048,7 +2147,15 @@ export class Cache implements CacheLayer {
|
|
|
2048
2147
|
* uploaded under a fresh sum.
|
|
2049
2148
|
*/
|
|
2050
2149
|
private treePlan(hash: string, tree: string, rows: readonly OutputFileRow[]): ArtifactPlan {
|
|
2051
|
-
|
|
2150
|
+
let plan: ArtifactPlan
|
|
2151
|
+
try {
|
|
2152
|
+
plan = planOfTree(tree, this.stdoutText(hash))
|
|
2153
|
+
} catch (err) {
|
|
2154
|
+
// A sidecar cut short at rest, as a file is: named as the damage it is.
|
|
2155
|
+
if (err instanceof SyntaxError)
|
|
2156
|
+
throw new CorruptArtifactError(hash, 'its sidecar does not parse')
|
|
2157
|
+
throw err
|
|
2158
|
+
}
|
|
2052
2159
|
const size = new Map(rows.map((r) => [treeName(r.path), r.size]))
|
|
2053
2160
|
for (const i of plan.inputs) {
|
|
2054
2161
|
const want = size.get(i.name)
|
|
@@ -2490,7 +2597,16 @@ export class Cache implements CacheLayer {
|
|
|
2490
2597
|
if (!this.write) return
|
|
2491
2598
|
// A cache that reads saves only on a miss. One that does not (`--force`)
|
|
2492
2599
|
// re-executes a key it may hold: that entry is kept as it is.
|
|
2493
|
-
|
|
2600
|
+
// The kept entry's bytes are this run's too (same key): the row's
|
|
2601
|
+
// `outputs` counts them as a hit would.
|
|
2602
|
+
if (!this.read && this.guard(() => this.storedHere(args.hash))) {
|
|
2603
|
+
const rows = this.loadOutputFilesBatch([args.hash]).get(args.hash) ?? []
|
|
2604
|
+
this.savedBytes.set(
|
|
2605
|
+
args.hash,
|
|
2606
|
+
rows.reduce((n, r) => n + r.size, 0),
|
|
2607
|
+
)
|
|
2608
|
+
return
|
|
2609
|
+
}
|
|
2494
2610
|
// The cache directory is the constructor's (`mkdirSync`, with a
|
|
2495
2611
|
// refusal recorded for `assertWritable`); re-creating it here and
|
|
2496
2612
|
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
@@ -2507,7 +2623,7 @@ export class Cache implements CacheLayer {
|
|
|
2507
2623
|
throw err
|
|
2508
2624
|
})
|
|
2509
2625
|
endPack()
|
|
2510
|
-
await this.guard(() =>
|
|
2626
|
+
const bytes = await this.guard(() =>
|
|
2511
2627
|
this.writeArtifactAndIndex(
|
|
2512
2628
|
args.hash,
|
|
2513
2629
|
'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
|
|
@@ -2520,6 +2636,7 @@ export class Cache implements CacheLayer {
|
|
|
2520
2636
|
'tar' in packed ? packed.tar : undefined,
|
|
2521
2637
|
),
|
|
2522
2638
|
)
|
|
2639
|
+
this.savedBytes.set(args.hash, bytes)
|
|
2523
2640
|
}
|
|
2524
2641
|
|
|
2525
2642
|
/**
|
|
@@ -2829,7 +2946,7 @@ export class Cache implements CacheLayer {
|
|
|
2829
2946
|
* scanned as is rather than decoded back out of the bytes just encoded.
|
|
2830
2947
|
*/
|
|
2831
2948
|
tar?: Uint8Array,
|
|
2832
|
-
): Promise<
|
|
2949
|
+
): Promise<number> {
|
|
2833
2950
|
// Validate BEFORE anything touches the final path. `ingest()` feeds
|
|
2834
2951
|
// us a temp of network bytes; a truncated/garbage body that went live first
|
|
2835
2952
|
// would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
|
|
@@ -3051,6 +3168,7 @@ export class Cache implements CacheLayer {
|
|
|
3051
3168
|
const upsertStdout = this.upsertStdout
|
|
3052
3169
|
const deleteStdout = this.deleteStdout
|
|
3053
3170
|
const outputs = this.outputs
|
|
3171
|
+
let outputBytes = 0
|
|
3054
3172
|
const insertEntryInput = this.insertEntryInput
|
|
3055
3173
|
const inputComponents = meta.inputComponents
|
|
3056
3174
|
// The artifact goes live INSIDE the write transaction, taken
|
|
@@ -3142,7 +3260,7 @@ export class Cache implements CacheLayer {
|
|
|
3142
3260
|
hash,
|
|
3143
3261
|
stdoutText.startsWith('\uFEFF') ? utf8.encode(stdoutText) : stdoutText,
|
|
3144
3262
|
)
|
|
3145
|
-
outputs.replaceFileRows(hash, outputFileRows)
|
|
3263
|
+
outputBytes = outputs.replaceFileRows(hash, outputFileRows)
|
|
3146
3264
|
// INSERT OR IGNORE: identical inputs derive this same hash, so a
|
|
3147
3265
|
// re-save's row is identical — keep the first, skip the rest.
|
|
3148
3266
|
if (inputComponents !== undefined && inputComponents.length > 0) {
|
|
@@ -3154,7 +3272,7 @@ export class Cache implements CacheLayer {
|
|
|
3154
3272
|
// Built inside the try: on a database a stopping run closed while a
|
|
3155
3273
|
// download streamed in, building it throws, and the temp or tree
|
|
3156
3274
|
// the body went to stayed behind.
|
|
3157
|
-
this.
|
|
3275
|
+
await this.indexInBatch(index)
|
|
3158
3276
|
} catch (err) {
|
|
3159
3277
|
endTx()
|
|
3160
3278
|
const left = renamed ?? tree ?? tmpPath
|
|
@@ -3164,6 +3282,57 @@ export class Cache implements CacheLayer {
|
|
|
3164
3282
|
}
|
|
3165
3283
|
endTx()
|
|
3166
3284
|
for (const a of aside) await rm(a, { recursive: true, force: true })
|
|
3285
|
+
return outputBytes
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
/**
|
|
3289
|
+
* Run `write` in a write transaction shared with every other save's that
|
|
3290
|
+
* arrives in this turn: settles once it has committed, or rejects with
|
|
3291
|
+
* its own error (its savepoint rolled back) or the commit's. A cold run's
|
|
3292
|
+
* saves land a few per turn, and a transaction each spent half its index
|
|
3293
|
+
* time on BEGIN and COMMIT on the main thread, the run's busiest
|
|
3294
|
+
* (2026-11-03: 81 of 164 ms over 500 saves).
|
|
3295
|
+
*/
|
|
3296
|
+
private indexInBatch(write: () => void): Promise<void> {
|
|
3297
|
+
return new Promise((resolve, reject) => {
|
|
3298
|
+
this.indexQueue.push({
|
|
3299
|
+
write,
|
|
3300
|
+
settle: (err) => (err === undefined ? resolve() : reject(err)),
|
|
3301
|
+
})
|
|
3302
|
+
if (this.indexQueue.length === 1) setImmediate(() => this.flushIndex())
|
|
3303
|
+
})
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
private flushIndex(): void {
|
|
3307
|
+
const batch = this.indexQueue
|
|
3308
|
+
if (batch.length === 0) return
|
|
3309
|
+
this.indexQueue = []
|
|
3310
|
+
const failed = new Map<number, unknown>()
|
|
3311
|
+
try {
|
|
3312
|
+
this.db
|
|
3313
|
+
.transaction(() => {
|
|
3314
|
+
for (let i = 0; i < batch.length; i++) {
|
|
3315
|
+
// An error SQLite answers by rolling the whole transaction
|
|
3316
|
+
// back (a full disk, a busy lock) leaves none to go on in: the
|
|
3317
|
+
// rest are not run, and fail with it.
|
|
3318
|
+
if (failed.size > 0 && !this.db.inTransaction) {
|
|
3319
|
+
failed.set(i, failed.values().next().value)
|
|
3320
|
+
continue
|
|
3321
|
+
}
|
|
3322
|
+
try {
|
|
3323
|
+
// Nested: a savepoint, so one write's failure undoes only its own rows.
|
|
3324
|
+
this.db.transaction(batch[i]!.write)()
|
|
3325
|
+
} catch (err) {
|
|
3326
|
+
failed.set(i, err)
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
})
|
|
3330
|
+
.immediate()
|
|
3331
|
+
} catch (err) {
|
|
3332
|
+
for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i) ?? err)
|
|
3333
|
+
return
|
|
3334
|
+
}
|
|
3335
|
+
for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i))
|
|
3167
3336
|
}
|
|
3168
3337
|
|
|
3169
3338
|
private tempTreePath(hash: string): string {
|
|
@@ -3210,17 +3379,6 @@ export class Cache implements CacheLayer {
|
|
|
3210
3379
|
this.db.prepare(`UPDATE entries SET accessed_at = ? WHERE hash ${test}`).run(now, ...params)
|
|
3211
3380
|
}
|
|
3212
3381
|
|
|
3213
|
-
/**
|
|
3214
|
-
* Raw `bun:sqlite` Database handle. Exposed for subsystems that
|
|
3215
|
-
* need to issue their own queries (LocalHistoryProvider's CTE,
|
|
3216
|
-
* future analytics consumers) without us proxying every method.
|
|
3217
|
-
* Callers must NOT close the handle directly — `Cache.close()` owns
|
|
3218
|
-
* the lifetime.
|
|
3219
|
-
*/
|
|
3220
|
-
dbHandle(): Database {
|
|
3221
|
-
return this.db
|
|
3222
|
-
}
|
|
3223
|
-
|
|
3224
3382
|
// --- run history: delegated to `RunHistory` (see run-history.ts) ---
|
|
3225
3383
|
// A run over a directory it cannot write records no history:
|
|
3226
3384
|
// `readOnlyNotice` said so once.
|
|
@@ -3528,6 +3686,12 @@ export class Cache implements CacheLayer {
|
|
|
3528
3686
|
|
|
3529
3687
|
close(): void {
|
|
3530
3688
|
try {
|
|
3689
|
+
this.flushIndex()
|
|
3690
|
+
clearTimeout(this.heldTimer)
|
|
3691
|
+
const held = this.heldBlobSizes ?? (this.heldTimer !== undefined ? this.heldRest() : [])
|
|
3692
|
+
this.heldBlobSizes = undefined
|
|
3693
|
+
this.heldTimer = undefined
|
|
3694
|
+
if (held.length > 0) this.files.rememberBlobSizes(new Map(held))
|
|
3531
3695
|
this.flushAccessed()
|
|
3532
3696
|
this.outputs.flushOutputDirs()
|
|
3533
3697
|
this.folds.flush()
|
|
@@ -3564,7 +3728,17 @@ export class Cache implements CacheLayer {
|
|
|
3564
3728
|
}
|
|
3565
3729
|
|
|
3566
3730
|
/** SQLite's file-control opcode (sqlite3.h). */
|
|
3567
|
-
const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
3731
|
+
export const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
3732
|
+
|
|
3733
|
+
/**
|
|
3734
|
+
* The cache's `bun:sqlite` handle, for core's own readers (`vx last`, `vx
|
|
3735
|
+
* why`, the history provider). A function, not a member: the façade hands
|
|
3736
|
+
* `Cache` to plugins and no plugin touches cache.db (2026-10-29). Callers
|
|
3737
|
+
* never close it — `Cache.close()` owns the lifetime.
|
|
3738
|
+
*/
|
|
3739
|
+
export function indexDb(cache: Cache): Database {
|
|
3740
|
+
return cache['db']
|
|
3741
|
+
}
|
|
3568
3742
|
|
|
3569
3743
|
/**
|
|
3570
3744
|
* Close for real. A plain `close()` leaves the connection open while any
|
|
@@ -3577,7 +3751,7 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
|
3577
3751
|
* `vx run` exited 1 with that stack after its tasks had passed (M-44). There
|
|
3578
3752
|
* the deferred close is the one that closes.
|
|
3579
3753
|
*/
|
|
3580
|
-
function closeDb(db: Database): void {
|
|
3754
|
+
export function closeDb(db: Database): void {
|
|
3581
3755
|
try {
|
|
3582
3756
|
db.close(true)
|
|
3583
3757
|
} catch {
|
package/src/cache/file-hashes.ts
CHANGED
|
@@ -72,6 +72,9 @@ export class FileHashStore {
|
|
|
72
72
|
/** Each of `oids` whose size a `rememberBlobSizes` stored: one `IN` query per 900. */
|
|
73
73
|
knownBlobSizes(oids: readonly string[]): Map<string, number> {
|
|
74
74
|
const out = new Map<string, number>()
|
|
75
|
+
// An empty memo (a cold cache) answers nothing: 40,030 lookups there
|
|
76
|
+
// cost a 9,603-task run 57 ms before its first task (2026-11-01).
|
|
77
|
+
if (this.db.query('SELECT 1 FROM blob_sizes LIMIT 1').get() === null) return out
|
|
75
78
|
for (let i = 0; i < oids.length; i += 900) {
|
|
76
79
|
const chunk = oids.slice(i, i + 900)
|
|
77
80
|
const rows = this.db
|