diff --git a/src/core/cache/cache.ts b/src/core/cache/cache.ts index c32e6f42df0..3d3ffc7a46e 100644 --- a/src/core/cache/cache.ts +++ b/src/core/cache/cache.ts @@ -7,8 +7,9 @@ */ import { assert } from "testing/asserts"; -import { ensureDirSync } from "../../deno_ral/fs.ts"; +import { ensureDirSync, safeRemoveSync } from "../../deno_ral/fs.ts"; import { join } from "../../deno_ral/path.ts"; +import { v8Branch } from "../../deno_ral/platform.ts"; import { md5HashBytes } from "../hash.ts"; import { satisfies } from "semver/mod.ts"; import { quartoConfig } from "../quarto.ts"; @@ -29,10 +30,16 @@ const requiredQuartoVersions: Record = { class ProjectCacheImpl implements Cloneable { projectScratchDir: string; + storeDir: string; index: Deno.Kv | null; constructor(_projectScratchDir: string) { this.projectScratchDir = _projectScratchDir; + this.storeDir = join( + _projectScratchDir, + "project-cache", + `v8-${v8Branch}`, + ); this.index = null; } @@ -51,11 +58,15 @@ class ProjectCacheImpl implements Cloneable { assert(this.index); const entries = this.index.list({ prefix: [] }); for await (const entry of entries) { - const diskValue = entry.value; - if (entry.key[0] !== "version") { - Deno.removeSync( - join(this.projectScratchDir, "project-cache", diskValue as string), - ); + const value = entry.value as CacheIndexEntry; + // immediate entries live in the index only; disk entries with + // identical content share one hash file + if ( + entry.key[0] !== "version" && + (value.type === "buffer" || value.type === "string") + ) { + const { hash } = value as DiskCacheEntry; + safeRemoveSync(join(this.storeDir, hash)); } await this.index.delete(entry.key); } @@ -67,9 +78,8 @@ class ProjectCacheImpl implements Cloneable { } async init(): Promise { - const indexPath = join(this.projectScratchDir, "project-cache"); - ensureDirSync(indexPath); - this.index = await Deno.openKv(join(indexPath, "deno-kv-file")); + ensureDirSync(this.storeDir); + this.index = await Deno.openKv(join(this.storeDir, "deno-kv-file")); let version = await this.index.get(["version"]); if (version.value === null) { await this.createOnDisk(); @@ -93,10 +103,7 @@ class ProjectCacheImpl implements Cloneable { async addBuffer(key: string[], value: Uint8Array): Promise { assert(this.index); const hash = await md5HashBytes(value); - Deno.writeFileSync( - join(this.projectScratchDir, "project-cache", hash), - value, - ); + Deno.writeFileSync(join(this.storeDir, hash), value); const result = await this.index.set(key, { hash, type: "buffer", @@ -108,10 +115,7 @@ class ProjectCacheImpl implements Cloneable { assert(this.index); const buffer = new TextEncoder().encode(value); const hash = await md5HashBytes(buffer); - Deno.writeTextFileSync( - join(this.projectScratchDir, "project-cache", hash), - value, - ); + Deno.writeTextFileSync(join(this.storeDir, hash), value); const result = await this.index.set(key, { hash, type: "string", @@ -187,9 +191,7 @@ class ProjectCacheImpl implements Cloneable { return this._getAs(key, (result) => { assert(result.type === "buffer", "Expected buffer"); const { hash } = result as DiskCacheEntry; - return Deno.readFileSync( - join(this.projectScratchDir, "project-cache", hash), - ); + return Deno.readFileSync(join(this.storeDir, hash)); }); } @@ -197,9 +199,7 @@ class ProjectCacheImpl implements Cloneable { return this._getAs(key, (result) => { assert(result.type === "string", "Expected string"); const { hash } = result as DiskCacheEntry; - return Deno.readTextFileSync( - join(this.projectScratchDir, "project-cache", hash), - ); + return Deno.readTextFileSync(join(this.storeDir, hash)); }); } diff --git a/src/core/sass/cache.ts b/src/core/sass/cache.ts index dd3790eb51c..53959dad64b 100644 --- a/src/core/sass/cache.ts +++ b/src/core/sass/cache.ts @@ -10,6 +10,7 @@ import { InternalError } from "../lib/error.ts"; import { md5HashAsync } from "../hash.ts"; import { join } from "../../deno_ral/path.ts"; import { ensureDirSync, existsSync } from "../../deno_ral/fs.ts"; +import { v8Branch } from "../../deno_ral/platform.ts"; import { TempContext } from "../temp.ts"; import { safeRemoveIfExists } from "../path.ts"; import * as log from "../../deno_ral/log.ts"; @@ -143,7 +144,9 @@ class SassCache implements Cloneable { } // add a cleanup method to register a cleanup handler - cleanup(temp: TempContext | undefined) { + // registryKey is the path the cache was requested with, which differs from + // this.path (the per-V8-branch store dir under it). + cleanup(temp: TempContext | undefined, registryKey: string) { const registerCleanup = temp ? temp.onCleanup : onCleanup; const cachePath = this.path; registerCleanup(() => { @@ -158,7 +161,7 @@ class SassCache implements Cloneable { } finally { // Drop the registry entry so a later resolve of the same path opens a // fresh KV handle instead of reusing this now-closed one (#14594, #13955). - delete _sassCache[cachePath]; + delete _sassCache[registryKey]; } }); } @@ -205,14 +208,15 @@ export async function sassCache( temp: TempContext | undefined, ): Promise { if (!_sassCache[path]) { - log.debug(`Creating SassCache at ${path}`); - ensureDirSync(path); - const kvFile = join(path, "sass.kv"); + const storeDir = join(path, `v8-${v8Branch}`); + log.debug(`Creating SassCache at ${storeDir}`); + ensureDirSync(storeDir); + const kvFile = join(storeDir, "sass.kv"); const kv = await Deno.openKv(kvFile); await checkVersion(kv, kvFile); - _sassCache[path] = new SassCache(kv, path); + _sassCache[path] = new SassCache(kv, storeDir); // register cleanup for this cache - _sassCache[path].cleanup(temp); + _sassCache[path].cleanup(temp, path); } log.debug(`Returning SassCache at ${path}`); const result = _sassCache[path]; diff --git a/src/deno_ral/platform.ts b/src/deno_ral/platform.ts index 62a1b422f2c..95cb0de7cc9 100644 --- a/src/deno_ral/platform.ts +++ b/src/deno_ral/platform.ts @@ -11,3 +11,7 @@ export const isLinux = Deno.build.os === "linux"; export const os = Deno.build.os; export const arch = Deno.build.arch; + +// Deno KV stores are kept per V8 release branch (".") because +// the V8 serializer format can change between branches. +export const v8Branch = Deno.version.v8.split(".").slice(0, 2).join("."); diff --git a/tests/unit/project-cache.test.ts b/tests/unit/project-cache.test.ts new file mode 100644 index 00000000000..7ed72847b50 --- /dev/null +++ b/tests/unit/project-cache.test.ts @@ -0,0 +1,79 @@ +/* + * project-cache.test.ts + * + * Tests that the project cache keeps its store per V8 release branch. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert, assertEquals } from "../asserts.ts"; +import { join } from "../../src/deno_ral/path.ts"; +import { ensureDirSync, existsSync } from "../../src/deno_ral/fs.ts"; +import { v8Branch } from "../../src/deno_ral/platform.ts"; +import { md5HashBytes } from "../../src/core/hash.ts"; +import { createProjectCache } from "../../src/core/cache/cache.ts"; +import { withTempDir } from "../utils.ts"; + +const LEGACY_SENTINEL = "legacy deno-kv-file owned by another Quarto version"; + +unitTest( + "projectCache - stores index and content under v8- and never opens a legacy deno-kv-file", + async () => { + await withTempDir(async (scratch) => { + const legacyDir = join(scratch, "project-cache"); + ensureDirSync(legacyDir); + const legacy = join(legacyDir, "deno-kv-file"); + Deno.writeTextFileSync(legacy, LEGACY_SENTINEL); + + const cache = await createProjectCache(scratch); + try { + await cache.addString(["k"], "cached value"); + assertEquals(await cache.getString(["k"]), "cached value"); + const storeDir = join(legacyDir, `v8-${v8Branch}`); + const hash = await md5HashBytes( + new TextEncoder().encode("cached value"), + ); + assert(existsSync(join(storeDir, "deno-kv-file"))); + assert(existsSync(join(storeDir, hash))); + assert(!existsSync(join(legacyDir, hash))); + } finally { + cache.close(); + } + assertEquals(Deno.readTextFileSync(legacy), LEGACY_SENTINEL); + }, "quarto-project-cache-v8"); + }, +); + +unitTest( + "projectCache - clear() removes disk and immediate entries of a populated cache", + async () => { + await withTempDir(async (scratch) => { + const cache = await createProjectCache(scratch); + try { + await cache.addString(["string"], "shared content"); + await cache.addString(["same-content"], "shared content"); + await cache.addBuffer(["buffer"], new Uint8Array([1, 2, 3])); + await cache.addSmallString(["small-string"], "small"); + await cache.addSmallBuffer(["small-buffer"], new Uint8Array([4])); + const storeDir = join(scratch, "project-cache", `v8-${v8Branch}`); + const stringHash = await md5HashBytes( + new TextEncoder().encode("shared content"), + ); + const bufferHash = await md5HashBytes(new Uint8Array([1, 2, 3])); + + await cache.clear(); + + assertEquals(await cache.get(["string"]), null); + assertEquals(await cache.get(["same-content"]), null); + assertEquals(await cache.get(["buffer"]), null); + assertEquals(await cache.get(["small-string"]), null); + assertEquals(await cache.get(["small-buffer"]), null); + assert(!existsSync(join(storeDir, stringHash))); + assert(!existsSync(join(storeDir, bufferHash))); + } finally { + cache.close(); + } + }, "quarto-project-cache-clear"); + }, +); diff --git a/tests/unit/sass-cache.test.ts b/tests/unit/sass-cache.test.ts index 6f93363b4df..6a26f690287 100644 --- a/tests/unit/sass-cache.test.ts +++ b/tests/unit/sass-cache.test.ts @@ -10,11 +10,14 @@ */ import { unitTest } from "../test.ts"; -import { assertEquals } from "testing/asserts"; -import { join } from "../../src/deno_ral/path.ts"; +import { assert, assertEquals, assertMatch } from "testing/asserts"; +import { dirname, join } from "../../src/deno_ral/path.ts"; +import { ensureDirSync, existsSync } from "../../src/deno_ral/fs.ts"; +import { v8Branch } from "../../src/deno_ral/platform.ts"; import { safeRemoveIfExists } from "../../src/core/path.ts"; import { createTempContext, TempContext } from "../../src/core/temp.ts"; import { sassCache } from "../../src/core/sass/cache.ts"; +import { withTempDir } from "../utils.ts"; // A syntactically valid md5 hash that will never be present in a fresh cache, // so getFromHash exercises kv.get() and returns a clean cache miss (null). @@ -56,3 +59,38 @@ unitTest( } }, ); + +// Bytes an older Quarto's store could hold; not a SQLite file, so opening it +// with Deno.openKv throws "file is not a database". +const LEGACY_SENTINEL = "legacy sass.kv owned by another Quarto version"; +const ID_HASH = "a".repeat(32); +const INPUT_HASH = "1".repeat(32); + +unitTest( + "sassCache - stores index and outputs under v8- and never opens a legacy sass.kv", + async () => { + await withTempDir(async (root) => { + const cachePath = join(root, "sass"); + ensureDirSync(cachePath); + const legacy = join(cachePath, "sass.kv"); + Deno.writeTextFileSync(legacy, LEGACY_SENTINEL); + + const temp = createTempContext(); + try { + const cache = await sassCache(cachePath, temp); + const out = await cache.setFromHash(ID_HASH, INPUT_HASH, "id", (p) => { + Deno.writeTextFileSync(p, "/* css */"); + return Promise.resolve(); + }); + assertMatch(v8Branch, /^\d+\.\d+$/); + const storeDir = join(cachePath, `v8-${v8Branch}`); + assertEquals(dirname(out), storeDir); + assert(existsSync(join(storeDir, "sass.kv"))); + assertEquals(await cache.getFromHash(ID_HASH, INPUT_HASH), out); + assertEquals(Deno.readTextFileSync(legacy), LEGACY_SENTINEL); + } finally { + temp.cleanup(); + } + }, "quarto-sass-cache-v8"); + }, +); diff --git a/tools/runtime-boundary-baseline.json b/tools/runtime-boundary-baseline.json index 315f1c2eb47..2810b910744 100644 --- a/tools/runtime-boundary-baseline.json +++ b/tools/runtime-boundary-baseline.json @@ -1,6 +1,6 @@ { "src": { - "denoCalls": 994, + "denoCalls": 993, "stdImportFiles": 49 }, "src-js": { @@ -16,7 +16,7 @@ "stdImportFiles": 3 }, "tests": { - "denoCalls": 832, + "denoCalls": 838, "stdImportFiles": 177 } }