From fdb47979fbb6fcb253bafeb66ceb77fe4556793c Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:13:35 +0000 Subject: [PATCH 1/4] feat(dag-viewer): show the stage pipeline as three stacked lanes The viewer drew one query at a time as a pre-ASAP and a post-ASAP DAG, read through stage-workload.js. It now shows the whole workload at each planning stage: Stage 0 (query-language frontends), the chosen Stage 1 logical ASAP candidate and its Stage 2 physical candidate, stacked top to bottom, with the Stage 3 candidate list beside them and node details below. - stages.js checks the document and builds each lane from the flat logical DAGs and the PhysicalASAPDAGs; a standalone scalar query is a Scalar node over the nodes it reads (scalar_roots in Stage 2). - The Stage 3 list keeps the candidate table: selected, costlier, rejected, pruned and composition-error rows, with a header that shows the Stage 1 error or selection.error. - The SQL and PromQL editor moves to editor.js and shows its result in the lanes. - server.py writes the examples.json documents into the git-ignored out/ on start (--regenerate rewrites them). Only Example 1 stays committed, for the stage_pipeline contract test; dag.example.json, generate-sample.sh and the scalar-queries fixture are removed, and the scalar test checks scalar_roots positions directly. - render.py, viewer.js, stage-workload.js and their tests go with the per-query view; test_viewer.py replaces test_render.py. --- .gitignore | 3 - crates/devtools/tests/stage_pipeline.rs | 14 +- tools/dag-viewer/.gitignore | 1 + tools/dag-viewer/app.js | 276 + tools/dag-viewer/cost-benefit-annotations.png | Bin 262020 -> 0 bytes .../cost-benefit-positive-savings.png | Bin 217800 -> 0 bytes tools/dag-viewer/dag.example.json | 27417 ---------------- tools/dag-viewer/editor.js | 167 + tools/dag-viewer/examples.json | 23 + tools/dag-viewer/examples/scalar-queries.json | 2802 -- tools/dag-viewer/generate-sample.sh | 24 - tools/dag-viewer/index.html | 662 +- tools/dag-viewer/node-category-outcome.png | Bin 202137 -> 0 bytes tools/dag-viewer/node-style.js | 147 +- tools/dag-viewer/planner-ui.js | 156 - tools/dag-viewer/render.py | 110 - tools/dag-viewer/server.py | 35 +- tools/dag-viewer/stage-workload.js | 207 - tools/dag-viewer/stages.js | 457 + tools/dag-viewer/test_render.py | 347 - tools/dag-viewer/test_server.py | 43 +- tools/dag-viewer/test_viewer.py | 426 + tools/dag-viewer/viewer.js | 795 - 23 files changed, 1630 insertions(+), 32482 deletions(-) create mode 100644 tools/dag-viewer/.gitignore create mode 100644 tools/dag-viewer/app.js delete mode 100644 tools/dag-viewer/cost-benefit-annotations.png delete mode 100644 tools/dag-viewer/cost-benefit-positive-savings.png delete mode 100644 tools/dag-viewer/dag.example.json create mode 100644 tools/dag-viewer/editor.js create mode 100644 tools/dag-viewer/examples.json delete mode 100644 tools/dag-viewer/examples/scalar-queries.json delete mode 100755 tools/dag-viewer/generate-sample.sh delete mode 100644 tools/dag-viewer/node-category-outcome.png delete mode 100644 tools/dag-viewer/planner-ui.js delete mode 100755 tools/dag-viewer/render.py delete mode 100644 tools/dag-viewer/stage-workload.js create mode 100644 tools/dag-viewer/stages.js delete mode 100644 tools/dag-viewer/test_render.py create mode 100644 tools/dag-viewer/test_viewer.py delete mode 100644 tools/dag-viewer/viewer.js diff --git a/.gitignore b/.gitignore index 3774bbbd1..fc28ab02d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,6 @@ # Cargo.lock is intentionally tracked (workspace builds binaries). -# Regenerated by tools/dag-viewer/generate-sample.sh; dag.example.json is the committed sample -/tools/dag-viewer/dag.json - # Python bytecode cache (tools/clickhouse/extract_functions.py) __pycache__/ *.pyc diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index 0cb261ec1..fce25c9c2 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -446,9 +446,9 @@ fn pass1_failure_still_writes_stage0_and_the_error() { assert!(document["stage3_plan_selection"]["selected"].is_null()); } -/// The scalar-queries viewer fixture: standalone scalar queries first, in -/// the middle and last, which the physical DAG keeps in `scalar_roots`. -const SCALAR_QUERIES: &str = "../../tools/dag-viewer/examples/scalar-queries.json"; +/// Standalone scalar queries first, in the middle and last, which the +/// physical DAG keeps in `scalar_roots` by query position (the viewer's +/// test_viewer.py draws this workload). const SCALAR_QUERIES_ARGS: &[&str] = &[ "--promql", "scalar(sum(m))", @@ -463,14 +463,8 @@ const SCALAR_QUERIES_ARGS: &[&str] = &[ ]; #[test] -fn scalar_queries_fixture_is_current() { +fn scalar_queries_keep_their_positions_in_scalar_roots() { let document = document_of(SCALAR_QUERIES_ARGS); - let committed: Value = serde_json::from_str( - &std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SCALAR_QUERIES)) - .unwrap(), - ) - .unwrap(); - assert_eq!(document, committed, "regenerate the committed fixture"); let selected = document["stage3_plan_selection"]["selected"] .as_str() .unwrap(); diff --git a/tools/dag-viewer/.gitignore b/tools/dag-viewer/.gitignore new file mode 100644 index 000000000..89f9ac04a --- /dev/null +++ b/tools/dag-viewer/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/tools/dag-viewer/app.js b/tools/dag-viewer/app.js new file mode 100644 index 000000000..c1285c2c3 --- /dev/null +++ b/tools/dag-viewer/app.js @@ -0,0 +1,276 @@ +// ASAPPlanner Stage Viewer: an asap-stage-pipeline/v1 document as a Stage 3 +// candidate list beside three DAG lanes stacked top to bottom (Stage 0, +// then the Stage 1 candidate, then its Stage 2 candidate), details below. +// Each lane shows the whole workload. Document checks, the candidate list +// and node labels live in stages.js (tested); node-style.js groups operator +// kinds. +(function () { + if (window.cytoscapeDagre) cytoscape.use(window.cytoscapeDagre); + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const tok = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + + let doc, rows, queryIds, logicalById, physicalById; + let current = { logical: null, physical: null }; + const cys = [null, null, null]; + const laneName = ['Stage 0 · query-language frontends', 'Stage 1 · logical ASAP optimization', 'Stage 2 · physical ASAP optimization']; + + function style() { + return [ + { selector: 'node', style: { + shape: 'round-rectangle', label: 'data(label)', 'text-wrap': 'wrap', 'text-valign': 'center', 'text-halign': 'center', + 'font-family': tok('--font-mono') || 'monospace', 'font-size': 10, color: tok('--fg'), + width: 'label', height: 'label', padding: '9px', 'border-width': 1.5, 'text-max-width': 240 } }, + { selector: 'node[group = "source"]', style: { 'background-color': tok('--cat-source'), 'border-color': tok('--cat-source-line') } }, + { selector: 'node[group = "rel"]', style: { 'background-color': tok('--cat-rel'), 'border-color': tok('--cat-rel-line') } }, + { selector: 'node[group = "summary"]', style: { 'background-color': tok('--cat-summary'), 'border-color': tok('--cat-summary-line') } }, + { selector: 'node[group = "scalar"]', style: { 'background-color': tok('--cat-scalar'), 'border-color': tok('--cat-scalar-line'), 'border-style': 'dashed' } }, + { selector: 'node.root', style: { 'border-width': 3.5, 'border-color': tok('--accent') } }, + { selector: 'node:selected', style: { 'overlay-color': tok('--accent'), 'overlay-opacity': 0.14, 'overlay-padding': 5 } }, + { selector: 'edge', style: { width: 1.6, 'line-color': tok('--edge'), 'target-arrow-color': tok('--edge'), 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'arrow-scale': 0.9 } }, + { selector: 'edge:selected', style: { 'line-color': tok('--accent'), 'target-arrow-color': tok('--accent'), width: 2.6 } }, + ]; + } + + function clearLane(i, text) { + if (cys[i]) { cys[i].destroy(); cys[i] = null; } + $(`lane${i}-meta`).textContent = text; + } + + function render(i, dag, physical, costPerNode) { + if (cys[i]) cys[i].destroy(); + const elements = stageLaneElements(dag, { physical, costPerNode, queryIds }) + .map((el) => ({ data: el.data, classes: [el.classes, el.data.rootFor && el.data.rootFor.length ? 'root' : ''].join(' ').trim() })); + const cy = cytoscape({ + container: $('cy' + i), elements, style: style(), + layout: { name: window.cytoscapeDagre ? 'dagre' : 'breadthfirst', rankDir: 'BT', nodeSep: 18, rankSep: 34, padding: 14, directed: true }, + wheelSensitivity: 0.25, minZoom: 0.2, maxZoom: 2.5, boxSelectionEnabled: false, + }); + cy.on('tap', 'node', (e) => showNode(e.target.data(), i)); + cy.on('tap', 'edge', (e) => showEdge(e.target.data(), i)); + cys[i] = cy; + } + + const fmtCost = (x) => (Math.abs(x) >= 100 ? x.toFixed(1) : String(Number(x.toPrecision(3)))); + const kv = (items) => `
${items.map(([k, v]) => `
${esc(k)}
${esc(v)}
`).join('')}
`; + + function schemaTable(schema) { + if (!schema || !Array.isArray(schema.fields)) return ''; + const fields = schema.fields.map((f) => `${esc(f.name)}${esc(compactWire(f.dtype))}${f.nullable ? 'yes' : 'no'}`).join(''); + return `
output schema
${fields}
fieldtypenullable
`; + } + + function showNode(d, lane) { + const n = d.node; + const items = [['lane', laneName[lane]], ['operator', d.kind], ['node id', d.nodeId]]; + if (d.kind === 'Scalar') items.push(['expression', stageScalarText(n.Scalar)]); + const [, body] = d.kind === 'Scalar' ? [null, {}] : stageOperatorParts(n.payload || n.operator); + if (d.kind === 'SummaryAgg') { + items.push(['summary parameters', summaryFamilyText(body.family)]); + items.push(['instances', summaryInstancesText(body.grouping, body.reduction)]); + } + if (d.rootFor && d.rootFor.length) items.push(['query root of', d.rootFor.join(', ')]); + if (d.timing !== undefined) items.push(['runs at', formatTiming(d.timing)]); + if (n.materialization) items.push(['materialization', compactWire(n.materialization)]); + if (n.output_state && n.output_state.primitive) items.push(['output primitive', compactWire(n.output_state.primitive)]); + if (d.cost) items.push(['Stage 3 cost', `${Number(d.cost.cost.toPrecision(4))}${d.cost.multiplier === 1 ? '' : ` × ${d.cost.multiplier}`} · ${d.cost.detail || ''}`]); + if (d.kind !== 'Scalar') items.push(['guarantee', n.guarantee ? compactWire(n.guarantee) : 'none']); + $('details').innerHTML = `

