@vzn/vx 0.0.632 → 0.0.633

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.
@@ -50,7 +50,7 @@ import {
50
50
  } from 'node:fs'
51
51
  import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
52
52
  import path from 'node:path'
53
- import { createTables, inHashes, lazyStatement } from './schema.js'
53
+ import { createArtifactTables, createTables, inHashes, lazyStatement } from './schema.js'
54
54
  import {
55
55
  UserError,
56
56
  formatBytes,
@@ -250,11 +250,61 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
250
250
  // output_files' ino + ctime_ms moved to the workspace's own
251
251
  // output_stamps, output_dirs lost its foreign key, and the value
252
252
  // salt moved to store_meta. The cache KEY is unchanged.
253
- export const SCHEMA_VERSION = 'v32'
253
+ // v33: an artifact of at most INLINE_MAX compressed bytes lives in the
254
+ // store's `artifacts` table, written in its rows' transaction, and
255
+ // every entry read joins it. The artifact bytes and the cache KEY are
256
+ // unchanged (CACHE_VERSION not bumped).
257
+ // v34: entry_inputs holds one row per entry, its key components as one
258
+ // JSON array, not a row per component. The cache KEY is unchanged.
259
+ export const SCHEMA_VERSION = 'v34'
260
+
261
+ /**
262
+ * An entry's key components as its one `entry_inputs` row (v34): JSON
263
+ * `[kind, name, hash]` triples in (kind, name) code-unit order, the first
264
+ * of a repeated (kind, name) kept, as the per-component primary key did.
265
+ */
266
+ function encodeEntryInputs(
267
+ components: readonly Pick<TaskInputRow, 'kind' | 'name' | 'hash'>[],
268
+ ): string {
269
+ const byId = new Map<string, Pick<TaskInputRow, 'kind' | 'name' | 'hash'>>()
270
+ for (const c of components) {
271
+ const id = `${c.kind}\0${c.name}`
272
+ if (!byId.has(id)) byId.set(id, c)
273
+ }
274
+ const ids = [...byId.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
275
+ return JSON.stringify(
276
+ ids.map((id) => {
277
+ const c = byId.get(id)!
278
+ return [c.kind, c.name, c.hash]
279
+ }),
280
+ )
281
+ }
254
282
 
255
283
  /** The tables a store holds: dropped from a workspace index that held them itself. */
256
284
  const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_meta', 'entries']
257
285
 
286
+ /**
287
+ * The inline artifacts' tables (schema.ts): dropped from a workspace index
288
+ * whose entries moved to a store, where an unqualified name would find them
289
+ * first, and never by a schema reset.
290
+ */
291
+ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
292
+
293
+ /**
294
+ * The `artifacts` table's own layout (`artifacts_meta.layout`): another value
295
+ * drops the table. It is read only where the schema was not this vx's (a
296
+ * reset, a new index or store, a store moved), so a change to it bumps
297
+ * `SCHEMA_VERSION` too: a warm open reads no statement for it.
298
+ */
299
+ const ARTIFACT_LAYOUT = 'a1'
300
+
301
+ /**
302
+ * The largest compressed artifact kept in the index rather than in a file
303
+ * of its own: it is written in its rows' transaction, with no temp and no
304
+ * rename (docs/design/cache-save-cpu-2026-10.md).
305
+ */
306
+ const INLINE_MAX = 32 * 1024
307
+
258
308
  /**
259
309
  * An index that records a schema other than this vx's: the one an opener
260
310
  * that writes resets. Reads the recorded version alone and touches
@@ -279,13 +329,26 @@ function indexOfAnotherSchema(dbFile: string): boolean {
279
329
  return found !== undefined && found !== SCHEMA_VERSION
280
330
  }
281
331
 
282
- /** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
283
- const SELECT_ENTRY =
284
- "SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
332
+ /**
333
+ * Each asked key's entry row with its stdout, which lives apart (v29), none
334
+ * stored reading as '', and its inline artifact's last use, null when the
335
+ * artifact is a file. Driven by the keys, so a key with an inline artifact
336
+ * and no row (a reset kept it) answers too, `hash` null: the miss learns
337
+ * there is something to adopt in the statement that found no entry.
338
+ */
339
+ const selectEntries = (keys: string): string =>
340
+ `SELECT q.value AS key, e.*, COALESCE(s.stdout, '') AS stdout, a.at AS inline_at FROM ${keys} q LEFT JOIN entries e ON e.hash = q.value LEFT JOIN entry_stdout s ON s.hash = e.hash LEFT JOIN artifacts a ON a.hash = q.value WHERE e.hash IS NOT NULL OR a.at IS NOT NULL`
341
+ const SELECT_ENTRY = selectEntries('(SELECT ? AS value)')
342
+ const SELECT_ENTRIES = selectEntries('json_each(?)')
285
343
 
286
- /** A file the orphan sweep may judge: a row-less artifact, or a temp. */
344
+ /**
345
+ * What the orphan sweep may judge: a row-less artifact file or inline
346
+ * artifact (`blob`, its hash), or a dead file — a temp, or a file an
347
+ * inline artifact of its key shadows — which goes whatever the policy.
348
+ */
287
349
  interface RowlessFile {
288
- file: string
350
+ file?: string
351
+ blob?: string
289
352
  size: number
290
353
  mtimeMs: number
291
354
  temp: boolean
@@ -349,7 +412,7 @@ function pickVictims(
349
412
  */
350
413
  async function scanOrphanFiles(
351
414
  cacheDir: string,
352
- indexedHashes: () => ReadonlySet<string>,
415
+ indexedHashes: () => { files: ReadonlySet<string>; inline: ReadonlySet<string> },
353
416
  ): Promise<RowlessFile[]> {
354
417
  let names: string[]
355
418
  try {
@@ -363,8 +426,10 @@ async function scanOrphanFiles(
363
426
  for (const name of names) {
364
427
  const m = VX_ARTIFACT_NAME.exec(name)
365
428
  if (m === null) continue
366
- const temp = m[2] !== undefined
367
- if (temp || !indexed.has(m[1]!)) candidates.push({ name, temp })
429
+ // A file whose key has an inline artifact is never read: readers take
430
+ // the blob first. It is what an inline save of a re-built key left.
431
+ const temp = m[2] !== undefined || indexed.inline.has(m[1]!)
432
+ if (temp || !indexed.files.has(m[1]!)) candidates.push({ name, temp })
368
433
  }
369
434
  const found: RowlessFile[] = []
370
435
  await Promise.all(
@@ -415,8 +480,12 @@ export * from './layer.js'
415
480
  export * from './policy.js'
416
481
  export { zstdContentSize } from './zstd.js'
417
482
 
483
+ type LiveRow = EntryRow & { hash: string }
484
+
418
485
  interface EntryRow {
419
- hash: string
486
+ key: string
487
+ /** Null for a row-less inline artifact. */
488
+ hash: string | null
420
489
  project: string
421
490
  task: string
422
491
  command: string
@@ -428,9 +497,10 @@ interface EntryRow {
428
497
  accessed_at: number
429
498
  cpu_ms: number | null
430
499
  peak_rss_bytes: number | null
500
+ inline_at: number | null
431
501
  }
432
502
 
433
- function entryOf(row: EntryRow, fileRows: OutputFileRow[]): CacheEntry {
503
+ function entryOf(row: LiveRow, fileRows: OutputFileRow[]): CacheEntry {
434
504
  return {
435
505
  hash: row.hash,
436
506
  taskId: `${row.project}#${row.task}`,
@@ -527,6 +597,9 @@ function ensurePrivateDir(dir: string): string | null {
527
597
  if (!st.isDirectory()) return `${dir} is not a directory`
528
598
  const uid = process.getuid?.()
529
599
  if (uid !== undefined && st.uid !== uid) return `${dir} belongs to another user`
600
+ // One others could write may already hold what they planted (a link, an
601
+ // entry of their own), and closing it now would keep those: not used.
602
+ if ((st.mode & 0o022) !== 0) return `${dir} is writable by other users`
530
603
  // The user's own directory open to others (a `~/.vx` made at the
531
604
  // default umask, 0755) is vx's to close: refusing it left the store
532
605
  // unused on a stock macOS home (owner, 2026-10-06: "you should own it").
@@ -642,9 +715,21 @@ export class Cache implements CacheLayer {
642
715
  private readonly upsertStdout: ReturnType<Database['prepare']>
643
716
  private readonly deleteStdout: ReturnType<Database['prepare']>
644
717
  private readonly entryExists: ReturnType<Database['prepare']>
718
+ private readonly selectBlob: ReturnType<Database['prepare']>
719
+ private readonly upsertBlob: ReturnType<Database['prepare']>
720
+ private readonly deleteBlob: ReturnType<Database['prepare']>
645
721
  private readonly touched = new Set<string>()
646
722
  /** Hit artifacts whose file time is past the grace: renewed at the flush (`ORPHAN_GRACE_MS`). */
647
723
  private readonly staleTimes = new Set<string>()
724
+ /** The same for inline artifacts, whose `at` stands for the file time. */
725
+ private readonly staleInline = new Set<string>()
726
+ /**
727
+ * The `at` of each inline artifact the last probe held rows for: a restore
728
+ * serves those rows only while the blob still carries it.
729
+ */
730
+ private readonly heldInlineAt = new Map<string, number>()
731
+ /** Keys the last lookups found an inline artifact for and no row: `adopt`'s to read. */
732
+ private readonly rowless = new Set<string>()
648
733
  private readonly insertEntryInput: ReturnType<Database['prepare']>
649
734
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
650
735
  private readonly files: FileHashStore
@@ -693,6 +778,8 @@ export class Cache implements CacheLayer {
693
778
  * indexed again as they hit. Never printed: the cache is vx's to keep.
694
779
  */
695
780
  readonly storeReset: SchemaReset | null = null
781
+ /** This open stamped the store's schema: it was new or another vx's. */
782
+ private storeStamped = false
696
783
 
697
784
  /** A reading verb's open: it never resets the index (the constructor's `mode`). */
698
785
  static inspect(cacheDir: string): Cache {
@@ -872,7 +959,8 @@ export class Cache implements CacheLayer {
872
959
  .prepare("SELECT 1 FROM schema_meta WHERE key = 'store_dir'")
873
960
  .get()
874
961
  if (storeRoot === undefined ? recordedStore != null : storeRoot !== null) {
875
- for (const t of STORE_TABLES) this.db.exec(`DROP TABLE main.${t}`)
962
+ for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES])
963
+ this.db.exec(`DROP TABLE main.${t}`)
876
964
  }
877
965
  this.db
878
966
  .prepare(
@@ -925,17 +1013,22 @@ export class Cache implements CacheLayer {
925
1013
  if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
926
1014
  // An index that held its entries itself would shadow the store's:
927
1015
  // SQLite resolves an unqualified name in `main` first. Its history
928
- // and memos stay; its entries' artifacts are orphans `prune` reaps.
1016
+ // and memos stay; its entries' artifact files are orphans `prune`
1017
+ // reaps, and its inline artifacts go with their table.
929
1018
  const holds = readable(
930
1019
  () =>
931
1020
  this.db
932
- .prepare("SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'entries'")
1021
+ .prepare(
1022
+ "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name IN ('entries', 'artifacts')",
1023
+ )
933
1024
  .get() != null,
934
1025
  )
935
1026
  if (holds) {
936
1027
  this.db
937
1028
  .transaction(() => {
938
- for (const t of STORE_TABLES) this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
1029
+ for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
1030
+ this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
1031
+ }
939
1032
  })
940
1033
  .immediate()
941
1034
  }
@@ -947,6 +1040,11 @@ export class Cache implements CacheLayer {
947
1040
  this.storeReset = this.matchStoreSchema(mode === 'open' && this.writeBlocked === null)
948
1041
  } else {
949
1042
  createTables(this.db, 'main')
1043
+ if (absent && existsSync(dbFile)) this.keepArtifacts(dbFile)
1044
+ }
1045
+ const foreign = storeDir === undefined ? current !== SCHEMA_VERSION : this.storeStamped
1046
+ if (mode === 'open' && this.writeBlocked === null && (foreign || storeDir !== recorded)) {
1047
+ readable(() => this.matchArtifactLayout(storeDir === undefined ? 'main' : 'store'))
950
1048
  }
951
1049
  if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
952
1050
  if (storeDir === undefined)
@@ -982,20 +1080,29 @@ export class Cache implements CacheLayer {
982
1080
  'INSERT INTO entry_stdout(hash, stdout) VALUES (?, CAST(? AS TEXT)) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
983
1081
  )
984
1082
  this.deleteStdout = lazyStatement(this.db, 'DELETE FROM entry_stdout WHERE hash = ?')
985
- this.selectEntry = lazyStatement(this.db, `${SELECT_ENTRY} WHERE e.hash = ?`)
986
- // `has` asks only whether the row is there, with no stdout joined. A
987
- // column off the index, so the table row is still read and a corrupt
988
- // table refuses here as it does on `get` (`SELECT 1` answers from the
989
- // index alone).
990
- this.entryExists = lazyStatement(this.db, 'SELECT exit_code FROM entries WHERE hash = ?')
1083
+ this.selectEntry = lazyStatement(this.db, SELECT_ENTRY)
1084
+ // `has` asks only whether the row is there and where its artifact is,
1085
+ // with no stdout joined. A column off the index, so the table row is
1086
+ // still read and a corrupt table refuses here as it does on `get`
1087
+ // (`SELECT 1` answers from the index alone).
1088
+ this.entryExists = lazyStatement(
1089
+ this.db,
1090
+ 'SELECT e.exit_code, a.at AS inline_at FROM entries e LEFT JOIN artifacts a ON a.hash = e.hash WHERE e.hash = ?',
1091
+ )
1092
+ this.selectBlob = lazyStatement(this.db, 'SELECT at, bytes FROM artifacts WHERE hash = ?')
1093
+ this.upsertBlob = lazyStatement(
1094
+ this.db,
1095
+ 'INSERT INTO artifacts(hash, at, bytes) VALUES (?, ?, ?) ON CONFLICT(hash) DO UPDATE SET at = excluded.at, bytes = excluded.bytes',
1096
+ )
1097
+ this.deleteBlob = lazyStatement(this.db, 'DELETE FROM artifacts WHERE hash = ?')
991
1098
  // INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
992
1099
  // overlapping concurrent saves) leaves the existing rows untouched —
993
1100
  // identical inputs derive the identical hash, so the rows are too.
994
1101
  this.insertEntryInput = lazyStatement(
995
1102
  this.db,
996
1103
  `
997
- INSERT OR IGNORE INTO entry_inputs(entry_hash, kind, name, hash)
998
- VALUES (?, ?, ?, ?)
1104
+ INSERT OR IGNORE INTO entry_inputs(entry_hash, components)
1105
+ VALUES (?, ?)
999
1106
  `,
1000
1107
  )
1001
1108
  // The slices: each owns its statements over this handle and its table(s);
@@ -1117,11 +1224,17 @@ export class Cache implements CacheLayer {
1117
1224
  return null
1118
1225
  }
1119
1226
  if (!writable) {
1227
+ const file = this.db
1228
+ .prepare("SELECT file FROM pragma_database_list WHERE name = 'store'")
1229
+ .get() as {
1230
+ file: string
1231
+ }
1120
1232
  if (seen.has) {
1121
1233
  this.db.exec('DETACH DATABASE store')
1122
1234
  this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1123
1235
  }
1124
1236
  createTables(this.db, 'store')
1237
+ if (seen.has && file.file !== '') this.keepArtifacts(file.file)
1125
1238
  return null
1126
1239
  }
1127
1240
  // Re-read, drop, create and stamp under one write lock: as separate
@@ -1129,6 +1242,7 @@ export class Cache implements CacheLayer {
1129
1242
  // tables, and this one's `CREATE IF NOT EXISTS` kept them under its stamp.
1130
1243
  return this.db
1131
1244
  .transaction((): SchemaReset | null => {
1245
+ this.storeStamped = true
1132
1246
  const now = read()
1133
1247
  let reset: SchemaReset | null = null
1134
1248
  if (now.has && now.found !== SCHEMA_VERSION) {
@@ -1148,6 +1262,64 @@ export class Cache implements CacheLayer {
1148
1262
  .immediate()
1149
1263
  }
1150
1264
 
1265
+ /**
1266
+ * A reading open over an index or store of another schema reads it as
1267
+ * the reset a writing open makes leaves it: empty, but for the inline
1268
+ * artifacts, which a reset keeps. They are read from the file in place,
1269
+ * through a view that shadows the empty table (a TEMP name is found
1270
+ * first), so a dry prune names the row-less ones the real prune reaps.
1271
+ */
1272
+ private keepArtifacts(file: string): void {
1273
+ this.db.prepare('ATTACH DATABASE ? AS kept').run(file)
1274
+ const layout = this.db
1275
+ .prepare("SELECT 1 FROM kept.sqlite_master WHERE type = 'table' AND name = 'artifacts_meta'")
1276
+ .get()
1277
+ ? (
1278
+ this.db.prepare("SELECT value FROM kept.artifacts_meta WHERE key = 'layout'").get() as {
1279
+ value: string
1280
+ } | null
1281
+ )?.value
1282
+ : undefined
1283
+ if (layout === ARTIFACT_LAYOUT) {
1284
+ this.db.exec('CREATE TEMP VIEW artifacts AS SELECT hash, at, bytes FROM kept.artifacts')
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * `artifacts` holds the layout this vx writes, or is dropped and made
1290
+ * again: its version is its own (`ARTIFACT_LAYOUT`), apart from the
1291
+ * schema's, so a schema reset keeps it. Silent, as a reset is. Asked
1292
+ * only where the schema was not this vx's (`ARTIFACT_LAYOUT`).
1293
+ */
1294
+ private matchArtifactLayout(schema: 'main' | 'store'): void {
1295
+ const read = (): string | undefined =>
1296
+ (
1297
+ this.db
1298
+ .prepare(`SELECT value FROM ${schema}.artifacts_meta WHERE key = 'layout'`)
1299
+ .get() as {
1300
+ value: string
1301
+ } | null
1302
+ )?.value
1303
+ // Read first: a workspace's first open of a current store asks too,
1304
+ // and must not wait on the store's write lock to learn it holds `a1`.
1305
+ if (read() === ARTIFACT_LAYOUT) return
1306
+ this.db
1307
+ .transaction(() => {
1308
+ const found = read()
1309
+ if (found === ARTIFACT_LAYOUT) return
1310
+ if (found !== undefined) {
1311
+ this.db.exec(`DROP TABLE ${schema}.artifacts`)
1312
+ createArtifactTables(this.db, schema)
1313
+ }
1314
+ this.db
1315
+ .prepare(
1316
+ `INSERT INTO ${schema}.artifacts_meta(key, value) VALUES ('layout', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
1317
+ )
1318
+ .run(ARTIFACT_LAYOUT)
1319
+ })
1320
+ .immediate()
1321
+ }
1322
+
1151
1323
  // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
1152
1324
  getConfigEval(key: string): string | null {
1153
1325
  return this.guard(() => this.configEvals.getConfigEval(key))
@@ -1296,9 +1468,22 @@ export class Cache implements CacheLayer {
1296
1468
  * `store.db` deleted) loses nothing a task asks for again. The artifact
1297
1469
  * is checked as an ingest checks a remote's (its recorded key, its names
1298
1470
  * against the declared outputs); one that fails is a miss, and the run
1299
- * that follows saves over it.
1471
+ * that follows saves over it. An inline artifact is read first, as every
1472
+ * reader reads it, and indexed in one transaction with its bytes.
1300
1473
  */
1301
1474
  private async adopt(hash: string, ctx: CacheGetContext): Promise<boolean> {
1475
+ const meta: IngestMeta = {
1476
+ taskId: ctx.taskId,
1477
+ command: ctx.command,
1478
+ durationMs: 0,
1479
+ ...(ctx.outputs !== undefined ? { outputs: ctx.outputs } : {}),
1480
+ }
1481
+ // Only a key whose lookup saw a row-less inline artifact asks for it: a
1482
+ // statement per miss otherwise, on every cold save.
1483
+ const blob = this.rowless.delete(hash)
1484
+ ? (this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
1485
+ : null
1486
+ if (blob !== null) return this.adopted(this.writeArtifactAndIndex(hash, blob.bytes, meta))
1302
1487
  const finalPath = this.tarPath(hash)
1303
1488
  if (!existsSync(finalPath)) return false
1304
1489
  const tmpPath = this.tempPath(hash)
@@ -1315,17 +1500,13 @@ export class Cache implements CacheLayer {
1315
1500
  } catch {
1316
1501
  return false
1317
1502
  }
1503
+ return this.adopted(this.writeArtifactAndIndex(hash, { tmpPath }, meta))
1504
+ }
1505
+
1506
+ /** An adoption's verdict: an artifact that fails the checks is a miss. */
1507
+ private async adopted(indexing: Promise<void>): Promise<boolean> {
1318
1508
  try {
1319
- await this.writeArtifactAndIndex(
1320
- hash,
1321
- { tmpPath },
1322
- {
1323
- taskId: ctx.taskId,
1324
- command: ctx.command,
1325
- durationMs: 0,
1326
- ...(ctx.outputs !== undefined ? { outputs: ctx.outputs } : {}),
1327
- },
1328
- )
1509
+ await indexing
1329
1510
  return true
1330
1511
  } catch (err) {
1331
1512
  if (err instanceof CorruptArtifactError || err instanceof ArchiveSecurityError) return false
@@ -1347,14 +1528,22 @@ export class Cache implements CacheLayer {
1347
1528
  }
1348
1529
 
1349
1530
  private async readEntry(hash: string): Promise<CacheEntry | null> {
1350
- const row = this.selectEntry.get(hash) as EntryRow | undefined
1351
- if (!row) return null
1531
+ const row = this.selectEntry.get(hash) as EntryRow | null
1532
+ if (row === null) return null
1533
+ if (row.hash === null) {
1534
+ this.rowless.add(hash)
1535
+ return null
1536
+ }
1352
1537
 
1353
- // Verify the tar artifact actually exists. The DB and the
1354
- // filesystem can drift if someone manually deletes the cache dir.
1355
- const st = statSync(this.tarPath(hash), { throwIfNoEntry: false })
1356
- if (st === undefined) return null
1357
- if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
1538
+ if (row.inline_at !== null) {
1539
+ if (row.inline_at < Date.now() - ORPHAN_GRACE_MS) this.staleInline.add(hash)
1540
+ } else {
1541
+ // Verify the artifact file actually exists. The DB and the
1542
+ // filesystem can drift if someone manually deletes the cache dir.
1543
+ const st = statSync(this.tarPath(hash), { throwIfNoEntry: false })
1544
+ if (st === undefined) return null
1545
+ if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
1546
+ }
1358
1547
 
1359
1548
  // Deferred: per-hit UPDATEs cost ~60 ms across 2000+ probes on a
1360
1549
  // full-cache run. Hashes are collected and flushed as ONE batched
@@ -1368,7 +1557,7 @@ export class Cache implements CacheLayer {
1368
1557
  // for four up-to-date hits on ~70 MB binaries). restoreOutputs
1369
1558
  // reads the artifact itself, only when extraction actually runs.
1370
1559
  const fileRows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
1371
- const entry = entryOf(row, fileRows)
1560
+ const entry = entryOf(row as LiveRow, fileRows)
1372
1561
  entry.outputDirRows = this.loadOutputDirsBatch([hash]).get(hash) ?? []
1373
1562
  return entry
1374
1563
  }
@@ -1401,12 +1590,24 @@ export class Cache implements CacheLayer {
1401
1590
  private async getManyEntries(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
1402
1591
  const out = new Map<string, CacheEntry>()
1403
1592
  if (!this.read || hashes.length === 0) return out
1404
- const { test, params } = inHashes(hashes)
1405
- const rows = this.db.query(`${SELECT_ENTRY} WHERE e.hash ${test}`).all(...params) as EntryRow[]
1593
+ const found = (
1594
+ hashes.length === 1
1595
+ ? [this.selectEntry.get(hashes[0]!)].filter((r) => r !== null)
1596
+ : this.db.query(SELECT_ENTRIES).all(JSON.stringify(hashes))
1597
+ ) as EntryRow[]
1598
+ const rows: LiveRow[] = []
1599
+ for (const r of found) {
1600
+ if (r.hash === null) this.rowless.add(r.key)
1601
+ else rows.push(r as LiveRow)
1602
+ }
1406
1603
  if (rows.length === 0) return out
1407
1604
  const graceStart = Date.now() - ORPHAN_GRACE_MS
1408
1605
  const refreshStart = Date.now() - ACCESS_REFRESH_MS
1409
1606
  const live = rows.filter((r) => {
1607
+ if (r.inline_at !== null) {
1608
+ if (r.inline_at < graceStart) this.staleInline.add(r.hash)
1609
+ return true
1610
+ }
1410
1611
  const st = statSync(this.tarPath(r.hash), { throwIfNoEntry: false })
1411
1612
  if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
1412
1613
  return st !== undefined
@@ -1414,6 +1615,8 @@ export class Cache implements CacheLayer {
1414
1615
  const liveHashes = live.map((r) => r.hash)
1415
1616
  const fileRows = this.loadOutputFilesBatch(liveHashes)
1416
1617
  this.outputs.hold(fileRows)
1618
+ this.heldInlineAt.clear()
1619
+ for (const r of live) if (r.inline_at !== null) this.heldInlineAt.set(r.hash, r.inline_at)
1417
1620
  const dirRows = this.loadOutputDirsBatch(liveHashes)
1418
1621
  for (const row of live) {
1419
1622
  if (row.accessed_at < refreshStart) this.touched.add(row.hash)
@@ -1424,16 +1627,18 @@ export class Cache implements CacheLayer {
1424
1627
  return out
1425
1628
  }
1426
1629
 
1427
- // Existence probe: SQL row + artifact-on-disk check, no byte reads
1428
- // and no accessed_at bump (the plan path must stay read-only).
1630
+ // Existence probe: SQL row + where its artifact is (an inline one, or a
1631
+ // file on disk), no byte reads and no accessed_at bump (the plan path
1632
+ // must stay read-only).
1429
1633
  async has(hash: string): Promise<'local' | 'remote' | null> {
1430
1634
  return this.guard(() => this.hasEntry(hash))
1431
1635
  }
1432
1636
 
1433
1637
  private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
1434
1638
  if (!this.read) return null
1435
- if (this.entryExists.get(hash) === null) return null
1436
- return existsSync(this.tarPath(hash)) ? 'local' : null
1639
+ const row = this.entryExists.get(hash) as { inline_at: number | null } | null
1640
+ if (row === null) return null
1641
+ return row.inline_at !== null || existsSync(this.tarPath(hash)) ? 'local' : null
1437
1642
  }
1438
1643
 
1439
1644
  // Local cache has no slower layer to warm from — prefetch is a no-op.
@@ -1457,6 +1662,16 @@ export class Cache implements CacheLayer {
1457
1662
  outputsPath(hash: string): string {
1458
1663
  return this.tarPath(hash)
1459
1664
  }
1665
+ artifactSize(hash: string): number | undefined {
1666
+ return this.guard(
1667
+ () =>
1668
+ (
1669
+ this.db.query('SELECT size_bytes FROM entries WHERE hash = ?').get(hash) as {
1670
+ size_bytes: number
1671
+ } | null
1672
+ )?.size_bytes,
1673
+ )
1674
+ }
1460
1675
 
1461
1676
  async recordOutputDirs(
1462
1677
  hash: string,
@@ -1497,6 +1712,7 @@ export class Cache implements CacheLayer {
1497
1712
  private async dropEntry(hash: string): Promise<void> {
1498
1713
  await rm(this.tarPath(hash), { force: true })
1499
1714
  const drop = this.db.transaction(() => {
1715
+ this.deleteBlob.run(hash)
1500
1716
  this.deleteEntryRow.run(hash)
1501
1717
  this.outputs.forget([hash])
1502
1718
  })
@@ -1549,7 +1765,25 @@ export class Cache implements CacheLayer {
1549
1765
  // same truncated expectation against the same truncated tree and agrees
1550
1766
  // forever. Refuse instead of silently under-restoring — checked before
1551
1767
  // anything is renamed into place.
1552
- const rows = this.guard(() => this.outputs.rowsOf(hash))
1768
+ //
1769
+ // An inline artifact and its rows are read in one read transaction, so
1770
+ // one snapshot: a re-save of the key in another process replaces both
1771
+ // in one commit, and two reads could pair its rows with the old bytes.
1772
+ // The probe's held rows (X-162) serve only while the blob carries the
1773
+ // `at` the probe saw: a re-save or a renewal since moves it, and then
1774
+ // the rows are read again in the blob's snapshot.
1775
+ let rows: readonly OutputFileRow[] = []
1776
+ let inline: Uint8Array | undefined
1777
+ this.guard(() =>
1778
+ this.db.transaction(() => {
1779
+ const blob = this.selectBlob.get(hash) as { at: number; bytes: Uint8Array } | null
1780
+ inline = blob?.bytes
1781
+ rows =
1782
+ blob === null || blob.at === this.heldInlineAt.get(hash)
1783
+ ? this.outputs.rowsOf(hash)
1784
+ : (this.outputs.loadOutputFilesBatch([hash]).get(hash) ?? [])
1785
+ })(),
1786
+ )
1553
1787
  const expected = rows
1554
1788
  .filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
1555
1789
  .map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
@@ -1580,7 +1814,7 @@ export class Cache implements CacheLayer {
1580
1814
  // 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
1581
1815
  // four workers overlap their round trips and a blocking one stalls
1582
1816
  // the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
1583
- const tar = await decodedTar(Bun.file(src), hash, this.artifactCeiling)
1817
+ const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
1584
1818
  await extractArtifactStream(tar, projectDir, workspaceRoot, verify)
1585
1819
  } catch (err) {
1586
1820
  // A UserError here is the extractor naming the tree's fault (an
@@ -1781,9 +2015,12 @@ export class Cache implements CacheLayer {
1781
2015
  * opened by path read those mid-upload (a digest of one artifact over
1782
2016
  * the bytes of another). A `Bun.file` over an fd is no answer — its
1783
2017
  * second read starts where the first ended. Throws when the artifact is
1784
- * gone; `release` unlinks the name.
2018
+ * gone; `release` unlinks the name. An inline artifact is read once into
2019
+ * the body, which no re-save can change.
1785
2020
  */
1786
2021
  pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
2022
+ const blob = this.guard(() => this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
2023
+ if (blob !== null) return { body: new Blob([blob.bytes]), release: async () => {} }
1787
2024
  const pinned = this.tempPath(hash)
1788
2025
  linkSync(this.tarPath(hash), pinned)
1789
2026
  return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
@@ -1794,7 +2031,8 @@ export class Cache implements CacheLayer {
1794
2031
  * `Response` and copies a file `Blob` without collecting either, so a
1795
2032
  * pull never holds the artifact. A body that fails mid-stream (a dropped
1796
2033
  * socket) leaves a partial temp behind it, removed here; validation
1797
- * removes its own.
2034
+ * removes its own. A body of at most `INLINE_MAX` bytes — by its size,
2035
+ * or counted as it streams — is read to memory and stored inline.
1798
2036
  */
1799
2037
  async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
1800
2038
  const tmpPath = this.tempPath(hash)
@@ -1811,33 +2049,51 @@ export class Cache implements CacheLayer {
1811
2049
  // Every refusal releases the remote body: a held response pins its
1812
2050
  // connection, and a layer that settles on the body's end never hears.
1813
2051
  let counted: ReadableStream<Uint8Array> | undefined
2052
+ let reader: ChunkReader | undefined
2053
+ let small: Uint8Array | undefined
1814
2054
  try {
2055
+ const length =
2056
+ body instanceof Blob ? body.size : Number(body.headers.get('content-length') ?? 0)
2057
+ if (length > cap) throw past()
1815
2058
  if (body instanceof Blob) {
1816
- if (body.size > cap) throw past()
1817
- await Bun.write(tmpPath, body)
1818
- } else if (Number(body.headers.get('content-length') ?? 0) > cap) {
1819
- throw past()
2059
+ if (length <= INLINE_MAX) small = await body.bytes()
2060
+ else await Bun.write(tmpPath, body)
1820
2061
  } else {
1821
- let n = 0
1822
- counted = body.body?.pipeThrough(
1823
- new TransformStream<Uint8Array, Uint8Array>({
1824
- transform(chunk, controller) {
1825
- n += chunk.byteLength
1826
- if (n > cap) controller.error(past())
1827
- else controller.enqueue(chunk)
1828
- },
1829
- }),
1830
- )
1831
- await Bun.write(tmpPath, new Response(counted ?? null))
2062
+ let source = body.body
2063
+ if (source === null) small = new Uint8Array(0)
2064
+ else if (length <= INLINE_MAX) {
2065
+ // Counted as it streams: a length the server did not send, or
2066
+ // one it sent short, cannot make a large body inline.
2067
+ const r: ChunkReader = source.getReader()
2068
+ reader = r
2069
+ const head = await readUpTo(r, INLINE_MAX)
2070
+ if (head.done) small = head.bytes
2071
+ else source = prepended(head.bytes, r)
2072
+ }
2073
+ if (small === undefined) {
2074
+ let n = 0
2075
+ counted = source?.pipeThrough(
2076
+ new TransformStream<Uint8Array, Uint8Array>({
2077
+ transform(chunk, controller) {
2078
+ n += chunk.byteLength
2079
+ if (n > cap) controller.error(past())
2080
+ else controller.enqueue(chunk)
2081
+ },
2082
+ }),
2083
+ )
2084
+ await Bun.write(tmpPath, new Response(counted ?? null))
2085
+ }
1832
2086
  }
1833
2087
  } catch (err) {
1834
2088
  // A failed write leaves the pipe's end unlocked; cancelling it
1835
2089
  // cancels the source. A body refused by its length is cancelled itself.
1836
- if (!(body instanceof Blob)) void (counted ?? body.body)?.cancel(err).catch(() => undefined)
2090
+ if (!(body instanceof Blob)) {
2091
+ void (counted ?? reader ?? body.body)?.cancel(err).catch(() => undefined)
2092
+ }
1837
2093
  await unlink(tmpPath).catch(() => undefined)
1838
2094
  throw err
1839
2095
  }
1840
- await this.guard(() => this.writeArtifactAndIndex(hash, { tmpPath }, meta))
2096
+ await this.guard(() => this.writeArtifactAndIndex(hash, small ?? { tmpPath }, meta))
1841
2097
  }
1842
2098
 
1843
2099
  /** Archive name → absolute source path for every declared output. */
@@ -1954,7 +2210,8 @@ export class Cache implements CacheLayer {
1954
2210
  }
1955
2211
 
1956
2212
  /**
1957
- * Atomically write `compressed` to `<hash>.tar.zst` and (re)build the
2213
+ * Atomically write `compressed` to `<hash>.tar.zst`, or inline into
2214
+ * `artifacts` when it is at most `INLINE_MAX` bytes, and (re)build the
1958
2215
  * entries + output_files SQL rows from the archive itself. Shared by
1959
2216
  * `save()` (we just packed the bytes) and `ingest()` (it streamed them
1960
2217
  * from the remote layer into a temp) — both index the identical values, because both
@@ -1979,9 +2236,14 @@ export class Cache implements CacheLayer {
1979
2236
  // restore, so the size/mode/mtime fingerprint we store matches
1980
2237
  // what isOutputsCurrent will compare against post-restore.
1981
2238
  const finalPath = this.tarPath(hash)
1982
- let tmpPath: string
1983
- if (compressed instanceof Uint8Array) {
1984
- tmpPath = this.tempPath(hash)
2239
+ // An inline artifact is written in the rows' transaction below: no
2240
+ // temp, no rename.
2241
+ const inline =
2242
+ compressed instanceof Uint8Array && compressed.byteLength <= INLINE_MAX ? compressed : null
2243
+ let tmpPath: string | undefined
2244
+ if (inline === null && compressed instanceof Uint8Array) {
2245
+ const temp = this.tempPath(hash)
2246
+ tmpPath = temp
1985
2247
  // The temp is written BEFORE validation so a large artifact can be
1986
2248
  // scanned from the file as it decodes — a file stream reads in
1987
2249
  // bounded pieces; the bytes in memory would not (see `decodedTar`).
@@ -1993,12 +2255,12 @@ export class Cache implements CacheLayer {
1993
2255
  // and a fresh one is an orphan only an hour on (A-14).
1994
2256
  // Off this thread even when small: a file's creation on the main
1995
2257
  // thread cost more CPU and wall than the round trip (A/B, 2026-10-04).
1996
- await writeFile(tmpPath, compressed).catch(async (err: unknown) => {
1997
- await unlink(tmpPath).catch(() => undefined)
2258
+ await writeFile(temp, compressed).catch(async (err: unknown) => {
2259
+ await unlink(temp).catch(() => undefined)
1998
2260
  throw err
1999
2261
  })
2000
2262
  endWrite()
2001
- } else {
2263
+ } else if (!(compressed instanceof Uint8Array)) {
2002
2264
  // `save` or `ingest` already streamed the artifact into its temp.
2003
2265
  tmpPath = compressed.tmpPath
2004
2266
  }
@@ -2015,7 +2277,7 @@ export class Cache implements CacheLayer {
2015
2277
  const source =
2016
2278
  compressed instanceof Uint8Array && compressed.byteLength <= STREAM_DECODE_FROM
2017
2279
  ? compressed
2018
- : Bun.file(tmpPath)
2280
+ : Bun.file(tmpPath!)
2019
2281
  scanned = await scanArtifact(await decodedTar(source, hash, this.artifactCeiling))
2020
2282
  }
2021
2283
  endScan()
@@ -2034,13 +2296,13 @@ export class Cache implements CacheLayer {
2034
2296
  }
2035
2297
  assertArtifactNames(hash, scanned.entries, meta.outputs)
2036
2298
  } catch (err) {
2037
- await unlink(tmpPath).catch(() => undefined)
2299
+ if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2038
2300
  if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
2039
2301
  throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
2040
2302
  }
2041
2303
  const { entries } = scanned
2042
2304
  const totalBytes =
2043
- compressed instanceof Uint8Array ? compressed.byteLength : Bun.file(tmpPath).size
2305
+ compressed instanceof Uint8Array ? compressed.byteLength : Bun.file(tmpPath!).size
2044
2306
  const outputFileRows: Array<[string, number, number, number]> = []
2045
2307
  // Per-output-file fingerprint rows feed the skip-restore check.
2046
2308
  // Row paths: project entries store the bare rel (`outputs/`
@@ -2102,18 +2364,36 @@ export class Cache implements CacheLayer {
2102
2364
  // renames finds no artifact, a miss (`ArtifactVanishedError`); one
2103
2365
  // holding the old file still reads it whole. The aside name is a temp
2104
2366
  // name, so a crash before its unlink leaves an orphan the sweep takes.
2105
- const aside = this.tempPath(hash)
2106
- let displaced = false
2367
+ //
2368
+ // An inline artifact is upserted in the same transaction, and a file
2369
+ // save deletes the key's inline one: as of every commit a key's live
2370
+ // bytes are in one place. An inline save moves aside whatever file the
2371
+ // key has, as a file save does: one rename that finds nothing on a
2372
+ // first save, where asking the index first was a statement per save.
2373
+ let aside: string | undefined
2107
2374
  let renamed = false
2108
- const tx = this.db.transaction(() => {
2375
+ const upsertBlob = this.upsertBlob
2376
+ const deleteBlob = this.deleteBlob
2377
+ const moveAside = (): string | undefined => {
2378
+ const to = this.tempPath(hash)
2109
2379
  try {
2110
- renameSync(finalPath, aside)
2111
- displaced = true
2380
+ renameSync(finalPath, to)
2381
+ return to
2112
2382
  } catch (err) {
2113
2383
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
2384
+ return undefined
2385
+ }
2386
+ }
2387
+ const tx = this.db.transaction(() => {
2388
+ if (inline !== null) {
2389
+ aside = moveAside()
2390
+ upsertBlob.run(hash, now, inline)
2391
+ } else {
2392
+ aside = moveAside()
2393
+ renameSync(tmpPath!, finalPath)
2394
+ renamed = true
2395
+ deleteBlob.run(hash)
2114
2396
  }
2115
- renameSync(tmpPath, finalPath)
2116
- renamed = true
2117
2397
  insertEntry.run(
2118
2398
  hash,
2119
2399
  project,
@@ -2144,11 +2424,9 @@ export class Cache implements CacheLayer {
2144
2424
  )
2145
2425
  outputs.replaceFileRows(hash, outputFileRows)
2146
2426
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
2147
- // re-save's rows are identical — keep the first set, skip the rest.
2148
- if (inputComponents !== undefined) {
2149
- for (const c of inputComponents) {
2150
- insertEntryInput.run(hash, c.kind, c.name, c.hash)
2151
- }
2427
+ // re-save's row is identical — keep the first, skip the rest.
2428
+ if (inputComponents !== undefined && inputComponents.length > 0) {
2429
+ insertEntryInput.run(hash, encodeEntryInputs(inputComponents))
2152
2430
  }
2153
2431
  })
2154
2432
  const endTx = span('save: index tx')
@@ -2156,18 +2434,21 @@ export class Cache implements CacheLayer {
2156
2434
  tx.immediate()
2157
2435
  } catch (err) {
2158
2436
  endTx()
2159
- await unlink(renamed ? finalPath : tmpPath).catch(() => undefined)
2160
- if (displaced) await unlink(aside).catch(() => undefined)
2437
+ const left = renamed ? finalPath : tmpPath
2438
+ if (left !== undefined) await unlink(left).catch(() => undefined)
2439
+ if (aside !== undefined) await unlink(aside).catch(() => undefined)
2161
2440
  throw err
2162
2441
  }
2163
2442
  endTx()
2164
- if (displaced) await unlink(aside).catch(() => undefined)
2443
+ if (aside !== undefined) await unlink(aside).catch(() => undefined)
2165
2444
  }
2166
2445
 
2167
2446
  /** Apply the deferred accessed_at bumps in one statement. */
2168
2447
  private flushAccessed(): void {
2169
2448
  // Apart: a hit whose row is fresh still renews an old file time.
2170
- if (this.touched.size === 0 && this.staleTimes.size === 0) return
2449
+ if (this.touched.size === 0 && this.staleTimes.size === 0 && this.staleInline.size === 0) {
2450
+ return
2451
+ }
2171
2452
  const hashes = [...this.touched]
2172
2453
  this.touched.clear()
2173
2454
  const now = Date.now()
@@ -2182,11 +2463,16 @@ export class Cache implements CacheLayer {
2182
2463
  }
2183
2464
  }
2184
2465
  this.staleTimes.clear()
2185
- if (hashes.length === 0) return
2466
+ const inline = [...this.staleInline]
2467
+ this.staleInline.clear()
2186
2468
  // LRU bookkeeping: on a full disk the bumps are dropped, never the prune
2187
2469
  // or the stats that asked for them (A-14).
2188
2470
  try {
2189
- this.writeAccessed(hashes, now)
2471
+ if (inline.length > 0) {
2472
+ const { test, params } = inHashes(inline)
2473
+ this.db.prepare(`UPDATE artifacts SET at = ? WHERE hash ${test}`).run(now, ...params)
2474
+ }
2475
+ if (hashes.length > 0) this.writeAccessed(hashes, now)
2190
2476
  } catch (err) {
2191
2477
  if (!isIndexFull(err)) throw err
2192
2478
  }
@@ -2334,7 +2620,7 @@ export class Cache implements CacheLayer {
2334
2620
  // them and evicted real entries to make room for them (item 975). None
2335
2621
  // is evicted or freed, whatever its age (item 1081); the ones past the
2336
2622
  // grace window are dropped.
2337
- const { phantoms, stale, rows } = await this.phantomRows()
2623
+ const { phantoms, stale, rows, inline } = await this.phantomRows()
2338
2624
  const picked = pickVictims(
2339
2625
  rows.filter((r) => !phantoms.has(r.hash)),
2340
2626
  await this.scanOrphans(),
@@ -2363,10 +2649,14 @@ export class Cache implements CacheLayer {
2363
2649
  // Artifacts first: a row whose artifact is gone reads as a miss, so the
2364
2650
  // order is safe, and on a full disk the delete's own journal needs the
2365
2651
  // space the unlinks free. Rows-first failed there before any file went,
2366
- // and the one verb meant to free a full disk freed nothing (A-14).
2367
- await Promise.all(hashes.map((h) => rm(this.tarPath(h), { force: true })))
2652
+ // and the one verb meant to free a full disk freed nothing (A-14). An
2653
+ // inline artifact goes with its rows, in their transaction.
2654
+ await Promise.all(
2655
+ hashes.filter((h) => !inline.has(h)).map((h) => rm(this.tarPath(h), { force: true })),
2656
+ )
2368
2657
  const { test, params } = inHashes(rows)
2369
2658
  const deleteRows = this.db.transaction(() => {
2659
+ this.db.prepare(`DELETE FROM artifacts WHERE hash ${test}`).run(...params)
2370
2660
  this.db.prepare(`DELETE FROM entries WHERE hash ${test}`).run(...params)
2371
2661
  this.outputs.forget(rows)
2372
2662
  })
@@ -2392,10 +2682,11 @@ export class Cache implements CacheLayer {
2392
2682
  /**
2393
2683
  * Unlink the row-less files `pickVictims` took: a `<hash>.tar.zst` with
2394
2684
  * no `entries` row (another vx version's open dropped it, a `cache.db`
2395
- * deleted by hand) past the policy, and a `<hash>.tar.zst.tmp-*` a
2396
- * crashed save never renamed. Nothing else reaps them — a lookup starts
2397
- * at the row, and a save of the same key renames over the file, so a key
2398
- * that never recurs leaks its bytes forever.
2685
+ * deleted by hand) past the policy, a `<hash>.tar.zst.tmp-*` a crashed
2686
+ * save never renamed, and a file an inline artifact shadows; and delete
2687
+ * the row-less inline artifacts it took. Nothing else reaps them — a
2688
+ * lookup starts at the row, and a save of the same key replaces the
2689
+ * bytes, so a key that never recurs leaks its bytes forever.
2399
2690
  */
2400
2691
  private async reapOrphans(
2401
2692
  taken: readonly RowlessFile[],
@@ -2411,8 +2702,29 @@ export class Cache implements CacheLayer {
2411
2702
  }
2412
2703
  let orphans = 0
2413
2704
  let orphanBytes = 0
2705
+ const blobs = taken.filter((o) => o.blob !== undefined)
2706
+ if (blobs.length > 0) {
2707
+ // Only while still row-less: an adopt since the scan keeps it.
2708
+ const drop = this.db.prepare(
2709
+ 'DELETE FROM artifacts WHERE hash = ? AND NOT EXISTS (SELECT 1 FROM entries WHERE hash = ?)',
2710
+ )
2711
+ try {
2712
+ this.db.transaction(() => {
2713
+ for (const o of blobs) {
2714
+ if (drop.run(o.blob!, o.blob!).changes === 0) continue
2715
+ orphans += 1
2716
+ orphanBytes += o.size
2717
+ }
2718
+ })()
2719
+ } catch (err) {
2720
+ if (!isIndexFull(err)) throw err
2721
+ orphans = 0
2722
+ orphanBytes = 0
2723
+ }
2724
+ }
2414
2725
  await Promise.all(
2415
2726
  taken.map(async (o) => {
2727
+ if (o.file === undefined) return
2416
2728
  try {
2417
2729
  // unlink, not `rm({ force })`: force swallows ENOENT, and a file a
2418
2730
  // concurrent prune took first must not be counted as ours.
@@ -2440,33 +2752,39 @@ export class Cache implements CacheLayer {
2440
2752
  phantoms: Map<string, number>
2441
2753
  stale: string[]
2442
2754
  rows: Array<{ hash: string; size_bytes: number; accessed_at: number }>
2755
+ /** The rows whose artifact is inline: present, whatever the directory holds. */
2756
+ inline: Set<string>
2443
2757
  }> {
2444
2758
  const graceStart = Date.now() - ORPHAN_GRACE_MS
2445
2759
  const rows = this.db
2446
- .prepare('SELECT hash, size_bytes, accessed_at FROM entries')
2760
+ .prepare(
2761
+ 'SELECT e.hash, e.size_bytes, e.accessed_at, a.hash IS NOT NULL AS inline FROM entries e LEFT JOIN artifacts a ON a.hash = e.hash',
2762
+ )
2447
2763
  .all() as Array<{
2448
2764
  hash: string
2449
2765
  size_bytes: number
2450
2766
  accessed_at: number
2767
+ inline: number
2451
2768
  }>
2769
+ const inline = new Set(rows.filter((r) => r.inline === 1).map((r) => r.hash))
2452
2770
  let names: string[]
2453
2771
  try {
2454
2772
  names = await readdir(this.artifactDir)
2455
2773
  } catch {
2456
- return { phantoms: new Map(), stale: [], rows }
2774
+ return { phantoms: new Map(), stale: [], rows, inline }
2457
2775
  }
2458
2776
  const present = new Set(names)
2459
2777
  const phantoms = new Map<string, number>()
2460
2778
  const stale: string[] = []
2461
2779
  for (const r of rows) {
2462
- if (present.has(`${r.hash}.tar.zst`)) continue
2780
+ if (r.inline === 1 || present.has(`${r.hash}.tar.zst`)) continue
2463
2781
  phantoms.set(r.hash, r.size_bytes)
2464
2782
  if (r.accessed_at < graceStart) stale.push(r.hash)
2465
2783
  }
2466
- return { phantoms, stale, rows }
2784
+ return { phantoms, stale, rows, inline }
2467
2785
  }
2468
2786
 
2469
- /** Row-less files past the in-flight grace, for `vx info`: a prune reaps them by its policy. */
2787
+ /** Row-less artifacts past the in-flight grace, for `vx info`: a prune reaps them by its policy. */
2470
2788
  async orphanStats(): Promise<{ orphans: number; orphanBytes: number }> {
2471
2789
  const found = await this.scanOrphans()
2472
2790
  let orphanBytes = 0
@@ -2474,21 +2792,40 @@ export class Cache implements CacheLayer {
2474
2792
  return { orphans: found.length, orphanBytes }
2475
2793
  }
2476
2794
 
2477
- /** Row-less artifacts and temps past the in-flight grace window: one readdir, one stat per candidate. */
2795
+ /**
2796
+ * Row-less artifacts and temps past the in-flight grace window: one
2797
+ * readdir, one stat per candidate, and the row-less inline artifacts,
2798
+ * their `at` standing for the file time.
2799
+ */
2478
2800
  private async scanOrphans(): Promise<RowlessFile[]> {
2479
- const found = await scanOrphanFiles(
2480
- this.artifactDir,
2481
- () =>
2482
- new Set(
2483
- (this.db.prepare('SELECT hash FROM entries').all() as Array<{ hash: string }>).map(
2484
- (r) => r.hash,
2485
- ),
2486
- ),
2487
- )
2801
+ // Asked once the directory read succeeded, as the rows are (scanOrphanFiles).
2802
+ let rowless: Array<{ hash: string; size: number; at: number }> = []
2803
+ const found = await scanOrphanFiles(this.artifactDir, () => {
2804
+ rowless = this.db
2805
+ .prepare(
2806
+ 'SELECT hash, length(bytes) AS size, at FROM artifacts WHERE at <= ? AND hash NOT IN (SELECT hash FROM entries)',
2807
+ )
2808
+ .all(Date.now() - ORPHAN_GRACE_MS) as typeof rowless
2809
+ const rows = this.db
2810
+ .prepare(
2811
+ 'SELECT e.hash, a.hash IS NOT NULL AS inline FROM entries e LEFT JOIN artifacts a ON a.hash = e.hash',
2812
+ )
2813
+ .all() as Array<{ hash: string; inline: number }>
2814
+ const blobs = this.db.prepare('SELECT hash FROM artifacts').all() as Array<{ hash: string }>
2815
+ return {
2816
+ files: new Set(rows.filter((r) => r.inline === 0).map((r) => r.hash)),
2817
+ inline: new Set(blobs.map((r) => r.hash)),
2818
+ }
2819
+ })
2820
+ for (const b of rowless) found.push({ blob: b.hash, size: b.size, mtimeMs: b.at, temp: false })
2488
2821
  // With a shared store, no artifact in `cacheDir` is indexed: they are
2489
2822
  // what this workspace saved before its entries moved out.
2490
2823
  if (this.artifactDir === this.cacheDir) return found
2491
- return [...found, ...(await scanOrphanFiles(this.cacheDir, () => new Set()))]
2824
+ const none = new Set<string>()
2825
+ return [
2826
+ ...found,
2827
+ ...(await scanOrphanFiles(this.cacheDir, () => ({ files: none, inline: none }))),
2828
+ ]
2492
2829
  }
2493
2830
 
2494
2831
  close(): void {
@@ -2604,3 +2941,45 @@ function assertArtifactNames(
2604
2941
  }
2605
2942
  }
2606
2943
  }
2944
+
2945
+ /** What `ingest` reads a remote body through. */
2946
+ interface ChunkReader {
2947
+ read(): Promise<{ done: true; value?: undefined } | { done: false; value: Uint8Array }>
2948
+ cancel(reason?: unknown): Promise<void>
2949
+ }
2950
+
2951
+ /**
2952
+ * Read `reader` until it ends or holds more than `max` bytes: `done` and
2953
+ * the whole body, or the bytes read so far for `prepended` to put back.
2954
+ */
2955
+ async function readUpTo(
2956
+ reader: ChunkReader,
2957
+ max: number,
2958
+ ): Promise<{ done: boolean; bytes: Uint8Array }> {
2959
+ const chunks: Uint8Array[] = []
2960
+ let n = 0
2961
+ while (n <= max) {
2962
+ const r = await reader.read()
2963
+ if (r.done) return { done: true, bytes: Buffer.concat(chunks, n) }
2964
+ chunks.push(r.value)
2965
+ n += r.value.byteLength
2966
+ }
2967
+ return { done: false, bytes: Buffer.concat(chunks, n) }
2968
+ }
2969
+
2970
+ /** `head`, then the rest of `reader`: a body `readUpTo` started, whole again. */
2971
+ function prepended(head: Uint8Array, reader: ChunkReader): ReadableStream<Uint8Array> {
2972
+ return new ReadableStream<Uint8Array>({
2973
+ start(controller) {
2974
+ controller.enqueue(head)
2975
+ },
2976
+ async pull(controller) {
2977
+ const r = await reader.read()
2978
+ if (r.done) controller.close()
2979
+ else controller.enqueue(r.value)
2980
+ },
2981
+ cancel(reason) {
2982
+ return reader.cancel(reason)
2983
+ },
2984
+ })
2985
+ }