Skip to content

Commit 5b23163

Browse files
authored
Handle damaged Deno KV cache files in the Sass and project caches (#15185)
A damaged value in the Sass or project cache used to abort every render with "RangeError: could not deserialize value", with no hint of which file was at fault. Both caches now read their Deno KV stores through a new runtime-layer module, src/deno_ral/kv.ts, which sorts errors by message because Deno gives them no code: - A damaged value is read as a cache miss, with one warning per cache file. The write that follows the miss repairs the row. - A damaged file fails with an error that names the file and the v8-<branch> folder to delete. Quarto never deletes the file itself, since another process may hold the store open. - Any other failure, such as a locked store or an unopenable path, is rethrown with the cache path added and is never treated as damage. Tests damage real stores with node:sqlite, truncation and a text file, rather than mocking errors. Fixes #15119
1 parent 0284dc1 commit 5b23163

11 files changed

Lines changed: 922 additions & 87 deletions

File tree

‎.claude/rules/typescript/deno-ral.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The `deno_ral/` directory provides a runtime abstraction layer over Deno's stand
1414
| `fs.ts` | File system | `existsSync`, `ensureDirSync`, `copySync`, `safeMoveSync`, `safeRemoveSync` |
1515
| `path.ts` | Path utilities | `join`, `dirname`, `basename`, `extname` |
1616
| `log.ts` | Logging | `debug`, `info`, `warning`, `error` |
17+
| `kv.ts` | Deno KV cache stores | `openKv`, `getKv`, `kvOp`, `KvDamagedError` |
1718
| `platform.ts` | Platform detection | `isWindows` |
1819
| `process.ts` | Process execution | Process utilities |
1920

‎news/changelog-1.11.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ All changes included in 1.11:
188188
- ([#14878](https://github.com/quarto-dev/quarto-cli/pull/14878)): Add `az` (Azerbaijani) language translation. (author: @abdanar)
189189
- ([#14983](https://github.com/quarto-dev/quarto-cli/issues/14983)): Fix `quarto render` treating a parent folder as a Hugo project when its `config/` folder can't be entered, which silently wrote `hugo-md` output or failed with a permission error. (author: @pinin4fjords)
190190
- ([#15097](https://github.com/quarto-dev/quarto-cli/pull/15097)): Fix the `.content-hidden unless-profile` attribute completion inserting invalid syntax (its `=` was missing), and typos in three attribute completion descriptions. (author: @rkskmt)
191+
- ([#15119](https://github.com/quarto-dev/quarto-cli/issues/15119)): Fix renders failing with `RangeError: could not deserialize value` when a Quarto cache file is damaged. A damaged entry is now recomputed, and a damaged cache file is reported with the folder to delete.
191192
- ([#15126](https://github.com/quarto-dev/quarto-cli/issues/15126)): Translate the `revealjs-menu-button-label` language key in French. (author: @D3ller)
192193
- ([#15140](https://github.com/quarto-dev/quarto-cli/pull/15140)): Add support for definition lists written as a `definition-list` div holding a nested bullet list, the definition list syntax of Quarto 2.
193194
- ([#15180](https://github.com/quarto-dev/quarto-cli/pull/15180)): Fix older Quarto versions failing to render with `RangeError: could not deserialize value` once a Quarto built on a newer Deno has used the same cache. Also fix a project cache that already has entries failing to clear with `Path must be a string`.

‎src/core/cache/cache.ts‎

Lines changed: 76 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type ImmediateStringCacheEntry,
2222
} from "./cache-types.ts";
2323
import { Cloneable } from "../safe-clone-deep.ts";
24+
import { getKv, kvOp, openKv } from "../../deno_ral/kv.ts";
2425
export { type ProjectCache } from "./cache-types.ts";
2526

2627
const currentCacheVersion = "1";
@@ -31,6 +32,7 @@ const requiredQuartoVersions: Record<string, string> = {
3132
class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
3233
projectScratchDir: string;
3334
storeDir: string;
35+
indexFile: string;
3436
index: Deno.Kv | null;
3537

3638
constructor(_projectScratchDir: string) {
@@ -40,6 +42,7 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
4042
"project-cache",
4143
`v8-${v8Branch}`,
4244
);
45+
this.indexFile = join(this.storeDir, "deno-kv-file");
4346
this.index = null;
4447
}
4548

@@ -56,58 +59,74 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
5659

5760
async clear(): Promise<void> {
5861
assert(this.index);
59-
const entries = this.index.list({ prefix: [] });
60-
for await (const entry of entries) {
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));
62+
const index = this.index;
63+
await kvOp(this.indexFile, async () => {
64+
const entries = index.list({ prefix: [] });
65+
for await (const entry of entries) {
66+
const value = entry.value as CacheIndexEntry;
67+
// immediate entries live in the index only; disk entries with
68+
// identical content share one hash file
69+
if (
70+
entry.key[0] !== "version" &&
71+
(value.type === "buffer" || value.type === "string")
72+
) {
73+
const { hash } = value as DiskCacheEntry;
74+
safeRemoveSync(join(this.storeDir, hash));
75+
}
76+
await index.delete(entry.key);
7077
}
71-
await this.index.delete(entry.key);
72-
}
78+
});
7379
}
7480

7581
async createOnDisk(): Promise<void> {
7682
assert(this.index);
77-
this.index.set(["version"], currentCacheVersion);
83+
const index = this.index;
84+
await kvOp(
85+
this.indexFile,
86+
() => index.set(["version"], currentCacheVersion),
87+
);
7888
}
7989

8090
async init(): Promise<void> {
8191
ensureDirSync(this.storeDir);
82-
this.index = await Deno.openKv(join(this.storeDir, "deno-kv-file"));
83-
let version = await this.index.get(["version"]);
84-
if (version.value === null) {
85-
await this.createOnDisk();
86-
version = await this.index.get(["version"]);
87-
}
88-
assert(typeof version.value === "string");
89-
const requiredVersion = requiredQuartoVersions[version.value];
90-
if (
91-
!requiredVersion || !satisfies(quartoConfig.version(), requiredVersion)
92-
) {
93-
console.warn("Unknown project cache version.");
94-
console.warn(
95-
"Project cache was likely created by a newer version of Quarto.",
96-
);
97-
console.warn("Quarto will clear the project cache.");
98-
await this.clear();
99-
await this.createOnDisk();
92+
this.index = await openKv(this.indexFile);
93+
try {
94+
let version = await getKv<string>(this.index, this.indexFile, [
95+
"version",
96+
]);
97+
if (version === null) {
98+
await this.createOnDisk();
99+
version = await getKv<string>(this.index, this.indexFile, ["version"]);
100+
}
101+
assert(typeof version === "string");
102+
const requiredVersion = requiredQuartoVersions[version];
103+
if (
104+
!requiredVersion || !satisfies(quartoConfig.version(), requiredVersion)
105+
) {
106+
console.warn("Unknown project cache version.");
107+
console.warn(
108+
"Project cache was likely created by a newer version of Quarto.",
109+
);
110+
console.warn("Quarto will clear the project cache.");
111+
await this.clear();
112+
await this.createOnDisk();
113+
}
114+
} catch (e) {
115+
this.close();
116+
throw e;
100117
}
101118
}
102119

103120
async addBuffer(key: string[], value: Uint8Array): Promise<void> {
104121
assert(this.index);
105122
const hash = await md5HashBytes(value);
106123
Deno.writeFileSync(join(this.storeDir, hash), value);
107-
const result = await this.index.set(key, {
108-
hash,
109-
type: "buffer",
110-
});
124+
const index = this.index;
125+
const result = await kvOp(this.indexFile, () =>
126+
index.set(key, {
127+
hash,
128+
type: "buffer",
129+
}));
111130
assert(result.ok);
112131
}
113132

@@ -116,38 +135,40 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
116135
const buffer = new TextEncoder().encode(value);
117136
const hash = await md5HashBytes(buffer);
118137
Deno.writeTextFileSync(join(this.storeDir, hash), value);
119-
const result = await this.index.set(key, {
120-
hash,
121-
type: "string",
122-
});
138+
const index = this.index;
139+
const result = await kvOp(this.indexFile, () =>
140+
index.set(key, {
141+
hash,
142+
type: "string",
143+
}));
123144
assert(result.ok);
124145
}
125146

126147
async addSmallBuffer(key: string[], value: Uint8Array): Promise<void> {
127148
assert(this.index);
128-
const result = await this.index.set(key, {
129-
value,
130-
type: "buffer-immediate",
131-
});
149+
const index = this.index;
150+
const result = await kvOp(this.indexFile, () =>
151+
index.set(key, {
152+
value,
153+
type: "buffer-immediate",
154+
}));
132155
assert(result.ok);
133156
}
134157

135158
async addSmallString(key: string[], value: string): Promise<void> {
136159
assert(this.index);
137-
const result = await this.index.set(key, {
138-
value,
139-
type: "string-immediate",
140-
});
160+
const index = this.index;
161+
const result = await kvOp(this.indexFile, () =>
162+
index.set(key, {
163+
value,
164+
type: "string-immediate",
165+
}));
141166
assert(result.ok);
142167
}
143168

144169
async get(key: string[]): Promise<CacheIndexEntry | null> {
145170
assert(this.index);
146-
const kvResult = await this.index.get(key);
147-
if (kvResult.value === null) {
148-
return null;
149-
}
150-
return kvResult.value as CacheIndexEntry;
171+
return await getKv<CacheIndexEntry>(this.index, this.indexFile, key);
151172
}
152173

153174
async _getAs<T>(
@@ -168,7 +189,8 @@ class ProjectCacheImpl implements Cloneable<ProjectCacheImpl> {
168189
console.warn(
169190
`Entry ${result} not found -- clearing entry in index`,
170191
);
171-
await this.index.delete(key);
192+
const index = this.index;
193+
await kvOp(this.indexFile, () => index.delete(key));
172194
return null;
173195
}
174196
}

‎src/core/sass/cache.ts‎

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,21 @@ import { safeRemoveIfExists } from "../path.ts";
1616
import * as log from "../../deno_ral/log.ts";
1717
import { onCleanup } from "../cleanup.ts";
1818
import { Cloneable } from "../safe-clone-deep.ts";
19+
import { getKv, kvOp, openKv } from "../../deno_ral/kv.ts";
1920

2021
class SassCache implements Cloneable<SassCache> {
2122
kv: Deno.Kv;
2223
path: string;
24+
kvFile: string;
2325

2426
clone() {
2527
return this;
2628
}
2729

28-
constructor(kv: Deno.Kv, path: string) {
30+
constructor(kv: Deno.Kv, path: string, kvFile: string) {
2931
this.kv = kv;
3032
this.path = path;
33+
this.kvFile = kvFile;
3134
}
3235

3336
async getFromHash(
@@ -43,22 +46,20 @@ class SassCache implements Cloneable<SassCache> {
4346
throw new InternalError(`Invalid hash length: ${hash.length}`);
4447
}
4548

46-
const result = await this.kv.get(["entry", hash]);
47-
if (result.value === null) {
49+
const value = await getKv<unknown>(this.kv, this.kvFile, ["entry", hash]);
50+
if (value === null) {
4851
log.debug(` cache miss`);
4952
return null;
5053
}
51-
if (typeof result.value !== "object") {
54+
if (typeof value !== "object") {
5255
throw new InternalError(
53-
`Unsupported SassCache entry type\nExpected SassCacheEntry, got ${typeof result
54-
.value}`,
56+
`Unsupported SassCache entry type\nExpected SassCacheEntry, got ${typeof value}`,
5557
);
5658
}
57-
const v = result.value as Record<string, unknown>;
59+
const v = value as Record<string, unknown>;
5860
if (typeof v.key !== "string" || typeof v.hash !== "string") {
5961
throw new InternalError(
60-
`Unsupported SassCache entry type\nExpected SassCacheEntry, got ${typeof result
61-
.value}`,
62+
`Unsupported SassCache entry type\nExpected SassCacheEntry, got ${typeof value}`,
6263
);
6364
}
6465
const outputFilePath = join(this.path, `${hash}.css`);
@@ -99,10 +100,11 @@ class SassCache implements Cloneable<SassCache> {
99100
}
100101
throw error;
101102
}
102-
await this.kv.set(["entry", identifierHash], {
103-
key: cacheIdentifier,
104-
hash: inputHash,
105-
});
103+
await kvOp(this.kvFile, () =>
104+
this.kv.set(["entry", identifierHash], {
105+
key: cacheIdentifier,
106+
hash: inputHash,
107+
}));
106108
return outputFilePath;
107109
}
108110

@@ -174,24 +176,23 @@ const requiredQuartoVersions: Record<number, string> = {
174176
};
175177

176178
async function checkVersion(kv: Deno.Kv, path: string) {
177-
const version = await kv.get(["version"]);
178-
if (version.value === null) {
179-
await kv.set(["version"], 1);
179+
const version = await getKv<unknown>(kv, path, ["version"]);
180+
if (version === null) {
181+
await kvOp(path, () => kv.set(["version"], 1));
180182
} else {
181-
if (typeof version.value !== "number") {
183+
if (typeof version !== "number") {
182184
throw new Error(
183-
`Unsupported SassCache version type in ${path}\nExpected number, got ${typeof version
184-
.value}`,
185+
`Unsupported SassCache version type in ${path}\nExpected number, got ${typeof version}`,
185186
);
186187
}
187-
if (version.value < currentSassCacheVersion) {
188+
if (version < currentSassCacheVersion) {
188189
// in the future we should clean this automatically, but this is v1 and there should be
189190
// no old data anywhere.
190191
throw new Error(
191192
`Found outdated SassCache version. Please clear ${path}.`,
192193
);
193194
}
194-
if (version.value > currentSassCacheVersion) {
195+
if (version > currentSassCacheVersion) {
195196
throw new Error(
196197
`Found a SassCache version that's newer than supported. Please clear ${path} or upgrade Quarto to ${
197198
requiredQuartoVersions[currentSassCacheVersion]
@@ -212,9 +213,14 @@ export async function sassCache(
212213
log.debug(`Creating SassCache at ${storeDir}`);
213214
ensureDirSync(storeDir);
214215
const kvFile = join(storeDir, "sass.kv");
215-
const kv = await Deno.openKv(kvFile);
216-
await checkVersion(kv, kvFile);
217-
_sassCache[path] = new SassCache(kv, storeDir);
216+
const kv = await openKv(kvFile);
217+
try {
218+
await checkVersion(kv, kvFile);
219+
} catch (e) {
220+
kv.close();
221+
throw e;
222+
}
223+
_sassCache[path] = new SassCache(kv, storeDir, kvFile);
218224
// register cleanup for this cache
219225
_sassCache[path].cleanup(temp, path);
220226
}

0 commit comments

Comments
 (0)