Skip to content
Open
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
55 changes: 42 additions & 13 deletions src/cache/cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import stream from 'node:stream';
import string_decoder from 'node:string_decoder';
import zlib from 'node:zlib';

import { E_CANCELED, Mutex } from 'async-mutex';
Expand Down Expand Up @@ -262,26 +263,46 @@ export default class Cache<V> {
}

try {
const chunks: Buffer[] = [];
// Parse the cache file incrementally, one newline-delimited [key, value] record at a time.
// Never rebuild the whole file into a single string: on large collections the serialized
// cache can exceed V8's maximum string length and throw a `RangeError`.
const decoder = new string_decoder.StringDecoder('utf8');
const map = new Map<string, V>();
let buffer = '';
const readState = { hasData: false };
const ingestLine = (line: string): void => {
if (line.length === 0) {
return;
}
const entry = JSON.parse(line) as unknown;
if (Array.isArray(entry) && entry.length === 2) {
const [key, value] = entry as [string, V];
map.set(key, value);
}
};
await stream.promises.pipeline(
fs.createReadStream(this.filePath),
zlib.createGunzip(),
new stream.Writable({
write(chunk: Buffer, _enc: BufferEncoding, cb: () => void): void {
chunks.push(chunk);
readState.hasData = true;
buffer += decoder.write(chunk);
let idx = buffer.indexOf('\n');
while (idx !== -1) {
ingestLine(buffer.slice(0, idx));
buffer = buffer.slice(idx + 1);
idx = buffer.indexOf('\n');
}
cb();
},
}),
);
if (chunks.length === 0) {
buffer += decoder.end();
ingestLine(buffer);
if (!readState.hasData) {
return this;
}
const keyValuesObject = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<
string,
V
>;
const keyValuesEntries = Object.entries(keyValuesObject);
this.keyValues = new Map(keyValuesEntries);
this.keyValues = map;
} catch {
/* ignored */
}
Expand Down Expand Up @@ -324,8 +345,7 @@ export default class Cache<V> {
return;
}

const keyValuesObject = Object.fromEntries(this.keyValues);
const json = JSON.stringify(keyValuesObject);
const entries = [...this.keyValues];
// Reset before I/O so mid-save changes re-set the flag
this.hasChanged = false;

Expand All @@ -338,15 +358,24 @@ export default class Cache<V> {
// Write to a temp file first
const tempFile = await FsUtil.mktemp(this.filePath);
try {
// Stream one newline-delimited [key, value] record at a time. This avoids serializing
// the entire cache into a single string, which can exceed V8's maximum string length
// and throw a `RangeError` on large collections.
await stream.promises.pipeline(
stream.Readable.from([Buffer.from(json, 'utf8')]),
stream.Readable.from(
(function* (): Generator<Buffer> {
for (const entry of entries) {
yield Buffer.from(`${JSON.stringify(entry)}\n`, 'utf8');
}
})(),
),
zlib.createGzip(),
fs.createWriteStream(tempFile),
);

// Validate the file was written correctly
const tempFileCache = await new Cache({ filePath: tempFile }).load();
if (tempFileCache.size() !== Object.keys(keyValuesObject).length) {
if (tempFileCache.size() !== entries.length) {
// The written file is bad, don't use it
await FsUtil.rm(tempFile, { force: true });
this.hasChanged = true;
Expand Down
92 changes: 92 additions & 0 deletions test/cache/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';

import Cache from '../../src/cache/cache.js';
import Temp from '../../src/globals/temp.js';
Expand Down Expand Up @@ -243,6 +245,96 @@ describe('save', () => {
});
});

describe('save and load', () => {
it('should round-trip values containing newlines, quotes, and backslashes', async () => {
// Records are newline-delimited, so any newlines inside a value must survive a save+load.
const tempFile = await FsUtil.mktemp(path.join(Temp.getTempDir(), 'cache'));

const values = new Map<string, string>([
['newlines', 'line1\nline2\r\nline3\n'],
['quotes', 'has "double" and \'single\' quotes'],
['backslashes', 'c:\\path\\to\\file'],
['looks-like-record', '["not","a","real","entry"]'],
['empty', ''],
]);

const firstCache = new Cache<string>({ filePath: tempFile });
for (const [key, value] of values) {
await firstCache.set(key, value);
}
await firstCache.save();

try {
const secondCache = new Cache<string>({ filePath: tempFile });
await secondCache.load();
expect(secondCache.size()).toEqual(values.size);
for (const [key, value] of values) {
await expect(secondCache.get(key)).resolves.toEqual(value);
}
} finally {
await FsUtil.rm(tempFile, { force: true });
}
});

it('should ignore a legacy single-object-format cache file', async () => {
// A cache file that is a single gzipped JSON object, rather than newline-delimited records,
// is not parseable as records. It is ignored gracefully — the cache simply rebuilds itself —
// instead of throwing.
const tempFile = await FsUtil.mktemp(path.join(Temp.getTempDir(), 'cache'));
const legacy = zlib.gzipSync(Buffer.from(JSON.stringify({ a: 1, b: 2, c: 3 }), 'utf8'));
await FsUtil.writeFile(tempFile, legacy);

try {
const cache = new Cache<number>({ filePath: tempFile });
await cache.load();
expect(cache.size()).toEqual(0);
} finally {
await FsUtil.rm(tempFile, { force: true });
}
});

it('should save each entry as its own newline-delimited record', async () => {
// The fix for large caches is to serialize one newline-delimited [key, value] record per
// entry and stream them, instead of building a single `JSON.stringify()` string of the whole
// cache (which throws `RangeError: Invalid string length` once it would exceed V8's ~512 MiB
// maximum string length). This asserts the on-disk format is one record per entry, so no
// single string is ever built from the entire cache.
const entryCount = 1000;
const tempFile = await FsUtil.mktemp(path.join(Temp.getTempDir(), 'cache'));

const firstCache = new Cache<number>({ filePath: tempFile });
for (let i = 0; i < entryCount; i += 1) {
await firstCache.set(String(i), i);
}
await firstCache.save();

try {
// The file is gzipped newline-delimited JSON: one [key, value] array per line.
const lines = zlib
.gunzipSync(await fs.promises.readFile(tempFile))
.toString('utf8')
.split('\n')
.filter((line) => line.length > 0);
expect(lines).toHaveLength(entryCount);
for (const line of lines) {
const record = JSON.parse(line) as unknown;
expect(Array.isArray(record)).toEqual(true);
expect((record as unknown[]).length).toEqual(2);
}

// ...and it round-trips back into an equivalent cache.
const secondCache = new Cache<number>({ filePath: tempFile });
await secondCache.load();
expect(secondCache.size()).toEqual(entryCount);
for (let i = 0; i < entryCount; i += 1) {
await expect(secondCache.get(String(i))).resolves.toEqual(i);
}
} finally {
await FsUtil.rm(tempFile, { force: true });
}
});
});

describe('getOrComputeAllKeys', () => {
it('should compute all values when all keys are missing', async () => {
const cache = new Cache<number>();
Expand Down
Loading