feat: showplan digest
No fixtures/real.sqlplan available (no SQL Server reachable from this machine); the real-plan test skips and says so. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1c3f222f9d
commit
4fefd0f6da
+111
@@ -0,0 +1,111 @@
|
|||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
|
||||||
|
export class PlanError extends Error {}
|
||||||
|
export const MAX_XML_BYTES = 12_000_000;
|
||||||
|
|
||||||
|
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true });
|
||||||
|
|
||||||
|
export type Operator = { id: string; op: string; cost: number; estRows: number; actualRows: number | null;
|
||||||
|
execs: number | null; io: number | null; cpu: number | null; spillLevels: number | null; parallel: boolean };
|
||||||
|
export type RowGuess = { id: string; op: string; est: number; actual: number; ratio: number };
|
||||||
|
export type MissingIndex = { table: string; equality: string[]; inequality: string[]; included: string[]; impact: number };
|
||||||
|
export type Wait = { type: string; ms: number };
|
||||||
|
export type Digest = { sql: string; bytes: number; subtreeCost: number | null; parallel: boolean;
|
||||||
|
nonParallelReason: string | null; earlyAbort: string | null; tables: string[]; topOperators: Operator[];
|
||||||
|
rowGuessErrors: RowGuess[]; missingIndexes: MissingIndex[]; warnings: string[]; waits: Wait[] };
|
||||||
|
|
||||||
|
function collect(node: any, name: string, out: any[] = []): any[] {
|
||||||
|
if (Array.isArray(node)) { for (const n of node) collect(n, name, out); return out; }
|
||||||
|
if (node && typeof node === "object") for (const [k, v] of Object.entries(node)) {
|
||||||
|
if (k === "#text") continue;
|
||||||
|
if (k === name) (Array.isArray(v) ? v : [v]).forEach((n) => out.push(n));
|
||||||
|
collect(v, name, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const num = (v: any): number | null =>
|
||||||
|
(v === undefined || v === null || v === "" || Number.isNaN(Number(v)) ? null : Number(v));
|
||||||
|
const text = (v: any): string | null =>
|
||||||
|
(typeof v === "object" && v !== null ? (String(v["#text"] ?? "") || null) : (v == null ? null : String(v)));
|
||||||
|
const unbrack = (v: any) => String(v ?? "").replace(/[[\]]/g, "");
|
||||||
|
const colNames = (node: any) => collect(node, "Column").map((c: any) => unbrack(c.Name)).filter(Boolean);
|
||||||
|
|
||||||
|
export function digestPlan(raw: string): Digest {
|
||||||
|
const xml = raw.replace(/^\uFEFF/, ""); // SSMS writes a BOM; the XML parser does not want it
|
||||||
|
if (!xml || xml.trim().length < 40) throw new PlanError("Empty input. Paste the ShowPlan XML, or drop a .sqlplan file.");
|
||||||
|
if (xml.length > MAX_XML_BYTES)
|
||||||
|
throw new PlanError(`Plan is ${Math.round(xml.length / 1e6)} MB, over the ${MAX_XML_BYTES / 1e6} MB cap.`);
|
||||||
|
let doc: any;
|
||||||
|
try { doc = parser.parse(xml); } catch { throw new PlanError("That XML will not parse — re-export the actual execution plan."); }
|
||||||
|
if (!doc?.ShowPlanXML) throw new PlanError("Not a SQL Server ShowPlan XML document. In SSMS: press Ctrl+M, run the query, then save the Execution Plan tab as .sqlplan.");
|
||||||
|
|
||||||
|
const stmts = collect(doc, "StmtSimple");
|
||||||
|
const stmt = stmts.find((s: any) => s?.QueryBlock || s?.RelOp) ?? stmts[0] ?? {};
|
||||||
|
const operators: Operator[] = collect(doc, "RelOp").map((o: any) => {
|
||||||
|
const rt = collect(o, "RunTimeCountersPerThread")[0];
|
||||||
|
return {
|
||||||
|
id: String(o.NodeId ?? o["Node_id"] ?? "?"),
|
||||||
|
op: String(o.LogicalOp ?? o.PhysicalOp ?? "Unknown"),
|
||||||
|
// Real ShowPlan spells these EstimateIO / EstimateCPU; builds differ, so fall through every spelling.
|
||||||
|
cost: num(o.EstimatedOperatorCost) ?? num(o.EstimatedTotalSubtreeCost) ?? num(o.EstimateIO) ?? 0,
|
||||||
|
estRows: num(o.EstimateRows) ?? 0,
|
||||||
|
actualRows: rt ? num(rt.ActualRows) : null,
|
||||||
|
execs: rt ? num(rt.ActualExecs) : null,
|
||||||
|
io: num(o.EstimateIO) ?? num(o.EstimatedIO), cpu: num(o.EstimateCPU) ?? num(o.EstimatedCPU),
|
||||||
|
spillLevels: num(collect(o, "SpillToTempDb")[0]?.SpillLevel),
|
||||||
|
parallel: o.Parallel === "true",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Database/Schema/Table arrive as separate attributes, each bracketed: rebuild the three-part name.
|
||||||
|
const fullName = (n: any) => [n.Database, n.Schema, n.Table].map(unbrack).filter(Boolean).join(".");
|
||||||
|
const tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan"), ...collect(doc, "Table")]
|
||||||
|
.map(fullName).filter(Boolean))];
|
||||||
|
|
||||||
|
const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g: any) => {
|
||||||
|
const mi = collect(g, "MissingIndex")[0] ?? {};
|
||||||
|
const byUsage = (u: string) => collect(mi, "ColumnGroup").filter((c: any) => c.Usage === u).flatMap(colNames);
|
||||||
|
return {
|
||||||
|
table: fullName(mi),
|
||||||
|
equality: byUsage("EQUALITY"), inequality: byUsage("INEQUALITY"),
|
||||||
|
included: collect(mi, "IncludedColumns").flatMap(colNames),
|
||||||
|
impact: num(g.Impact) ?? 0,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.impact - a.impact);
|
||||||
|
|
||||||
|
// Generic on purpose: SQL Server adds warning elements over time, unknown ones still surface.
|
||||||
|
const warnings = [...new Set(collect(doc, "Warnings").flatMap((w: any) =>
|
||||||
|
Object.entries(w).filter(([k]) => k !== "#text").map(([k, v]: [string, any]) => {
|
||||||
|
const attrs = v && typeof v === "object"
|
||||||
|
? Object.entries(v).filter(([, x]) => typeof x !== "object").map(([ak, x]) => `${ak}=${x}`).join(" ")
|
||||||
|
: "";
|
||||||
|
return attrs ? `${k} ${attrs}` : k;
|
||||||
|
})))];
|
||||||
|
|
||||||
|
const waits: Wait[] = collect(doc, "WaitTime")
|
||||||
|
.map((w: any) => ({ type: String(w.WaitType ?? "unknown"), ms: num(w.WaitTime) ?? 0 }))
|
||||||
|
.filter((w) => w.ms > 0).sort((a, b) => b.ms - a.ms).slice(0, 5);
|
||||||
|
|
||||||
|
const rowGuessErrors: RowGuess[] = operators
|
||||||
|
.filter((o) => o.actualRows !== null && o.estRows > 0)
|
||||||
|
.map((o) => ({ id: o.id, op: o.op, est: o.estRows, actual: o.actualRows as number,
|
||||||
|
ratio: Math.max(o.estRows, o.actualRows as number) / Math.min(o.estRows, o.actualRows as number) }))
|
||||||
|
.filter((r) => r.ratio >= 10).sort((a, b) => b.ratio - a.ratio).slice(0, 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sql: text(stmt.StatementText) ?? "", bytes: xml.length,
|
||||||
|
subtreeCost: num(stmt.StatementSubtreeCost), parallel: operators.some((o) => o.parallel),
|
||||||
|
nonParallelReason: text(collect(doc, "NonParallelPlanReason")[0]),
|
||||||
|
earlyAbort: text(collect(doc, "StatementOptmEarlyAbortReason")[0]),
|
||||||
|
tables, topOperators: operators.sort((a, b) => b.cost - a.cost).slice(0, 12),
|
||||||
|
rowGuessErrors, missingIndexes, warnings, waits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens are the constraint: models and Jev read this, never the raw XML.
|
||||||
|
export const digestForModel = (d: Digest) => ({
|
||||||
|
sql: d.sql.slice(0, 4000), subtreeCost: d.subtreeCost, parallel: d.parallel,
|
||||||
|
nonParallelReason: d.nonParallelReason, earlyAbort: d.earlyAbort, tables: d.tables.slice(0, 20),
|
||||||
|
topOperators: d.topOperators, rowGuessErrors: d.rowGuessErrors,
|
||||||
|
missingIndexes: d.missingIndexes.slice(0, 5), warnings: d.warnings.slice(0, 20), waits: d.waits,
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
const { digestPlan, PlanError, digestForModel } = await import("../lib/digest.ts");
|
||||||
|
|
||||||
|
export const PLAN = `<?xml version="1.0" encoding="utf-16"?>
|
||||||
|
<ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan" Version="1.6" Build="16.0.4085.2">
|
||||||
|
<BatchSequence><Batch><Statements>
|
||||||
|
<StmtSimple StatementId="1" StatementSubtreeCost="4.871" StatementOptmLevel="FULL"
|
||||||
|
StatementText="SELECT o.Id FROM Sales.Orders o WHERE o.CustomerId = @c1 AND o.Total > 500">
|
||||||
|
<StatementOptmEarlyAbortReason>GoodEnoughPlanFound</StatementOptmEarlyAbortReason>
|
||||||
|
<QueryBlock QueryHash="0x1A" QueryPlanHash="0x2B">
|
||||||
|
<RelOp NodeId="0" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Index Scan" EstimateRows="1000"
|
||||||
|
EstimateIO="41.6" EstimateCPU="39.0" EstimatedTotalSubtreeCost="4.10" Parallel="false">
|
||||||
|
<RunTimeInformation><RunTimeCountersPerThread Thread="0" ActualRows="1243500" ActualExecs="1"
|
||||||
|
ActualElapsedms="2411" ActualCPUms="2210"/></RunTimeInformation>
|
||||||
|
<IndexScan Database="[wide]" Schema="[Sales]" Table="[Orders]"/>
|
||||||
|
<Warnings><NoJoinPredicate/><SpillToTempDb SpillLevel="2"/>
|
||||||
|
<WaitTime WaitType="PAGEIOLATCH_SH" WaitTime="812"/></Warnings>
|
||||||
|
</RelOp>
|
||||||
|
<RelOp NodeId="1" PhysicalOp="Index Seek" LogicalOp="Index Seek" EstimateRows="3"
|
||||||
|
EstimateIO="0.7" EstimateCPU="0.02" EstimatedTotalSubtreeCost="0.77" Parallel="true">
|
||||||
|
<RunTimeInformation><RunTimeCountersPerThread Thread="0" ActualRows="4" ActualExecs="2"/></RunTimeInformation>
|
||||||
|
<IndexScan Database="[wide]" Schema="[Sales]" Table="[Customer]"/>
|
||||||
|
</RelOp>
|
||||||
|
<MissingIndexes><MissingIndexGroup Impact="94.17">
|
||||||
|
<MissingIndex Database="[wide]" Schema="[Sales]" Table="[Orders]">
|
||||||
|
<ColumnGroup Usage="EQUALITY"><Column Name="[CustomerId]"/></ColumnGroup>
|
||||||
|
<ColumnGroup Usage="INEQUALITY"><Column Name="[Total]"/></ColumnGroup>
|
||||||
|
<IncludedColumns><Column Name="[Id]"/></IncludedColumns>
|
||||||
|
</MissingIndex></MissingIndexGroup></MissingIndexes>
|
||||||
|
<NonParallelPlanReason>Could Not Generate Valid Parallel Plan</NonParallelPlanReason>
|
||||||
|
</QueryBlock>
|
||||||
|
</StmtSimple>
|
||||||
|
</Statements></Batch></BatchSequence>
|
||||||
|
</ShowPlanXML>`;
|
||||||
|
|
||||||
|
test("digests what matters in a showplan", () => {
|
||||||
|
const d = digestPlan(PLAN);
|
||||||
|
assert.match(d.sql, /o\.Total > 500/, "XML entities decoded");
|
||||||
|
assert.equal(d.subtreeCost, 4.871);
|
||||||
|
assert.equal(d.earlyAbort, "GoodEnoughPlanFound");
|
||||||
|
assert.equal(d.nonParallelReason, "Could Not Generate Valid Parallel Plan");
|
||||||
|
assert.deepEqual(d.tables, ["wide.Sales.Orders", "wide.Sales.Customer"]);
|
||||||
|
assert.equal(d.topOperators[0].op, "Clustered Index Scan");
|
||||||
|
assert.equal(d.topOperators[0].spillLevels, 2);
|
||||||
|
assert.equal(d.topOperators[0].actualRows, 1243500);
|
||||||
|
assert.equal(d.topOperators[0].parallel, false);
|
||||||
|
assert.deepEqual(d.missingIndexes[0], { table: "wide.Sales.Orders", equality: ["CustomerId"],
|
||||||
|
inequality: ["Total"], included: ["Id"], impact: 94.17 });
|
||||||
|
assert.ok(d.warnings.includes("NoJoinPredicate"));
|
||||||
|
assert.ok(d.warnings.some((w) => w.startsWith("SpillToTempDb")));
|
||||||
|
assert.deepEqual(d.waits, [{ type: "PAGEIOLATCH_SH", ms: 812 }]);
|
||||||
|
assert.ok(d.rowGuessErrors[0].ratio > 100);
|
||||||
|
assert.equal(d.bytes, PLAN.length);
|
||||||
|
});
|
||||||
|
test("a BOM-prefixed plan still digests — SSMS writes .sqlplan as UTF-16 with a BOM", () => {
|
||||||
|
assert.equal(digestPlan("\uFEFF" + PLAN).subtreeCost, 4.871);
|
||||||
|
});
|
||||||
|
test("rejects input that is not a plan", () => {
|
||||||
|
for (const bad of ["", "SELECT 1", "<html></html>", "<ShowPlanXML><trunc"])
|
||||||
|
assert.throws(() => digestPlan(bad), PlanError);
|
||||||
|
});
|
||||||
|
test("digestForModel stays bounded on a fat plan", () => {
|
||||||
|
const big = `<ShowPlanXML><Batch><Statements><StmtSimple StatementText="q">` +
|
||||||
|
Array.from({ length: 400 }, (_, i) =>
|
||||||
|
`<RelOp NodeId="${i}" LogicalOp="Scan" EstimateRows="1" EstimatedTotalSubtreeCost="${i}"/>`).join("") +
|
||||||
|
`</StmtSimple></Statements></Batch></Batch></ShowPlanXML>`;
|
||||||
|
const v = digestForModel(digestPlan(big)) as { topOperators: unknown[]; tables: unknown[] };
|
||||||
|
assert.ok(v.topOperators.length <= 12);
|
||||||
|
});
|
||||||
|
test("a real .sqlplan digests", () => {
|
||||||
|
let xml: string;
|
||||||
|
try { xml = readFileSync("fixtures/real.sqlplan", "utf8"); } catch { return console.log("skip: no fixtures/real.sqlplan"); }
|
||||||
|
const d = digestPlan(xml);
|
||||||
|
assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings");
|
||||||
|
assert.ok(d.sql.length > 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user