Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions src/core/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,10 +30,16 @@ const requiredQuartoVersions: Record<string, string> = {

class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
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;
}

Expand All @@ -51,11 +58,15 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
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);
}
Expand All @@ -67,9 +78,8 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
}

async init(): Promise<void> {
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();
Expand All @@ -93,10 +103,7 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
async addBuffer(key: string[], value: Uint8Array): Promise<void> {
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",
Expand All @@ -108,10 +115,7 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
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",
Expand Down Expand Up @@ -187,19 +191,15 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
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));
});
}

async getString(key: string[]): Promise<string | null> {
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));
});
}

Expand Down
18 changes: 11 additions & 7 deletions src/core/sass/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -143,7 +144,9 @@ class SassCache implements Cloneable<SassCache> {
}

// 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(() => {
Expand All @@ -158,7 +161,7 @@ class SassCache implements Cloneable<SassCache> {
} 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];
}
});
}
Expand Down Expand Up @@ -205,14 +208,15 @@ export async function sassCache(
temp: TempContext | undefined,
): Promise<SassCache> {
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];
Expand Down
4 changes: 4 additions & 0 deletions src/deno_ral/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<major>.<minor>") because
// the V8 serializer format can change between branches.
export const v8Branch = Deno.version.v8.split(".").slice(0, 2).join(".");
79 changes: 79 additions & 0 deletions tests/unit/project-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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-<branch> 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);

Check warning on line 27 in tests/unit/project-cache.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +1 Deno.writeTextFileSync in tests/unit/project-cache.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).

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);

Check warning on line 43 in tests/unit/project-cache.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +1 Deno.readTextFileSync in tests/unit/project-cache.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).
}, "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");
},
);
42 changes: 40 additions & 2 deletions tests/unit/sass-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -56,3 +59,38 @@
}
},
);

// Bytes an older Quarto's store could hold; not a SQLite file, so opening it
// with Deno.openKv throws "file is not a database".

Check warning on line 64 in tests/unit/sass-cache.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +1 Deno.openKv in tests/unit/sass-cache.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).
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-<branch> 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);

Check warning on line 76 in tests/unit/sass-cache.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +2 Deno.writeTextFileSync in tests/unit/sass-cache.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).

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);

Check warning on line 90 in tests/unit/sass-cache.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +1 Deno.readTextFileSync in tests/unit/sass-cache.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).
} finally {
temp.cleanup();
}
}, "quarto-sass-cache-v8");
},
);
4 changes: 2 additions & 2 deletions tools/runtime-boundary-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"src": {
"denoCalls": 994,
"denoCalls": 993,
"stdImportFiles": 49
},
"src-js": {
Expand All @@ -16,7 +16,7 @@
"stdImportFiles": 3
},
"tests": {
"denoCalls": 832,
"denoCalls": 838,
"stdImportFiles": 177
}
}
Loading