sotashimozono_obsidian-remo.../plugin/scripts/perf/compare.mjs
Souta 6476372550 ci(plugin): Phase C.M10 — perf-bench off the PR critical path; nightly + workflow_dispatch (0.4.48)
Closes the second half of the Phase C MVP, but with a structurally
different cadence than the original M10 design (PR #106 v1).

The bench (`plugin/tests/integration/perf.sync.bench.test.ts`)
takes ~6 minutes per run — 96 % of the integration job's wall
time, all of it in the SSH-tunneled RPC RTT (~41 ms × ~3000 calls).
Running it on every PR was wrong on two counts:

  1. **Feedback latency**: every PR adds 6 minutes to CI for a
     comment-only gate. Souta correctly called out that the bench
     is overkill for the per-PR feedback loop.
  2. **Bench purpose**: the bench is a *trend tracker* and an
     *on-demand perf microscope*. Trends want a stable cadence
     (nightly), not noise from every PR; a microscope wants to be
     pulled out when there's a hypothesis to test, not run on
     every keystroke.

So this PR splits the workflow:

  - **integration.yml** (PR + push to main): runs everything
    EXCEPT the bench. Drops back to ~30 s wall time.
  - **bench.yml** (new, nightly cron + workflow_dispatch +
    push-to-main): runs ONLY the bench. Updates `perf-baseline`
    branch on nightly + main-push. workflow_dispatch with an
    optional `pr_number` input posts the diff comment to the
    named PR — preserves the on-demand "hey what does this
    branch look like perf-wise" capability without paying the
    6-minute tax on every push.

What ships:

- `plugin/scripts/perf/compare.mjs` (new, pure)

  Comparison core. Reads two NDJSON datasets, joins by
  `(span, transport, sizeBytes)`, computes p95 deltas, classifies
  each bucket as regressed / improved / unchanged / new / removed
  against per-span tolerance (default 25 %; S.fs widened to 40 %
  because shared-CI disk variance is high; S.note to 50 % for
  fsnotify debounce noise). Renders a Markdown table for the PR
  comment with arrows (🔴↑/🟢↓/−/🆕/), bolds regressed rows,
  appends a pass/fail summary + a footer with the baseline / head
  SHA pair + run URL. Status-ordered output (regressions first)
  so reviewers see the bad news without scrolling.

- `plugin/scripts/perf/run-compare.mjs` (new, CLI)

  Picks the latest NDJSON in `plugin/perf-results/`, fetches the
  baseline at `<workspace>/perf-baseline/baseline.ndjson`, calls
  `compare()`, posts via `gh pr comment`. **Upserts** the comment
  by sentinel header so a long-running PR with multiple
  workflow_dispatch invocations gets one rolling comment instead
  of a per-trigger spam stack. Exits 0 unless `PERF_GATE=1` AND
  there's a regression past tolerance.

- `plugin/scripts/perf/run-update-baseline.mjs` (new, CLI)

  Bootstraps the orphan `perf-baseline` branch on first run; on
  subsequent triggers, copies the latest NDJSON onto it as
  `baseline.ndjson` + writes a sidecar `baseline.sha`. Skips the
  commit when the baseline bytes haven't changed (no-op pushes
  on idle main). Uses `git worktree` to isolate the baseline
  checkout from the main checkout.

- `.github/workflows/bench.yml` (new)

  Triggers: nightly cron (UTC 19:00 = JST 04:00) + manual
  `workflow_dispatch` + push to main. Manual triggers can pass an
  optional `pr_number` input to comment the diff to a PR (the
  on-demand spot-check path). Permissions:
  `contents: write` (baseline branch push) +
  `pull-requests: write` (PR comment).

- `plugin/package.json`

  - `test:integration` — now excludes the bench file
    (`--exclude **/perf.sync.bench.test.ts`) so PR runs of the
    integration job stay fast.
  - `test:integration:bench` (new) — runs ONLY the bench file
    (used by bench.yml).

