cache-verdict 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +311 -2
- package/bin/cache-verdict.mjs +383 -0
- package/examples/npm-build-cache/README.md +95 -0
- package/examples/npm-build-cache/build.mjs +25 -0
- package/examples/npm-build-cache/package.json +10 -0
- package/examples/npm-build-cache/src/input.txt +5 -0
- package/examples/run.mjs +63 -0
- package/lib/config.mjs +141 -0
- package/lib/detect.mjs +647 -0
- package/lib/glob.mjs +192 -0
- package/lib/key.mjs +52 -0
- package/lib/ledger.mjs +357 -0
- package/lib/report.mjs +144 -0
- package/lib/run.mjs +204 -0
- package/package.json +41 -4
package/lib/report.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
function median(values) {
|
|
2
|
+
if (values.length === 0) return null;
|
|
3
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
4
|
+
const middle = Math.floor(sorted.length / 2);
|
|
5
|
+
if (sorted.length % 2 === 1) return sorted[middle];
|
|
6
|
+
return (sorted[middle - 1] + sorted[middle]) / 2;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function finite(value) {
|
|
10
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sampleTiming(sample) {
|
|
14
|
+
const buildMs = finite(sample.buildMs);
|
|
15
|
+
const restoreMs = finite(sample.restoreMs);
|
|
16
|
+
const totalMs = finite(sample.totalMs) ?? (buildMs !== null && restoreMs !== null ? buildMs + restoreMs : null);
|
|
17
|
+
return {
|
|
18
|
+
buildMs,
|
|
19
|
+
restoreMs,
|
|
20
|
+
totalMs,
|
|
21
|
+
hit: sample.hit === true || sample.temperature === 'warm',
|
|
22
|
+
key: typeof sample.key === 'string' ? sample.key : 'unknown',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function round(value) {
|
|
27
|
+
return value === null ? null : Math.round(value * 1000) / 1000;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Turn ledger samples into the numbers and single verdict the report prints.
|
|
32
|
+
*
|
|
33
|
+
* @param {Array<object>} samples samples read from the JSONL ledger
|
|
34
|
+
* @param {{ hasCachePaths?: boolean, storedKeys?: number, storedSizeBytes?: number }} [context]
|
|
35
|
+
*/
|
|
36
|
+
export function analyzeSamples(samples, context = {}) {
|
|
37
|
+
const hasCachePaths = context.hasCachePaths !== false;
|
|
38
|
+
const observations = samples.map(sampleTiming).filter((sample) => sample.totalMs !== null);
|
|
39
|
+
|
|
40
|
+
const cold = observations.filter((sample) => !sample.hit);
|
|
41
|
+
const warm = observations.filter((sample) => sample.hit);
|
|
42
|
+
const keys = new Set(observations.map((sample) => sample.key));
|
|
43
|
+
|
|
44
|
+
const coldTotals = cold.map((sample) => sample.totalMs);
|
|
45
|
+
const warmTotals = warm.map((sample) => sample.totalMs);
|
|
46
|
+
const restoreCosts = warm
|
|
47
|
+
.map((sample) => sample.restoreMs)
|
|
48
|
+
.filter((value) => value !== null);
|
|
49
|
+
|
|
50
|
+
const medianColdMs = median(coldTotals);
|
|
51
|
+
const medianWarmMs = median(warmTotals);
|
|
52
|
+
const restoreMs = median(restoreCosts);
|
|
53
|
+
const netMsPerHit =
|
|
54
|
+
medianColdMs !== null && medianWarmMs !== null ? medianColdMs - medianWarmMs : null;
|
|
55
|
+
|
|
56
|
+
let verdict;
|
|
57
|
+
let moreRunsNeeded = false;
|
|
58
|
+
let reason;
|
|
59
|
+
|
|
60
|
+
if (!hasCachePaths) {
|
|
61
|
+
verdict = 'cache-unobserved';
|
|
62
|
+
reason = 'cachePath is empty, so there is nothing to measure';
|
|
63
|
+
} else if (observations.length === 0) {
|
|
64
|
+
verdict = 'cache-unobserved';
|
|
65
|
+
moreRunsNeeded = true;
|
|
66
|
+
reason = 'no samples have been recorded yet; run the build once';
|
|
67
|
+
} else if (warm.length === 0) {
|
|
68
|
+
if (observations.length < 2) {
|
|
69
|
+
verdict = 'cache-unobserved';
|
|
70
|
+
moreRunsNeeded = true;
|
|
71
|
+
reason = 'one cold sample is not enough; run again to collect a warm sample';
|
|
72
|
+
} else {
|
|
73
|
+
verdict = 'cache-never-hits';
|
|
74
|
+
reason = `all ${observations.length} samples missed the cache`;
|
|
75
|
+
}
|
|
76
|
+
} else if (cold.length === 0) {
|
|
77
|
+
verdict = 'cache-unobserved';
|
|
78
|
+
moreRunsNeeded = true;
|
|
79
|
+
reason = 'no cold baseline has been recorded; add one with "cache-verdict run --cold"';
|
|
80
|
+
} else if (keys.size > 1) {
|
|
81
|
+
verdict = 'cache-churns';
|
|
82
|
+
reason = `${keys.size} distinct keys across ${observations.length} samples; the cache key changes too often to accumulate hits`;
|
|
83
|
+
} else if (netMsPerHit !== null && netMsPerHit > 0) {
|
|
84
|
+
verdict = 'cache-pays';
|
|
85
|
+
reason = `warm builds are ${netMsPerHit.toFixed(1)} ms faster per hit after paying the restore cost`;
|
|
86
|
+
} else {
|
|
87
|
+
verdict = 'cache-costs';
|
|
88
|
+
reason = `a warm build is not faster than cold (${netMsPerHit?.toFixed(1) ?? 'unknown'} ms per hit)`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
runs: observations.length,
|
|
93
|
+
hits: warm.length,
|
|
94
|
+
misses: cold.length,
|
|
95
|
+
hitRate: observations.length === 0 ? null : warm.length / observations.length,
|
|
96
|
+
coldRuns: cold.length,
|
|
97
|
+
warmRuns: warm.length,
|
|
98
|
+
uniqueKeys: keys.size,
|
|
99
|
+
medianColdMs: round(medianColdMs),
|
|
100
|
+
medianWarmMs: round(medianWarmMs),
|
|
101
|
+
restoreMs: round(restoreMs),
|
|
102
|
+
netMsPerHit: round(netMsPerHit),
|
|
103
|
+
storedKeys: context.storedKeys ?? 0,
|
|
104
|
+
cacheSizeBytes: context.storedSizeBytes ?? 0,
|
|
105
|
+
verdict,
|
|
106
|
+
moreRunsNeeded,
|
|
107
|
+
reason,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatMs(value) {
|
|
112
|
+
return value === null || value === undefined ? 'n/a' : `${value.toFixed(1)} ms`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatBytes(value) {
|
|
116
|
+
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
|
117
|
+
if (value < 1024) return `${value} B`;
|
|
118
|
+
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
|
119
|
+
let scaled = value;
|
|
120
|
+
let unit = -1;
|
|
121
|
+
do {
|
|
122
|
+
scaled /= 1024;
|
|
123
|
+
unit += 1;
|
|
124
|
+
} while (scaled >= 1024 && unit < units.length - 1);
|
|
125
|
+
return `${scaled.toFixed(1)} ${units[unit]}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatReport(analysis) {
|
|
129
|
+
const hitRate =
|
|
130
|
+
analysis.hitRate === null ? 'n/a' : `${(analysis.hitRate * 100).toFixed(1)}%`;
|
|
131
|
+
const storedKeys = analysis.storedKeys === 1 ? 'stored key' : 'stored keys';
|
|
132
|
+
const lines = [
|
|
133
|
+
`Runs: ${analysis.runs} (${analysis.hits} hit${analysis.hits === 1 ? '' : 's'}, ${analysis.misses} miss${analysis.misses === 1 ? '' : 'es'})`,
|
|
134
|
+
`Hit rate: ${hitRate}`,
|
|
135
|
+
`Median cold total: ${formatMs(analysis.medianColdMs)}`,
|
|
136
|
+
`Median warm total: ${formatMs(analysis.medianWarmMs)}`,
|
|
137
|
+
`Restore cost: ${formatMs(analysis.restoreMs)}`,
|
|
138
|
+
`Net ms per hit: ${formatMs(analysis.netMsPerHit)}`,
|
|
139
|
+
`Cache size: ${formatBytes(analysis.cacheSizeBytes)} across ${analysis.storedKeys} ${storedKeys}`,
|
|
140
|
+
`Verdict: ${analysis.verdict}`,
|
|
141
|
+
];
|
|
142
|
+
if (analysis.reason) lines.push(`Reason: ${analysis.reason}`);
|
|
143
|
+
return `${lines.join('\n')}\n`;
|
|
144
|
+
}
|
package/lib/run.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { loadConfig } from './config.mjs';
|
|
6
|
+
import { resolvePathPatterns } from './glob.mjs';
|
|
7
|
+
import { computeInputKey } from './key.mjs';
|
|
8
|
+
import {
|
|
9
|
+
appendLedgerSample,
|
|
10
|
+
ensureWorkDirectory,
|
|
11
|
+
movePath,
|
|
12
|
+
pathExists,
|
|
13
|
+
pathSize,
|
|
14
|
+
readStoredCache,
|
|
15
|
+
removePath,
|
|
16
|
+
restoreStoredCache,
|
|
17
|
+
storeCache,
|
|
18
|
+
storeKeyDirectory,
|
|
19
|
+
tempDirectory,
|
|
20
|
+
touchStoredCache,
|
|
21
|
+
workDirectory,
|
|
22
|
+
} from './ledger.mjs';
|
|
23
|
+
|
|
24
|
+
function nowMs() {
|
|
25
|
+
return Number(process.hrtime.bigint()) / 1e6;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function roundMs(value) {
|
|
29
|
+
return Math.round(value * 1000) / 1000;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertSafeTarget(root, target) {
|
|
33
|
+
const project = path.resolve(root);
|
|
34
|
+
const resolved = path.resolve(target);
|
|
35
|
+
const internal = path.resolve(workDirectory(project));
|
|
36
|
+
|
|
37
|
+
if (resolved === project) {
|
|
38
|
+
throw new Error('cachePath must not point at the project root');
|
|
39
|
+
}
|
|
40
|
+
const projectInsideTarget = path.relative(resolved, project);
|
|
41
|
+
if (
|
|
42
|
+
projectInsideTarget !== '' &&
|
|
43
|
+
!projectInsideTarget.startsWith('..') &&
|
|
44
|
+
!path.isAbsolute(projectInsideTarget)
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`cachePath ${resolved} contains the project itself`);
|
|
47
|
+
}
|
|
48
|
+
if (resolved === internal || resolved.startsWith(`${internal}${path.sep}`)) {
|
|
49
|
+
throw new Error(`cachePath ${resolved} is inside cache-verdict's own work directory`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveTargets(root, config, options = {}) {
|
|
54
|
+
const resolved = resolvePathPatterns(root, config.cachePath, options);
|
|
55
|
+
for (const target of resolved.targets) assertSafeTarget(root, target);
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function moveWorkingTargets(targets, stashRoot) {
|
|
60
|
+
const moved = [];
|
|
61
|
+
try {
|
|
62
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
63
|
+
const target = targets[index];
|
|
64
|
+
if (!pathExists(target)) continue;
|
|
65
|
+
const stash = path.join(stashRoot, String(index));
|
|
66
|
+
movePath(target, stash);
|
|
67
|
+
moved.push({ target, stash });
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
for (const entry of [...moved].reverse()) movePath(entry.stash, entry.target);
|
|
71
|
+
removePath(stashRoot);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
return moved;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function restoreWorkingTargets(root, config, moved, stashRoot) {
|
|
78
|
+
const produced = resolveTargets(root, config, { existingOnly: true }).targets;
|
|
79
|
+
const current = new Set([...moved.map((entry) => entry.target), ...produced]);
|
|
80
|
+
for (const target of current) removePath(target);
|
|
81
|
+
for (const entry of moved) {
|
|
82
|
+
if (pathExists(entry.stash)) movePath(entry.stash, entry.target);
|
|
83
|
+
}
|
|
84
|
+
removePath(stashRoot);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Take one timed sample. Existing cache directories are moved aside first, a
|
|
89
|
+
* stored cache for this exact key (if any) is restored, the build runs once,
|
|
90
|
+
* and the produced artifacts are stored for the next identical key.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} root absolute project directory
|
|
93
|
+
* @param {{ cold?: boolean }} [options]
|
|
94
|
+
*/
|
|
95
|
+
export function runSample(root, options = {}) {
|
|
96
|
+
const directory = path.resolve(root);
|
|
97
|
+
const config = loadConfig(directory);
|
|
98
|
+
const forceCold = options.cold === true;
|
|
99
|
+
const unresolvedBefore = resolveTargets(directory, config);
|
|
100
|
+
if (config.cachePath.length > 0 && unresolvedBefore.targets.length === 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`cannot resolve any cachePath entries (${unresolvedBefore.unresolved.join(', ') || config.cachePath.join(', ')}); set the referenced environment variables or edit cachePath`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { key } = computeInputKey(directory, config);
|
|
107
|
+
const before = resolveTargets(directory, config, { existingOnly: true }).targets;
|
|
108
|
+
|
|
109
|
+
ensureWorkDirectory(directory);
|
|
110
|
+
const stashRoot = path.join(
|
|
111
|
+
tempDirectory(directory),
|
|
112
|
+
`${Date.now()}-${process.pid}-${randomUUID()}`,
|
|
113
|
+
);
|
|
114
|
+
const moved = moveWorkingTargets(before, stashRoot);
|
|
115
|
+
|
|
116
|
+
let hit = false;
|
|
117
|
+
let restoreMs = 0;
|
|
118
|
+
let restoreBytes = 0;
|
|
119
|
+
let buildMs = 0;
|
|
120
|
+
let produced = [];
|
|
121
|
+
let sizeBytes = 0;
|
|
122
|
+
let stored = false;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
if (!forceCold) {
|
|
126
|
+
const storedCache = readStoredCache(directory, key);
|
|
127
|
+
if (storedCache !== null) {
|
|
128
|
+
const restoreStart = nowMs();
|
|
129
|
+
const restored = restoreStoredCache(directory, storedCache);
|
|
130
|
+
restoreMs = nowMs() - restoreStart;
|
|
131
|
+
restoreBytes = restored.restoredBytes;
|
|
132
|
+
hit = true;
|
|
133
|
+
touchStoredCache(directory, storedCache);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const buildStart = nowMs();
|
|
138
|
+
const result = spawnSync(config.command, {
|
|
139
|
+
cwd: directory,
|
|
140
|
+
shell: true,
|
|
141
|
+
stdio: 'inherit',
|
|
142
|
+
});
|
|
143
|
+
buildMs = nowMs() - buildStart;
|
|
144
|
+
|
|
145
|
+
if (result.error) throw result.error;
|
|
146
|
+
const status = result.status ?? 1;
|
|
147
|
+
if (status !== 0) {
|
|
148
|
+
throw new Error(`build command exited with status ${status}: ${config.command}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
produced = resolveTargets(directory, config, { existingOnly: true }).targets;
|
|
152
|
+
sizeBytes = produced.reduce((total, target) => total + pathSize(target), 0);
|
|
153
|
+
|
|
154
|
+
if (produced.length > 0) {
|
|
155
|
+
storeCache(directory, key, produced);
|
|
156
|
+
stored = true;
|
|
157
|
+
} else if (!forceCold) {
|
|
158
|
+
// The build used to produce artifacts for this key but no longer does.
|
|
159
|
+
removePath(storeKeyDirectory(directory, key));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const totalMs = restoreMs + buildMs;
|
|
163
|
+
const sample = {
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
key,
|
|
167
|
+
hit,
|
|
168
|
+
miss: !hit,
|
|
169
|
+
cold: !hit,
|
|
170
|
+
warm: hit,
|
|
171
|
+
temperature: hit ? 'warm' : 'cold',
|
|
172
|
+
forcedCold: forceCold,
|
|
173
|
+
buildMs: roundMs(buildMs),
|
|
174
|
+
restoreMs: roundMs(restoreMs),
|
|
175
|
+
restoreCostMs: roundMs(restoreMs),
|
|
176
|
+
totalMs: roundMs(totalMs),
|
|
177
|
+
sizeBytes,
|
|
178
|
+
cacheSizeBytes: sizeBytes,
|
|
179
|
+
restoreSizeBytes: restoreBytes,
|
|
180
|
+
stored,
|
|
181
|
+
command: config.command,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
appendLedgerSample(directory, sample);
|
|
185
|
+
removePath(stashRoot);
|
|
186
|
+
return sample;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
restoreWorkingTargets(directory, config, moved, stashRoot);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatRunSummary(sample, config) {
|
|
194
|
+
const temperature = sample.hit ? 'HIT (warm)' : 'MISS (cold)';
|
|
195
|
+
return [
|
|
196
|
+
`cache-verdict: ${temperature}`,
|
|
197
|
+
` key: ${sample.key}`,
|
|
198
|
+
` command: ${config.command}`,
|
|
199
|
+
` restore: ${sample.restoreMs.toFixed(1)} ms`,
|
|
200
|
+
` build: ${sample.buildMs.toFixed(1)} ms`,
|
|
201
|
+
` total: ${sample.totalMs.toFixed(1)} ms`,
|
|
202
|
+
` size: ${sample.sizeBytes} bytes${sample.stored ? '' : ' (no cache artifacts stored)'}`,
|
|
203
|
+
].join('\n');
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cache-verdict",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Times build-cache cold vs warm runs and says whether the cache pays",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"build-cache",
|
|
8
|
+
"cache",
|
|
9
|
+
"ci",
|
|
10
|
+
"cold-start",
|
|
11
|
+
"benchmark",
|
|
12
|
+
"cargo",
|
|
13
|
+
"gradle",
|
|
14
|
+
"nextjs",
|
|
15
|
+
"npm",
|
|
16
|
+
"python",
|
|
17
|
+
"go"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"cache-verdict": "./bin/cache-verdict.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"lib",
|
|
26
|
+
"examples"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test",
|
|
30
|
+
"example": "node examples/run.mjs"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/WJDdidi-1/cache-verdict.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/WJDdidi-1/cache-verdict#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/WJDdidi-1/cache-verdict/issues"
|
|
42
|
+
}
|
|
43
|
+
}
|