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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,9 @@ examples/wp-theme-unit-test/

.perf-query-counts
query-counts-out/

# Generated query-dump artefacts; tooling lives in scripts/query-dumps/ but
# the per-run JSON and classification reports regenerate from the harness.
scripts/query-dumps/sqlite/
scripts/query-dumps/d1/
scripts/query-dumps/classification.*.md
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"**/*.d.ts",
"skills/**/scaffold/**",
".agents/skills/**/scaffold/**",
".claude/skills/**/scaffold/**"
".claude/skills/**/scaffold/**",
"scripts/query-dumps/**"
]
}
16 changes: 16 additions & 0 deletions scripts/build-perf-d1.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const fixtureDir = resolve(__dirname, "..", "fixtures/perf-site");

const r = spawnSync("pnpm", ["exec", "astro", "build"], {
cwd: fixtureDir,
stdio: "inherit",
env: { ...process.env, EMDASH_FIXTURE_TARGET: "d1" },
});
if (r.status !== 0) process.exit(r.status ?? 1);
writeFileSync(resolve(fixtureDir, "dist/.perf-target"), "d1\n");
232 changes: 232 additions & 0 deletions scripts/query-counts-dump.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* Sibling of scripts/query-counts.mjs that dumps raw query events to JSON
* files under scripts/query-dumps/{target}/{routeSlug}.{phase}.json
*
* Each file is an array of { sql, params, durationMs, route, method, phase }.
* The harness assumes the fixture is already built and seeded -- we only
* spin servers, hit routes, and partition events. For sqlite, the main
* `query-counts.mjs --target sqlite` flow builds and seeds; for d1, run
* `query-counts.mjs --target d1` once first (or `build-perf-d1.mjs` to
* build only) so wrangler state and dist/ exist.
*
* The dump JSON itself is gitignored — it's an analysis artifact that
* regenerates from the harness in seconds. The helper scripts in
* `query-dumps/` (classify.mjs, cold-only.mjs, inspect-other.mjs) are
* the things worth keeping in source.
*/

import { spawn } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { createConnection } from "node:net";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const fixtureDir = resolve(repoRoot, "fixtures/perf-site");
const dumpsDir = resolve(__dirname, "query-dumps");

const HOST = "127.0.0.1";
const PORT = 14322;
const BASE = `http://${HOST}:${PORT}`;
const QUERY_LOG_PREFIX = "[emdash-query-log] ";

const ROUTES = [
["GET", "/"],
["GET", "/posts"],
["GET", "/posts/building-for-the-long-term"],
["GET", "/pages/about"],
["GET", "/category/development"],
["GET", "/tag/webdev"],
["GET", "/rss.xml"],
["GET", "/search?q=static"],
];

function parseArgs(argv) {
const out = { target: "sqlite", routesOnly: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--target") out.target = argv[++i];
else if (a.startsWith("--target=")) out.target = a.slice("--target=".length);
else if (a === "--routes") out.routesOnly = argv[++i].split(",");
}
if (out.target !== "sqlite" && out.target !== "d1") {
throw new Error(`bad --target ${out.target}`);
}
return out;
Comment on lines +47 to +58

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

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

parseArgs() silently ignores unknown CLI flags. That makes it easy to run the dump harness with a misspelled/unsupported option and get partial or misleading output without noticing. Consider matching scripts/query-counts.mjs by throwing on unknown arguments (and also validating that --routes has a following value) so failures are explicit.

Copilot uses AI. Check for mistakes.
}

const { target, routesOnly } = parseArgs(process.argv.slice(2));

function waitForPort(host, port, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolveReady, rejectReady) => {
const attempt = () => {
if (Date.now() > deadline) {
rejectReady(new Error(`port ${host}:${port} did not open within ${timeoutMs}ms`));
return;
}
const socket = createConnection({ host, port });
socket.once("connect", () => {
socket.destroy();
resolveReady();
});
socket.once("error", () => {
socket.destroy();
setTimeout(attempt, 100);
});
};
attempt();
});
}

function startServer(events) {
let cmd, args;
if (target === "sqlite") {
cmd = "node";
args = ["./dist/server/entry.mjs"];
} else {
cmd = "pnpm";
args = ["exec", "astro", "preview", "--host", HOST, "--port", String(PORT)];
}

const child = spawn(cmd, args, {
cwd: fixtureDir,
env: {
...process.env,
EMDASH_FIXTURE_TARGET: target,
EMDASH_QUERY_LOG: "1",
HOST,
PORT: String(PORT),
},
stdio: ["ignore", "pipe", "inherit"],
});

const ready = waitForPort(HOST, PORT);
const rl = createInterface({ input: child.stdout });
rl.on("line", (line) => {
const idx = line.indexOf(QUERY_LOG_PREFIX);
if (idx !== -1) {
const payload = line.slice(idx + QUERY_LOG_PREFIX.length);
try {
events.push(JSON.parse(payload));
} catch {
// ignore
}
return;
}
process.stdout.write(line + "\n");
});
Comment on lines +107 to +121

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

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

startServer() currently swallows malformed query-log payloads (JSON.parse errors) and drops any text that appears before the query-log prefix on the same line. In practice this can silently lose query events and produce incomplete dumps. Consider copying the more robust handling from scripts/query-counts.mjs (preserve/print any leading text, emit a warning on parse failures, and attach a child 'error' handler) so dump output is trustworthy.

Copilot uses AI. Check for mistakes.

const exited = new Promise((res) => child.once("exit", res));

async function stop() {
child.kill("SIGTERM");
await Promise.race([
exited,
new Promise((r) => setTimeout(r, 5_000)).then(() => child.kill("SIGKILL")),
]);
await new Promise((r) => setTimeout(r, 250));
}

return { ready, stop };
}