- `plugin/tests/PerfCompare.test.ts` (new, 19 tests)

  Pure-module unit coverage for the comparison logic — runs in
  the fast unit suite via vitest's ESM loader (no .mjs build step
  needed; ~280 ms).

  Covered:
    parseNDJSON: empty, well-formed, malformed (line number in
      error), missing required fields.
    compare status semantics: new / removed / unchanged (within
      ±5 % band) / regressed (past tolerance) / improved (clear
      drop). Per-span tolerance override (S.fs 40 %). Drops to
      'new' when baseline p95 is 0 (can't compute %).
    ordering: regressions surface first (status-grouped), then
      improvements, unchanged, new, removed, each lex-sorted.
    isolation: buckets across (span, transport, sizeBytes) tuples
      stay separate.
    formatMarkdown: header / table / pass-summary on no-
      regressions,  + bolded rows on regressions, "no buckets"
      message on empty input, gate-enabled hint surface.
    round-trip: NDJSON in → table out, end-to-end smoke.

- `.github/workflows/integration.yml`

  Unchanged from main — this PR deliberately does NOT add perf-
  related steps to the integration workflow. integration.yml
  stays the home of functional integration tests; bench.yml is
  the home of perf bench + baseline. The split keeps each
  workflow's purpose discoverable from its name.

Architecture summary:

  PR push          → integration.yml (no bench, ~30 s)
  push to main     → integration.yml (no bench)
                   → bench.yml (writes perf-baseline)
  nightly cron     → bench.yml (refreshes perf-baseline)
  workflow_dispatch→ bench.yml (no baseline write; optional PR comment)

Verification:

- `npx vitest run` — 37 files / 501 tests green (was 482, +19 new)
- `npx tsc --noEmit -p tsconfig.json` — clean
- `npm run test:integration` (locally if Docker present) — bench
  excluded via `--exclude` flag; only the functional integration
  tests run.
- workflow YAML: validated against the existing repo's structural
  patterns.

manifest/package/versions bumped 0.4.46 → 0.4.48 (one ahead of
the merged PR #105 / M9 at 0.4.47).

Roadmap context:
- M0 → M9 (Phase C MVP) ✓
- **M10 (this)** wires the gate but off the PR critical path.
- Issue #110 (PERF_GATE flip) needs an update to track "flip on
  schedule + workflow_dispatch in bench.yml" rather than the old
  "flip in integration.yml" wording.
- Issue #109 (E2E expansion) and #108 (modify case revival) are
  unaffected; they unlock more bench cells, not bench cadence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:39:01 +09:00

253 lines
8.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pure comparison logic for the Phase C M10 perf gate.
// Reads two NDJSON datasets (baseline + head), joins by the bench
// matrix tuple `(span, transport, sizeBytes)`, computes deltas, and
// returns a structured result the CLI wrapper formats as Markdown
// for `gh pr comment`. Kept pure / dependency-free so the unit
// suite can exercise it without spawning a workflow.
/**
* @typedef {object} PerfRecord
* @property {string} span - 'S.adp' | 'S.rpc' | 'S.app' | 'S.e2e' | …
* @property {string} transport - 'rpc' | 'sftp'
* @property {number} sizeBytes
* @property {number} p50
* @property {number} p95
* @property {number} p99
* @property {number} mean
* @property {number} stddev
* @property {number} n
* @property {number} [filtered]
*/
/**
* @typedef {object} Tolerances
* @property {{p95Tolerance: number}} default
* @property {Record<string, {p95Tolerance: number}>} [byName]
*/
/**
* @typedef {object} ComparisonRow
* @property {string} span
* @property {string} transport
* @property {number} sizeBytes
* @property {PerfRecord|null} base
* @property {PerfRecord|null} head
* @property {number|null} p95DeltaMs
* @property {number|null} p95DeltaPct - null when base p95 is 0 / NaN / missing
* @property {'regressed'|'improved'|'unchanged'|'new'|'removed'} status
* @property {number} tolerancePct - the p95 tolerance applied to this row
*/
const DEFAULT_TOLERANCES = Object.freeze({
default: { p95Tolerance: 0.25 },
byName: {
// Disk-write variance on shared CI disks is high; let it breathe.
'S.fs': { p95Tolerance: 0.40 },
// fsnotify debounce is timing-sensitive on a loaded runner.
'S.note': { p95Tolerance: 0.50 },
},
});
const UNCHANGED_BAND_PCT = 0.05; // ±5 % is "unchanged" so noise doesn't paint everything red
/**
* Parse one NDJSON document into an array of PerfRecords. Tolerates
* blank lines and ignores unknown fields. Throws on the first
* malformed JSON line so a corrupt baseline doesn't silently degrade
* the gate to "no diffs".
*
* @param {string} text
* @returns {PerfRecord[]}
*/
export function parseNDJSON(text) {
/** @type {PerfRecord[]} */
const out = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
let obj;
try {
obj = JSON.parse(line);
} catch (e) {
throw new Error(`compare: NDJSON parse error at line ${i + 1}: ${e.message}`);
}
if (!isPerfRecord(obj)) {
throw new Error(`compare: NDJSON line ${i + 1} is not a PerfRecord (got keys ${Object.keys(obj).join(',')})`);
}
out.push(obj);
}
return out;
}
function isPerfRecord(o) {
return o
&& typeof o === 'object'
&& typeof o.span === 'string'
&& typeof o.transport === 'string'
&& typeof o.sizeBytes === 'number'
&& typeof o.p95 === 'number';
}
/**
* Compare two datasets bucket-by-bucket. Buckets present in head but
* not baseline are status='new' (no baseline to compare against);
* buckets in baseline but not head are status='removed'.
*
* @param {PerfRecord[]} baseline
* @param {PerfRecord[]} head
* @param {Tolerances} [tolerances=DEFAULT_TOLERANCES]
* @returns {{ rows: ComparisonRow[], regressions: ComparisonRow[] }}
*/
export function compare(baseline, head, tolerances = DEFAULT_TOLERANCES) {
const baseByKey = indexByKey(baseline);
const headByKey = indexByKey(head);
/** @type {ComparisonRow[]} */
const rows = [];
const seenKeys = new Set();
for (const [key, h] of headByKey.entries()) {
seenKeys.add(key);
const b = baseByKey.get(key) ?? null;
rows.push(makeRow(h.span, h.transport, h.sizeBytes, b, h, tolerances));
}
for (const [key, b] of baseByKey.entries()) {
if (seenKeys.has(key)) continue;
rows.push(makeRow(b.span, b.transport, b.sizeBytes, b, null, tolerances));
}
// Sort: regressions first, then improvements, then unchanged/new/removed,
// each group ordered by (span, transport, sizeBytes) for determinism.
const statusOrder = { regressed: 0, improved: 1, unchanged: 2, new: 3, removed: 4 };
rows.sort((a, b) => {
const s = statusOrder[a.status] - statusOrder[b.status];
if (s !== 0) return s;
if (a.span !== b.span) return a.span < b.span ? -1 : 1;
if (a.transport !== b.transport) return a.transport < b.transport ? -1 : 1;
return a.sizeBytes - b.sizeBytes;
});
const regressions = rows.filter((r) => r.status === 'regressed');
return { rows, regressions };
}
function indexByKey(records) {
const m = new Map();
for (const r of records) m.set(`${r.span}|${r.transport}|${r.sizeBytes}`, r);
return m;
}
function makeRow(span, transport, sizeBytes, base, head, tolerances) {
const tolerancePct = toleranceFor(span, tolerances);
if (!base && head) return { span, transport, sizeBytes, base: null, head, p95DeltaMs: null, p95DeltaPct: null, status: 'new', tolerancePct };
if (base && !head) return { span, transport, sizeBytes, base, head: null, p95DeltaMs: null, p95DeltaPct: null, status: 'removed', tolerancePct };
// Both present.
const p95DeltaMs = head.p95 - base.p95;
/** @type {number|null} */
const p95DeltaPct = base.p95 > 0 && Number.isFinite(base.p95) ? p95DeltaMs / base.p95 : null;
let status;
if (p95DeltaPct === null) status = 'new'; // can't compute relative — treat as new
else if (p95DeltaPct > tolerancePct) status = 'regressed';
else if (p95DeltaPct < -UNCHANGED_BAND_PCT) status = 'improved';
else if (Math.abs(p95DeltaPct) <= UNCHANGED_BAND_PCT) status = 'unchanged';
else status = 'unchanged'; // small positive within tolerance band
return { span, transport, sizeBytes, base, head, p95DeltaMs, p95DeltaPct, status, tolerancePct };
}
function toleranceFor(span, tolerances) {
return tolerances.byName?.[span]?.p95Tolerance ?? tolerances.default.p95Tolerance;
}
/**
* Render the rows as a single Markdown comment body suitable for
* `gh pr comment`. Status arrows + bolding mark regressions; a
* trailing summary line states pass/fail and the gate threshold.
*
* @param {ComparisonRow[]} rows
* @param {{ commitSha?: string, baselineSha?: string, runUrl?: string, gateEnabled?: boolean }} [meta]
* @returns {string}
*/
export function formatMarkdown(rows, meta = {}) {
const header = '## Phase C perf-bench diff';
if (rows.length === 0) {
return `${header}\n\n_No bench buckets in either head or baseline — nothing to compare._\n${footerLines(meta, 0)}`;
}
const lines = [
header,
'',
'| span | transport | size | base p95 (ms) | head p95 (ms) | Δ ms | Δ % | tol % | status |',
'|------|-----------|-----:|---------------:|---------------:|-----:|----:|------:|--------|',
];
for (const r of rows) {
lines.push(formatRow(r));
}
const regressed = rows.filter((r) => r.status === 'regressed').length;
lines.push('');
if (regressed === 0) {
lines.push(`✅ no regressions over per-span p95 tolerance.`);
} else {
lines.push(`❌ **${regressed} bucket(s) regressed past their p95 tolerance.**`);
}
return lines.concat(footerLines(meta, regressed)).join('\n');
}
function formatRow(r) {
const sym = { regressed: '🔴 ↑', improved: '🟢 ↓', unchanged: '', new: '🆕', removed: '' }[r.status];
const cells = [
r.span,
r.transport,
humanBytes(r.sizeBytes),
r.base ? fmt(r.base.p95) : '—',
r.head ? fmt(r.head.p95) : '—',
r.p95DeltaMs !== null ? signed(r.p95DeltaMs) : '—',
r.p95DeltaPct !== null ? signedPct(r.p95DeltaPct) : '—',
`${(r.tolerancePct * 100).toFixed(0)}%`,
sym,
];
if (r.status === 'regressed') {
return `| **${cells.join('** | **')}** |`;
}
return `| ${cells.join(' | ')} |`;
}
function footerLines(meta, regressedCount) {
const out = [''];
if (meta.baselineSha || meta.commitSha) {
out.push(
`_baseline @ \`${shortSha(meta.baselineSha)}\` → head @ \`${shortSha(meta.commitSha)}\`_`,
);
}
if (meta.runUrl) {
out.push(`_[view CI run](${meta.runUrl})_`);
}
if (regressedCount > 0 && meta.gateEnabled) {
out.push('');
out.push(`_Gate is **enabled**; this run will fail. Set \`PERF_GATE=0\` in the workflow env to make it informational._`);
}
return out;
}
function shortSha(s) {
if (!s) return 'unknown';
return s.slice(0, 7);
}
function signed(n) {
return (n > 0 ? '+' : '') + n.toFixed(2);
}
function signedPct(n) {
return (n > 0 ? '+' : '') + (n * 100).toFixed(1) + '%';
}
function humanBytes(n) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}MB`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}KB`;
return `${n}B`;
}
function fmt(n) {
if (!Number.isFinite(n)) return '—';
if (n < 1) return n.toFixed(3);
if (n < 100) return n.toFixed(2);
return n.toFixed(1);
}
export { DEFAULT_TOLERANCES, UNCHANGED_BAND_PCT };