mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
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>
253 lines
8.8 KiB
JavaScript
253 lines
8.8 KiB
JavaScript
// 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 };
|