async function hit(method, path, phase) {
let lastErr;
for (let i = 0; i < 10; i++) {
try {
const r = await fetch(`${BASE}${path}`, {
method,
headers: { "x-perf-phase": phase },
redirect: "manual",
});
await r.arrayBuffer();
process.stdout.write(` ${phase.padEnd(5)} ${method} ${path} -> ${r.status}\n`);
return r.status;
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 200));
}
}
throw lastErr;
}

async function warmup() {
const r = await fetch(BASE, { redirect: "manual" });
await r.arrayBuffer();
process.stdout.write(` warmup GET / -> ${r.status}\n`);
}

const ROUTE_LEADING_SLASH = /^\//;
const ROUTE_NON_ALNUM = /[^a-zA-Z0-9]+/g;

function routeSlug(path) {
if (path === "/") return "root";
return path.replace(ROUTE_LEADING_SLASH, "").replace(ROUTE_NON_ALNUM, "_");
}

function dumpEventsByRoute(events, dumpTarget) {
const targetDir = resolve(dumpsDir, dumpTarget);
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });

const groups = new Map();
for (const e of events) {
if (e.phase !== "cold" && e.phase !== "warm") continue;
const key = `${routeSlug(e.route)}.${e.phase}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
for (const [key, list] of groups) {
const file = resolve(targetDir, `${key}.json`);
writeFileSync(file, JSON.stringify(list, null, "\t") + "\n");
process.stdout.write(`wrote ${file} (${list.length})\n`);
}
const allFile = resolve(targetDir, "_all.json");
writeFileSync(allFile, JSON.stringify(events, null, "\t") + "\n");
process.stdout.write(`wrote ${allFile} (${events.length})\n`);
}

async function runSqlite(events) {
const server = startServer(events);
try {
await server.ready;
await warmup();
const routes = routesOnly ? ROUTES.filter(([_, p]) => routesOnly.includes(p)) : ROUTES;
for (const [m, p] of routes) await hit(m, p, "cold");
for (const [m, p] of routes) await hit(m, p, "warm");
} finally {
await server.stop();
}
}

async function runD1(events) {
const routes = routesOnly ? ROUTES.filter(([_, p]) => routesOnly.includes(p)) : ROUTES;
for (const [m, p] of routes) {
process.stdout.write(`--- fresh isolate for ${m} ${p} ---\n`);
const server = startServer(events);
try {
await server.ready;
await hit(m, p, "cold");
await hit(m, p, "warm");
} finally {
await server.stop();
}
}
}

async function main() {
const events = [];
if (target === "sqlite") await runSqlite(events);
else await runD1(events);
dumpEventsByRoute(events, target);
}

main()
.then(() => process.exit(0))
.catch((err) => {
process.stderr.write(`${err.stack ?? err.message ?? err}\n`);
process.exit(1);
});
21 changes: 21 additions & 0 deletions scripts/query-dumps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Query dumps for the perf fixture

Tooling for slicing the per-route × phase query dumps captured by `scripts/query-counts-dump.mjs`. Useful when investigating where queries are coming from on a specific route — the catalogue this produced drove the perf reductions in PRs #838, #839, #840.

## Layout

- `sqlite/`, `d1/` — generated dump JSON, one file per route × phase. Gitignored. Regenerate with `scripts/query-counts-dump.mjs --target {sqlite|d1}`.
- `classification.{sqlite,d1}.md` — generated reports from `classify.mjs`. Gitignored — point-in-time snapshots that go stale on every code change.
- `classify.mjs <target>` — produces the classification table from the dumps.
- `cold-only.mjs` — diffs cold vs warm in the d1 dumps to surface the cold-isolate startup tax.
- `inspect-other.mjs <target> <class>` — prints distinct SQL for a class.

Each dump `*.json` is an array of `{ sql, params, durationMs, route, method, phase }`. `_all.json` is the un-grouped feed.

## Workflow

```
node scripts/query-counts.mjs --target sqlite # build + seed + run main harness
node scripts/query-counts-dump.mjs --target sqlite # capture per-query dumps
node scripts/query-dumps/classify.mjs sqlite # write classification.sqlite.md
```
Loading
Loading