${esc(d.kind)} node ${esc(d.nodeId)}

${kv(items)} + ${schemaTable(n.output_schema || n.schema)} +
Full node (JSON)
${esc(JSON.stringify(n, null, 2))}
`; + } + + function showEdge(d, lane) { + const [from, to] = [d.source.slice(1), d.target.slice(1)]; + const items = [['lane', laneName[lane]], ['from → to', `node ${from} → node ${d.target.startsWith('s') ? `scalar root of query #${Number(to) + 1}` : to}`]]; + const e = d.edge; + if (e) { + items.push(['role', compactWire(e.role)], ['grouping', compactWire(e.grouping)]); + if (e.data_state) items.push(['data state', compactWire(e.data_state)]); + if (e.window) items.push(['window', compactWire(e.window)]); + } + $('details').innerHTML = `

Edge ${esc(from)} → ${esc(to)}

${kv(items)} + ${e ? schemaTable(e.intermediate_schema) : ''}`; + } + + function statusChip(status) { + if (status === 'selected') return '✓ selected'; + if (status === 'costlier') return 'valid · costlier'; + if (status === 'rejected') return '✗ invalid'; + return `${esc(status)}`; + } + + function rowKey(row) { return `${row.status === 'pruned' || row.status === 'composition error' ? 'L' : 'P'}:${row.id}`; } + + function renderRanking() { + const pressed = current.physical ? `P:${current.physical}` : `L:${current.logical}`; + $('rank').innerHTML = rows.map((r) => `
  • `).join(''); + $('rank').querySelectorAll('button').forEach((b) => b.addEventListener('click', () => { + const [kind, id] = [b.dataset.key.slice(0, 1), b.dataset.key.slice(2)]; + if (kind === 'P') selectPhysical(id); + else selectLogical(id, true); + })); + } + + // Show Stage 1 candidate `id`; `alone` clears Stage 2 (a pruned or + // uncomposed candidate has no physical plan). + function selectLogical(id, alone) { + current.logical = id; + $('pick1').value = id; + const c = logicalById.get(id); + if (c.dag) { + render(1, c.dag, false, null); + const physicalFor = doc.stage2_physical_asap_optimization.candidates.filter((p) => p.from_logical === id).map((p) => p.id); + const pruned = (doc.stage2_physical_asap_optimization.pruned || []).find((p) => p.from_logical === id); + $('lane1-meta').innerHTML = `${esc(c.display_name)} · ${c.dag.nodes.length} nodes · ` + + (pruned ? `pruned by Stage 2: ${esc(pruned.reason)}` : `physical plan ${physicalFor.map((p) => `${esc(p)}`).join(', ') || 'none'}`); + } else { + clearLane(1, ''); + $('lane1-meta').innerHTML = `${esc(c.display_name)} · composition error: ${esc(c.error)}`; + } + if (alone) { + current.physical = null; + $('pick2').value = ''; + clearLane(2, 'no physical plan for this Stage 1 candidate'); + renderRanking(); + } + } + + function selectPhysical(id) { + current.physical = id; + $('pick2').value = id; + const c = physicalById.get(id); + const selection = doc.stage3_plan_selection; + const cost = selection.costs[id]; + render(2, c.dag, true, cost ? cost.per_node : null); + const row = rows.find((r) => rowKey(r) === `P:${id}`); + const priced = rows.filter((r) => r.rank !== null).length; + $('lane2-meta').innerHTML = `${esc(c.display_name)} · from ${esc(c.from_logical)} · ` + + (cost ? `total ${fmtCost(cost.total)} ${esc(cost.unit.replace(/_/g, ' '))} · rank ${row.rank} of ${priced}` : 'not priced') + + ` · ${statusChip(row.status)}`; + if (c.from_logical !== current.logical) selectLogical(c.from_logical, false); + renderRanking(); + } + + function restyle() { cys.forEach((cy) => cy && cy.style(style())); } + + const HINT = '

    Select a node to see its operator, output schema, timing and Stage 3 cost; select an edge to see the schema and data state it carries.

    '; + + // Show one stage document; `source` names it, `story` explains it. + function showDocument(data, source, story) { + const errors = validateStagePipeline(data); + if (errors.length) throw new Error(errors.slice(0, 3).join('; ')); + doc = data; + current = { logical: null, physical: null }; + $('details').innerHTML = HINT; + $('docName').textContent = source || ''; + $('story').textContent = story || ''; + $('story').hidden = !story; + queryIds = doc.workload.queries.map((q) => q.id); + const logical = doc.stage1_logical_asap_optimization; + const physical = doc.stage2_physical_asap_optimization; + const selection = doc.stage3_plan_selection; + logicalById = new Map(logical.candidates.map((c) => [c.id, c])); + physicalById = new Map(physical.candidates.map((c) => [c.id, c])); + rows = stageCandidateRows(doc); + + $('queries').innerHTML = doc.workload.queries.map((q) => { + const r = q.requirements || {}; + const acc = r.accuracy === 'exact' ? 'exact' : r.accuracy ? `ε=${r.accuracy.epsilon}${r.accuracy.delta !== undefined ? `, δ=${r.accuracy.delta}` : ''}` : ''; + const req = [acc && `accuracy ${acc}`, r.latency_ms && `latency ≤ ${r.latency_ms} ms`, r.repeat_interval_ms && `every ${r.repeat_interval_ms / 1000} s`].filter(Boolean).join(' · '); + return `
    ${esc(q.id)} · ${esc(q.language)}${esc(q.text)}${req ? `${esc(req)}` : ''}
    `; + }).join(''); + const count = (status) => rows.filter((r) => r.status === status).length; + $('counts').innerHTML = `Candidates per stage: 1 logical DAG → ${logical.combinations} logical ASAP` + + (logical.capped ? ` (${logical.candidates.length} shown)` : '') + + ` → ${physicalById.size} physical` + + (selection.selected ? ` → 1 selected (${count('rejected')} invalid, ${count('costlier')} valid but costlier)` : ' → none selected'); + $('rank-meta').textContent = stageCandidateHeader(doc); + const sel = selection.selected ? selection.costs[selection.selected] : null; + $('cost-unit').textContent = sel ? sel.unit.replace(/_/g, ' ') : ''; + + $('pick1').innerHTML = logical.candidates.map((c) => ``).join(''); + $('pick2').innerHTML = rows.filter((r) => rowKey(r).startsWith('P')) + .map((r) => ``).join(''); + + render(0, doc.stage0_query_language_frontends.dag, false, null); + $('lane0-meta').innerHTML = `${doc.stage0_query_language_frontends.dag.nodes.length} nodes · roots for ${queryIds.map((q) => `${esc(q)}`).join(', ')}`; + // The selected plan is the first row when there is one. + const first = rows[0]; + if (first && rowKey(first).startsWith('P')) selectPhysical(first.id); + else if (first) selectLogical(first.logical, true); + else { + clearLane(1, 'no candidates'); + clearLane(2, 'no candidates'); + renderRanking(); + } + } + + function showError(err) { + $('queries').innerHTML = `Could not load the planner output: ${esc(err.message)}`; + } + + // Loads are numbered so a slow earlier fetch cannot replace a later one. + let loading = 0; + function loadUrl(url, source, story) { + const ticket = ++loading; + $('queries').innerHTML = 'Loading planner output…'; + return fetch(url).then((r) => { if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`); return r.json(); }) + .then((data) => { if (ticket === loading) showDocument(data, source, story); }) + .catch((err) => { if (ticket === loading) showError(err); }); + } + + let examples = []; + function pressTab(id) { + $('tabs').querySelectorAll('button').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.id === id))); + } + function loadExample(example) { + pressTab(example.id); + try { history.replaceState(null, '', '#' + example.id); } catch (e) { /* the hash is a convenience */ } + loadUrl(example.file, example.file, example.story); + } + + // Documents from outside the example list (a file, the editor) clear the tab. + function showExternal(data, source) { + ++loading; + pressTab(null); + try { history.replaceState(null, '', location.pathname + location.search); } catch (e) { /* ignore */ } + try { showDocument(data, source, ''); } catch (err) { showError(err); } + } + + function readFile(file) { + file.text().then((text) => showExternal(JSON.parse(text), file.name)).catch(showError); + } + $('openFile').addEventListener('click', () => $('fileInput').click()); + $('fileInput').addEventListener('change', (e) => { if (e.target.files[0]) readFile(e.target.files[0]); e.target.value = ''; }); + document.addEventListener('dragover', (e) => { e.preventDefault(); document.body.classList.add('drop-active'); }); + document.addEventListener('dragleave', (e) => { if (!e.relatedTarget) document.body.classList.remove('drop-active'); }); + document.addEventListener('drop', (e) => { + e.preventDefault(); + document.body.classList.remove('drop-active'); + if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]); + }); + + $('pick1').addEventListener('change', (e) => selectLogical(e.target.value, true)); + $('pick2').addEventListener('change', (e) => selectPhysical(e.target.value)); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', restyle); + new MutationObserver(restyle).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + $('details').innerHTML = HINT; + + window.StageViewer = { showDocument: showExternal }; + + // `?doc=` opens one document; otherwise the examples listed in + // examples.json, starting from `#` or the first. + const requested = new URLSearchParams(location.search).get('doc'); + fetch('examples.json').then((r) => (r.ok ? r.json() : [])).catch(() => []).then((list) => { + examples = Array.isArray(list) ? list : []; + $('tabs').innerHTML = examples.map((x) => ``).join(''); + $('tabs').hidden = examples.length === 0; + $('tabs').querySelectorAll('button').forEach((b) => b.addEventListener('click', () => loadExample(examples.find((x) => x.id === b.dataset.id)))); + if (requested) loadUrl(requested, requested, ''); + else if (examples.length) loadExample(examples.find((x) => '#' + x.id === location.hash) || examples[0]); + }); +})(); diff --git a/tools/dag-viewer/cost-benefit-annotations.png b/tools/dag-viewer/cost-benefit-annotations.png deleted file mode 100644 index 265df517896865dbf6ca57177ddcb31f1f70d345..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 262020 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