Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
537b336
feat(core): add ByteLRU + entrySize helpers
pranavz28 Apr 22, 2026
eed9c32
feat(cli-command): add --max-cache-ram flag (MB units)
pranavz28 Apr 22, 2026
0e5364c
feat(core): extend discovery config schema with maxCacheRam
pranavz28 Apr 22, 2026
9e173ec
feat(core): swap Map for ByteLRU when --max-cache-ram is set
pranavz28 Apr 22, 2026
ff45eb3
feat(core): add warning-at-threshold when --max-cache-ram is unset
pranavz28 Apr 22, 2026
2e26f1a
feat(core): emit cache_eviction_started + cache_summary telemetry
pranavz28 Apr 22, 2026
d1cf2c0
test(core): integration coverage for --max-cache-ram
pranavz28 Apr 22, 2026
6ae0659
docs(core): document --max-cache-ram flag
pranavz28 Apr 22, 2026
dad8a08
fix(core): clamp --max-cache-ram below 25MB instead of failing
pranavz28 Apr 22, 2026
e1d6e1c
fix(core): address PR review (frozen counter, reorder checks, reorder…
pranavz28 Apr 22, 2026
a299cd1
test(core): close remaining coverage gaps
pranavz28 Apr 22, 2026
6a1a3d0
feat(core): spill evicted resources to disk, restore on lookup
pranavz28 Apr 23, 2026
2422b01
fix(core): preserve disk-tier stats for cache_summary telemetry
pranavz28 Apr 24, 2026
8a8d2ca
fix(core): drop dead stats guard in disk-spill end handler
pranavz28 Apr 24, 2026
e1b7b6c
test(core): make DiskSpillStore mkdir-failure tests Windows-portable
pranavz28 Apr 28, 2026
b7cd130
test(core): drop redundant disk-stats integration test
pranavz28 Apr 28, 2026
aeae5da
refactor(core): address review nits — leak fix, dedupe, perms, drop d…
pranavz28 Apr 28, 2026
f92aee6
fix(core): restore strict pre-refactor parity in cache telemetry path
pranavz28 Apr 28, 2026
5699888
test(core): cover sendCacheSummary payload-construction catch
pranavz28 Apr 28, 2026
fcd8373
test(core): assert cache_summary build catch via spy not logger mock
pranavz28 Apr 28, 2026
2874ad8
test(core): force-stop instead of graceful in disk-spill destroy spec
pranavz28 Apr 28, 2026
e41db79
fix(core): guard fireCacheEventSafe microtask chain against unhandled…
pranavz28 Apr 28, 2026
2c02f94
test(core): cover fireCacheEventSafe trailing .catch
pranavz28 Apr 28, 2026
9ed10d0
fix(core): address PR review fixes — RAM promotion, array spill, byte…
pranavz28 Apr 30, 2026
3da1637
test(core): cover byte-lru contentBytes/encode/decode fallthrough bra…
pranavz28 May 4, 2026
5abf631
test(core): cover encode/decodeArrayElement falsy-entry early-return
pranavz28 May 4, 2026
cf428cd
test(core): cover saveResource race-window guard for stale disk entries
pranavz28 May 4, 2026
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
11 changes: 11 additions & 0 deletions packages/cli-command/src/flags.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ export const disableCache = {
group: 'Percy'
};

export const maxCacheRam = {
name: 'max-cache-ram',
description: 'Cap asset-discovery cache memory in MB (e.g. 300)',
env: 'PERCY_MAX_CACHE_RAM',
percyrc: 'discovery.maxCacheRam',
type: 'integer',
parse: Number,
group: 'Percy'
};

export const debug = {
name: 'debug',
description: 'Debug asset discovery and do not upload snapshots',
Expand Down Expand Up @@ -134,6 +144,7 @@ export const DISCOVERY = [
disallowedHostnames,
networkIdleTimeout,
disableCache,
maxCacheRam,
debug
];

Expand Down
1 change: 1 addition & 0 deletions packages/cli-command/test/flags.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe('Built-in flags:', () => {
--disallowed-hostname <hostname> Disallowed hostnames to abort in asset discovery
-t, --network-idle-timeout <ms> Asset discovery network idle timeout
--disable-cache Disable asset discovery caches
--max-cache-ram <integer> Cap asset-discovery cache memory in MB (e.g. 300)
--debug Debug asset discovery and do not upload snapshots
`);

Expand Down
1 change: 1 addition & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The following options can also be defined within a Percy config file
- `requestHeaders` — Request headers used when discovering snapshot assets
- `authorization` — Basic auth `username` and `password` for protected snapshot assets
- `disableCache` — Disable asset caching (**default** `false`)
- `maxCacheRam` — Cap the asset-discovery cache at this many MB (**default** unset/unbounded). When set, least-recently-used resources are evicted to stay within the cap. MB is decimal (1 MB = 1,000,000 bytes), not binary MiB (1,048,576). The cap measures cache body bytes only; process RSS is typically 1.5–2× the cap due to Node's Buffer slab allocator. Values below 25 MB are clamped to 25 MB with a warn log (the per-resource ceiling is 25 MB, so smaller caps would reject every resource). Also settable via the `--max-cache-ram <MB>` CLI flag or the `PERCY_MAX_CACHE_RAM` env var
- `userAgent` — Custom user-agent string used when requesting assets
- `cookies` — Browser cookies to use when requesting assets
- `networkIdleTimeout` — Milliseconds to wait for the network to idle (**default** `100`)
Expand Down
275 changes: 275 additions & 0 deletions packages/core/src/cache/byte-lru.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// Two-tier cache used by asset discovery:
// ByteLRU — byte-budget in-memory LRU; Map insertion order = LRU order.
// DiskSpillStore — on-disk overflow tier. RAM evictions spill here; lookups
// fall back to disk before refetching from origin.
// All operations are synchronous; callers (network intercept, ByteLRU.set)
// cannot yield to the event loop mid-op. Per-entry size is capped at 25MB
// upstream so disk I/O latency is bounded.

import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';

const DEFAULT_PER_ENTRY_OVERHEAD = 512;

export class ByteLRU {
#map = new Map();
#bytes = 0;
#max;
#stats = { hits: 0, misses: 0, evictions: 0, peakBytes: 0 };
onEvict;

constructor(maxBytes, { onEvict } = {}) {
this.#max = maxBytes;
this.onEvict = onEvict;
}

get(key) {
if (!this.#map.has(key)) {
this.#stats.misses++;
return undefined;
}
const rec = this.#map.get(key);
this.#map.delete(key);
this.#map.set(key, rec);
this.#stats.hits++;
return rec.value;
}

set(key, value, size) {
if (!Number.isFinite(size) || size < 0) return false;

// Reject oversize BEFORE touching any existing entry — a failed set on an
// existing key must not evict the prior (valid) entry.
if (this.#max !== undefined && size > this.#max) {
if (this.onEvict) this.onEvict(key, 'too-big', value);
return false;
}

if (this.#map.has(key)) {
this.#bytes -= this.#map.get(key).size;
this.#map.delete(key);
}

this.#map.set(key, { value, size });
this.#bytes += size;
if (this.#bytes > this.#stats.peakBytes) this.#stats.peakBytes = this.#bytes;

while (this.#max !== undefined && this.#bytes > this.#max) {
const oldestKey = this.#map.keys().next().value;
const rec = this.#map.get(oldestKey);
this.#bytes -= rec.size;
this.#map.delete(oldestKey);
this.#stats.evictions++;
if (this.onEvict) this.onEvict(oldestKey, 'lru', rec.value);
}

return true;
}

has(key) { return this.#map.has(key); }

delete(key) {
if (!this.#map.has(key)) return false;
this.#bytes -= this.#map.get(key).size;
return this.#map.delete(key);
}

clear() {
this.#map.clear();
this.#bytes = 0;
}

get size() { return this.#map.size; }
get calculatedSize() { return this.#bytes; }
get stats() { return { ...this.#stats, currentBytes: this.#bytes }; }
}

// Returns the byte length of a resource's content. Buffer.byteLength is used
// for strings so that multi-byte UTF-8 (CJK, emoji) is counted in bytes, not
// JS string units, otherwise the cache budget can drift past its cap.
function contentBytes(content) {
if (content == null) return 0;
if (Buffer.isBuffer(content)) return content.length;
if (typeof content === 'string') return Buffer.byteLength(content);
return content.length ?? 0;
}

// Handles the two Percy cache-entry shapes: single resource, or array of
// roots captured at multiple widths (see discovery.js parseDomResources).
export function entrySize(resource, overhead = DEFAULT_PER_ENTRY_OVERHEAD) {
if (Array.isArray(resource)) {
return resource.reduce((n, r) => n + contentBytes(r?.content) + overhead, 0);
}
return contentBytes(resource?.content) + overhead;
}

// Multi-width root arrays carry per-element binary content. Encode buffers as
// base64 inside JSON so the whole array survives a disk roundtrip; null and
// string content pass through as themselves.
function encodeArrayElement(r) {
if (!r) return r;
const { content, ...rest } = r;
if (content == null) return { ...rest, content: null };
if (Buffer.isBuffer(content)) return { ...rest, content: { __buf: content.toString('base64') } };
return { ...rest, content: String(content) };
}

function decodeArrayElement(r) {
if (!r) return r;
const { content, ...rest } = r;
if (content && typeof content === 'object' && '__buf' in content) {
return { ...rest, content: Buffer.from(content.__buf, 'base64') };
}
return { ...rest, content };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: entrySize uses content.length which undercounts bytes for multi-byte string content

content.length on a string returns character count, not byte count. For multi-byte UTF-8 content (CJK, emoji), '\u{1F600}'.length === 2 but Buffer.byteLength('\u{1F600}') === 4.

If content arrives as a string (which is possible — DiskSpillStore.set explicitly handles !Buffer.isBuffer(content)), byte budget accounting will undercount, potentially allowing the cache to exceed the RAM cap.

Consider Buffer.byteLength(content) or assert that content is always a Buffer at this point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ed10d0dentrySize now uses Buffer.byteLength(content) for string content via a shared contentBytes helper. Test asserts entrySize({ content: "\u{1F600}" }, 0) === 4 (4 UTF-8 bytes, not 2 string units) and the same for CJK.


export class DiskSpillStore {
#index = new Map();
#bytes = 0;
#peakBytes = 0;
#stats = { spilled: 0, restored: 0, spillFailures: 0, readFailures: 0 };
#counter = 0;
#ready = false;

constructor(dir, { log } = {}) {
this.dir = dir;
this.log = log;
try {
// mode 0o700: spilled bytes are origin-fetchable so the threat model is
// small, but on shared-tenant CI hosts other users on the same box
// shouldn't be able to read them.
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
this.#ready = true;
} catch (err) {
this.log?.debug?.(`disk-spill init failed for ${dir}: ${err.message}`);
}
}

// Returns true on success; false on any failure so caller falls back to drop.
// Overwrites prior spill for the same URL — a fresh discovery write wins.
// Two resource shapes are supported: a single resource with a binary
// .content, and a multi-width root array (see entrySize for the array
// shape). Arrays are JSON-encoded with base64 buffers so the whole array
// survives the disk roundtrip.
set(url, resource) {
if (!this.#ready) return false;

let bytes;
let meta;
let isArray = false;

if (Array.isArray(resource)) {
isArray = true;
try {
bytes = Buffer.from(JSON.stringify(resource.map(encodeArrayElement)));
} catch { return false; }
} else {
let content = resource?.content;
if (content == null) return false;
if (!Buffer.isBuffer(content)) {
try { content = Buffer.from(content); } catch { return false; }
}
bytes = content;
meta = { ...resource };
delete meta.content;
}

// Counter-based filename keeps URL-derived data out of path.join —
// avoids any path-traversal surface even though sha256 would be safe.
const filepath = path.join(this.dir, String(++this.#counter));

try {
fs.writeFileSync(filepath, bytes);
} catch (err) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Sync writeFileSync on the eviction hot path

When the cache is full, every ByteLRU.set() in the eviction while loop triggers a synchronous disk write here. For builds with thousands of resources and a tight cap, this serializes disk I/O on the Node event loop.

The 25MB per-entry ceiling bounds the worst case for a single write, but aggregate across a batch of evictions can add up. Worth monitoring post-deploy via cache_summary telemetry — if disk_spilled_count is high and builds are slow, async writes with a queue would be the natural next step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — tracked in the PR rollout plan. disk_spilled_count and disk_peak_bytes already ride the cache_summary event; if the post-deploy dashboard shows high spill counts on slow builds, the natural next step is an async write queue. Keeping sync for now to bound semantics.

this.#stats.spillFailures++;
this.log?.debug?.(`disk-spill write failed for ${url}: ${err.message}`);
return false;
}

if (this.#index.has(url)) {
const prev = this.#index.get(url);
this.#bytes -= prev.size;
try { fs.unlinkSync(prev.path); } catch { /* best-effort */ }
}

this.#index.set(url, { path: filepath, size: bytes.length, isArray, meta });
this.#bytes += bytes.length;
if (this.#bytes > this.#peakBytes) this.#peakBytes = this.#bytes;
this.#stats.spilled++;
return true;
}

get(url) {
const entry = this.#index.get(url);
if (!entry) return undefined;
let raw;
try {
raw = fs.readFileSync(entry.path);
} catch (err) {
this.#stats.readFailures++;
this.log?.debug?.(`disk-spill read failed for ${url}: ${err.message}`);
this.#removeEntry(url, entry);
return undefined;
}
if (entry.isArray) {
let arr;
try {
arr = JSON.parse(raw.toString('utf8')).map(decodeArrayElement);
} catch (err) {
this.#stats.readFailures++;
this.log?.debug?.(`disk-spill array-decode failed for ${url}: ${err.message}`);
this.#removeEntry(url, entry);
return undefined;
}
this.#stats.restored++;
return arr;
}
this.#stats.restored++;
return { ...entry.meta, content: raw };
}

has(url) { return this.#index.has(url); }

delete(url) {
const entry = this.#index.get(url);
if (!entry) return false;
this.#removeEntry(url, entry);
return true;
}

destroy() {
try {
if (this.#ready) fs.rmSync(this.dir, { recursive: true, force: true });
} catch (err) {
this.log?.debug?.(`disk-spill cleanup failed for ${this.dir}: ${err.message}`);
}
this.#index.clear();
this.#bytes = 0;
this.#ready = false;
}

get size() { return this.#index.size; }
get bytes() { return this.#bytes; }
get ready() { return this.#ready; }
get stats() {
return {
...this.#stats,
currentBytes: this.#bytes,
peakBytes: this.#peakBytes,
entries: this.#index.size
};
}

#removeEntry(url, entry) {
this.#bytes -= entry.size;
this.#index.delete(url);
try { fs.unlinkSync(entry.path); } catch { /* best-effort */ }
}
}

export function createSpillDir() {
const suffix = `${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
return path.join(os.tmpdir(), `percy-cache-${suffix}`);
}
7 changes: 7 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,13 @@ export const configSchema = {
disableCache: {
type: 'boolean'
},
maxCacheRam: {
// 0 has no meaningful semantics — it's neither "unbounded" (use null)
// nor "disabled" (use --disable-cache). Reject it at schema time so the
// discovery clamp doesn't silently bump it to 25MB.
type: ['integer', 'null'],
minimum: 1
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: Schema allows maxCacheRam: 0

minimum: 0 means maxCacheRam: 0 passes validation. The clamp in discovery.js catches it (bumps to 25MB), but users might expect 0 to mean "unbounded" or "disabled" rather than "25MB cache." Consider minimum: 1 or explicitly treating 0 as null/unbounded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ed10d0d — schema minimum bumped from 0 to 1. Zero has no useful semantics (null = unbounded, --disable-cache = off), and rejecting at schema time avoids the silent clamp to 25MB.

captureMockedServiceWorker: {
type: 'boolean',
default: false
Expand Down
Loading
Loading