Skip to content

Commit 714c426

Browse files
authored
Keep Deno KV caches per V8 release branch to handle several Deno (#15180)
Deno 2.9 writes V8 serializer format 16 where 2.7 writes 15, and an older V8 rejects newer formats. A Quarto on Deno 2.9 that wrote the shared sass.kv or a project's .quarto/project-cache/deno-kv-file made every older Quarto on the machine fail with "RangeError: could not deserialize value" until the cache was deleted. Released versions cannot be fixed on the read side, so the new Quarto keeps its stores under a v8-<major>.<minor> directory and leaves untagged stores untouched for older versions. - Sass cache: compiled CSS and the KV store sit under the V8 directory; cleanup passes the requested path explicitly. - Project cache: index and content-addressed files sit under project-cache/v8-<major>.<minor>. - clear() no longer passes index values to join(), which threw "Path must be a string" on any populated cache. It removes hash files only for disk entries and uses safeRemoveSync because identical content shares one hash file. Related to #15157, #15152, #14594, #13955
1 parent 303b95f commit 714c426

6 files changed

Lines changed: 159 additions & 34 deletions

File tree

‎src/core/cache/cache.ts‎

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
*/
88

99
import { assert } from "testing/asserts";
10-
import { ensureDirSync } from "../../deno_ral/fs.ts";
10+
import { ensureDirSync, safeRemoveSync } from "../../deno_ral/fs.ts";
1111
import { join } from "../../deno_ral/path.ts";
12+
import { v8Branch } from "../../deno_ral/platform.ts";
1213
import { md5HashBytes } from "../hash.ts";
1314
import { satisfies } from "semver/mod.ts";
1415
import { quartoConfig } from "../quarto.ts";
@@ -29,10 +30,16 @@ const requiredQuartoVersions: Record<string, string> = {
2930

3031
class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
3132
projectScratchDir: string;
33+
storeDir: string;
3234
index: Deno.Kv | null;
3335

3436
constructor(_projectScratchDir: string) {
3537
this.projectScratchDir = _projectScratchDir;
38+
this.storeDir = join(
39+
_projectScratchDir,
40+
"project-cache",
41+
`v8-${v8Branch}`,
42+
);
3643
this.index = null;
3744
}
3845

@@ -51,11 +58,15 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
5158
assert(this.index);
5259
const entries = this.index.list({ prefix: [] });
5360
for await (const entry of entries) {
54-
const diskValue = entry.value;
55-
if (entry.key[0] !== "version") {
56-
Deno.removeSync(
57-
join(this.projectScratchDir, "project-cache", diskValue as string),
58-
);
61+
const value = entry.value as CacheIndexEntry;
62+
// immediate entries live in the index only; disk entries with
63+
// identical content share one hash file
64+
if (
65+
entry.key[0] !== "version" &&
66+
(value.type === "buffer" || value.type === "string")
67+
) {
68+
const { hash } = value as DiskCacheEntry;
69+
safeRemoveSync(join(this.storeDir, hash));
5970
}
6071
await this.index.delete(entry.key);
6172
}
@@ -67,9 +78,8 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
6778
}
6879

6980
async init(): Promise<void> {
70-
const indexPath = join(this.projectScratchDir, "project-cache");
71-
ensureDirSync(indexPath);
72-
this.index = await Deno.openKv(join(indexPath, "deno-kv-file"));
81+
ensureDirSync(this.storeDir);
82+
this.index = await Deno.openKv(join(this.storeDir, "deno-kv-file"));
7383
let version = await this.index.get(["version"]);
7484
if (version.value === null) {
7585
await this.createOnDisk();
@@ -93,10 +103,7 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
93103
async addBuffer(key: string[], value: Uint8Array): Promise<void> {
94104
assert(this.index);
95105
const hash = await md5HashBytes(value);
96-
Deno.writeFileSync(
97-
join(this.projectScratchDir, "project-cache", hash),
98-
value,
99-
);
106+
Deno.writeFileSync(join(this.storeDir, hash), value);
100107
const result = await this.index.set(key, {
101108
hash,
102109
type: "buffer",
@@ -108,10 +115,7 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
108115
assert(this.index);
109116
const buffer = new TextEncoder().encode(value);
110117
const hash = await md5HashBytes(buffer);
111-
Deno.writeTextFileSync(
112-
join(this.projectScratchDir, "project-cache", hash),
113-
value,
114-
);
118+
Deno.writeTextFileSync(join(this.storeDir, hash), value);
115119
const result = await this.index.set(key, {
116120
hash,
117121
type: "string",
@@ -187,19 +191,15 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
187191
return this._getAs(key, (result) => {
188192
assert(result.type === "buffer", "Expected buffer");
189193
const { hash } = result as DiskCacheEntry;
190-
return Deno.readFileSync(
191-
join(this.projectScratchDir, "project-cache", hash),
192-
);
194+
return Deno.readFileSync(join(this.storeDir, hash));
193195
});
194196
}
195197

196198
async getString(key: string[]): Promise<string | null> {
197199
return this._getAs(key, (result) => {
198200
assert(result.type === "string", "Expected string");
199201
const { hash } = result as DiskCacheEntry;
200-
return Deno.readTextFileSync(
201-
join(this.projectScratchDir, "project-cache", hash),
202-
);
202+
return Deno.readTextFileSync(join(this.storeDir, hash));
203203
});
204204
}
205205

‎src/core/sass/cache.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { InternalError } from "../lib/error.ts";
1010
import { md5HashAsync } from "../hash.ts";
1111
import { join } from "../../deno_ral/path.ts";
1212
import { ensureDirSync, existsSync } from "../../deno_ral/fs.ts";
13+
import { v8Branch } from "../../deno_ral/platform.ts";
1314
import { TempContext } from "../temp.ts";
1415
import { safeRemoveIfExists } from "../path.ts";
1516
import * as log from "../../deno_ral/log.ts";
@@ -143,7 +144,9 @@ class SassCache implements Cloneable<SassCache> {
143144
}
144145

145146
// add a cleanup method to register a cleanup handler
146-
cleanup(temp: TempContext | undefined) {
147+
// registryKey is the path the cache was requested with, which differs from
148+
// this.path (the per-V8-branch store dir under it).
149+
cleanup(temp: TempContext | undefined, registryKey: string) {
147150
const registerCleanup = temp ? temp.onCleanup : onCleanup;
148151
const cachePath = this.path;
149152
registerCleanup(() => {
@@ -158,7 +161,7 @@ class SassCache implements Cloneable<SassCache> {
158161
} finally {
159162
// Drop the registry entry so a later resolve of the same path opens a
160163
// fresh KV handle instead of reusing this now-closed one (#14594, #13955).
161-
delete _sassCache[cachePath];
164+
delete _sassCache[registryKey];
162165
}
163166
});
164167
}
@@ -205,14 +208,15 @@ export async function sassCache(
205208
temp: TempContext | undefined,
206209
): Promise<SassCache> {
207210
if (!_sassCache[path]) {
208-
log.debug(`Creating SassCache at ${path}`);
209-
ensureDirSync(path);
210-
const kvFile = join(path, "sass.kv");
211+
const storeDir = join(path, `v8-${v8Branch}`);
212+
log.debug(`Creating SassCache at ${storeDir}`);
213+
ensureDirSync(storeDir);
214+
const kvFile = join(storeDir, "sass.kv");
211215
const kv = await Deno.openKv(kvFile);
212216
await checkVersion(kv, kvFile);
213-
_sassCache[path] = new SassCache(kv, path);
217+
_sassCache[path] = new SassCache(kv, storeDir);
214218
// register cleanup for this cache
215-
_sassCache[path].cleanup(temp);
219+
_sassCache[path].cleanup(temp, path);
216220
}
217221
log.debug(`Returning SassCache at ${path}`);
218222
const result = _sassCache[path];

‎src/deno_ral/platform.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ export const isLinux = Deno.build.os === "linux";
1111
export const os = Deno.build.os;
1212

1313
export const arch = Deno.build.arch;
14+
15+
// Deno KV stores are kept per V8 release branch ("<major>.<minor>") because
16+
// the V8 serializer format can change between branches.
17+
export const v8Branch = Deno.version.v8.split(".").slice(0, 2).join(".");

‎tests/unit/project-cache.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* project-cache.test.ts
3+
*
4+
* Tests that the project cache keeps its store per V8 release branch.
5+
*
6+
* Copyright (C) 2026 Posit Software, PBC
7+
*/
8+
9+
import { unitTest } from "../test.ts";
10+
import { assert, assertEquals } from "../asserts.ts";
11+
import { join } from "../../src/deno_ral/path.ts";
12+
import { ensureDirSync, existsSync } from "../../src/deno_ral/fs.ts";
13+
import { v8Branch } from "../../src/deno_ral/platform.ts";
14+
import { md5HashBytes } from "../../src/core/hash.ts";
15+
import { createProjectCache } from "../../src/core/cache/cache.ts";
16+
import { withTempDir } from "../utils.ts";
17+
18+
const LEGACY_SENTINEL = "legacy deno-kv-file owned by another Quarto version";
19+
20+
unitTest(
21+
"projectCache - stores index and content under v8-<branch> and never opens a legacy deno-kv-file",
22+
async () => {
23+
await withTempDir(async (scratch) => {
24+
const legacyDir = join(scratch, "project-cache");
25+
ensureDirSync(legacyDir);
26+
const legacy = join(legacyDir, "deno-kv-file");
27+
Deno.writeTextFileSync(legacy, LEGACY_SENTINEL);
28+
29+
const cache = await createProjectCache(scratch);
30+
try {
31+
await cache.addString(["k"], "cached value");
32+
assertEquals(await cache.getString(["k"]), "cached value");
33+
const storeDir = join(legacyDir, `v8-${v8Branch}`);
34+
const hash = await md5HashBytes(
35+
new TextEncoder().encode("cached value"),
36+
);
37+
assert(existsSync(join(storeDir, "deno-kv-file")));
38+
assert(existsSync(join(storeDir, hash)));
39+
assert(!existsSync(join(legacyDir, hash)));
40+
} finally {
41+
cache.close();
42+
}
43+
assertEquals(Deno.readTextFileSync(legacy), LEGACY_SENTINEL);
44+
}, "quarto-project-cache-v8");
45+
},
46+
);
47+
48+
unitTest(
49+
"projectCache - clear() removes disk and immediate entries of a populated cache",
50+
async () => {
51+
await withTempDir(async (scratch) => {
52+
const cache = await createProjectCache(scratch);
53+
try {
54+
await cache.addString(["string"], "shared content");
55+
await cache.addString(["same-content"], "shared content");
56+
await cache.addBuffer(["buffer"], new Uint8Array([1, 2, 3]));
57+
await cache.addSmallString(["small-string"], "small");
58+
await cache.addSmallBuffer(["small-buffer"], new Uint8Array([4]));
59+
const storeDir = join(scratch, "project-cache", `v8-${v8Branch}`);
60+
const stringHash = await md5HashBytes(
61+
new TextEncoder().encode("shared content"),
62+
);
63+
const bufferHash = await md5HashBytes(new Uint8Array([1, 2, 3]));
64+
65+
await cache.clear();
66+
67+
assertEquals(await cache.get(["string"]), null);
68+
assertEquals(await cache.get(["same-content"]), null);
69+
assertEquals(await cache.get(["buffer"]), null);
70+
assertEquals(await cache.get(["small-string"]), null);
71+
assertEquals(await cache.get(["small-buffer"]), null);
72+
assert(!existsSync(join(storeDir, stringHash)));
73+
assert(!existsSync(join(storeDir, bufferHash)));
74+
} finally {
75+
cache.close();
76+
}
77+
}, "quarto-project-cache-clear");
78+
},
79+
);

‎tests/unit/sass-cache.test.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
*/
1111

1212
import { unitTest } from "../test.ts";
13-
import { assertEquals } from "testing/asserts";
14-
import { join } from "../../src/deno_ral/path.ts";
13+
import { assert, assertEquals, assertMatch } from "testing/asserts";
14+
import { dirname, join } from "../../src/deno_ral/path.ts";
15+
import { ensureDirSync, existsSync } from "../../src/deno_ral/fs.ts";
16+
import { v8Branch } from "../../src/deno_ral/platform.ts";
1517
import { safeRemoveIfExists } from "../../src/core/path.ts";
1618
import { createTempContext, TempContext } from "../../src/core/temp.ts";
1719
import { sassCache } from "../../src/core/sass/cache.ts";
20+
import { withTempDir } from "../utils.ts";
1821

1922
// A syntactically valid md5 hash that will never be present in a fresh cache,
2023
// so getFromHash exercises kv.get() and returns a clean cache miss (null).
@@ -56,3 +59,38 @@ unitTest(
5659
}
5760
},
5861
);
62+
63+
// Bytes an older Quarto's store could hold; not a SQLite file, so opening it
64+
// with Deno.openKv throws "file is not a database".
65+
const LEGACY_SENTINEL = "legacy sass.kv owned by another Quarto version";
66+
const ID_HASH = "a".repeat(32);
67+
const INPUT_HASH = "1".repeat(32);
68+
69+
unitTest(
70+
"sassCache - stores index and outputs under v8-<branch> and never opens a legacy sass.kv",
71+
async () => {
72+
await withTempDir(async (root) => {
73+
const cachePath = join(root, "sass");
74+
ensureDirSync(cachePath);
75+
const legacy = join(cachePath, "sass.kv");
76+
Deno.writeTextFileSync(legacy, LEGACY_SENTINEL);
77+
78+
const temp = createTempContext();
79+
try {
80+
const cache = await sassCache(cachePath, temp);
81+
const out = await cache.setFromHash(ID_HASH, INPUT_HASH, "id", (p) => {
82+
Deno.writeTextFileSync(p, "/* css */");
83+
return Promise.resolve();
84+
});
85+
assertMatch(v8Branch, /^\d+\.\d+$/);
86+
const storeDir = join(cachePath, `v8-${v8Branch}`);
87+
assertEquals(dirname(out), storeDir);
88+
assert(existsSync(join(storeDir, "sass.kv")));
89+
assertEquals(await cache.getFromHash(ID_HASH, INPUT_HASH), out);
90+
assertEquals(Deno.readTextFileSync(legacy), LEGACY_SENTINEL);
91+
} finally {
92+
temp.cleanup();
93+
}
94+
}, "quarto-sass-cache-v8");
95+
},
96+
);

‎tools/runtime-boundary-baseline.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"src": {
3-
"denoCalls": 994,
3+
"denoCalls": 993,
44
"stdImportFiles": 49
55
},
66
"src-js": {
@@ -16,7 +16,7 @@
1616
"stdImportFiles": 3
1717
},
1818
"tests": {
19-
"denoCalls": 832,
19+
"denoCalls": 838,
2020
"stdImportFiles": 177
2121
}
2222
}

0 commit comments

Comments
 (0)