From 798debf68e7db6919d36f93be94dfb65d54af263 Mon Sep 17 00:00:00 2001 From: mary <2723253023@qq.com> Date: Thu, 6 Aug 2026 18:54:27 +0800 Subject: [PATCH 1/5] feat: visualize profile execution graphs --- package.json | 2 + .../profile-analysis/ProfileAnalysis.scss | 76 +++- .../profile-analysis/ProfileAnalyzer.tsx | 92 ++++- .../profile-analysis/ProfileDag.scss | 332 +++++++++++++++++ .../profile-analysis/ProfileDag.tsx | 261 +++++++++++++ .../profile-analysis/ProfileDagNode.tsx | 74 ++++ .../profile-analysis.api.test.js | 261 ++++++++++++- .../profile-analysis/profile-analysis.api.ts | 345 +++++++++++++++++- .../profile-analysis.components.test.js | 18 + .../profile-analysis.dag.test.js | 222 +++++++++++ .../profile-analysis/profile-analysis.dag.ts | 240 ++++++++++++ .../profile-analysis.recovery.test.js | 32 ++ .../profile-analysis.recovery.ts | 14 +- .../profile-analysis.types.ts | 160 +++++++- .../use-profile-analysis.test.js | 91 ++++- .../profile-analysis/use-profile-analysis.ts | 211 ++++++++++- yarn.lock | 147 ++++++++ 17 files changed, 2530 insertions(+), 48 deletions(-) create mode 100644 src/components/profile-analysis/ProfileDag.scss create mode 100644 src/components/profile-analysis/ProfileDag.tsx create mode 100644 src/components/profile-analysis/ProfileDagNode.tsx create mode 100644 src/components/profile-analysis/profile-analysis.dag.test.js create mode 100644 src/components/profile-analysis/profile-analysis.dag.ts diff --git a/package.json b/package.json index 361bfc3ffd5d8..777e37bc6f314 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@emotion/css": "^11.13.5", "@hcaptcha/react-hcaptcha": "^2.0.2", "@mdx-js/react": "^3.0.0", + "@xyflow/react": "^12.11.2", "@yang1666204/docusaurus-search-local": "0.0.7", "antd": "^5.12.2", "autoprefixer": "^10.4.16", @@ -63,6 +64,7 @@ "copy-to-clipboard": "^3.3.3", "docusaurus-plugin-matomo": "^0.0.8", "docusaurus-plugin-sass": "^0.2.3", + "elkjs": "^0.12.0", "postcss": "^8.4.32", "prism-react-renderer": "^2.1.0", "react": "^18.2.0", diff --git a/src/components/profile-analysis/ProfileAnalysis.scss b/src/components/profile-analysis/ProfileAnalysis.scss index 66604e2a1fc0a..092eb58d00564 100644 --- a/src/components/profile-analysis/ProfileAnalysis.scss +++ b/src/components/profile-analysis/ProfileAnalysis.scss @@ -33,7 +33,8 @@ } &__uploader, - &__result { + &__result, + &__workspace { margin-bottom: 1.5rem; padding: 1.5rem; border: 1px solid var(--brand-border-soft); @@ -47,6 +48,70 @@ } } + &__workspace-title { + margin: 0 0 1rem; + font-size: 1.35rem; + } + + &__tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--brand-border-soft); + + button { + position: relative; + padding: 0.7rem 1rem; + border: 0; + color: var(--ifm-color-emphasis-700); + background: transparent; + cursor: pointer; + font: inherit; + font-weight: 700; + } + + button::after { + position: absolute; + right: 0.5rem; + bottom: -1px; + left: 0.5rem; + height: 3px; + border-radius: 3px 3px 0 0; + background: transparent; + content: ''; + } + + button[aria-selected='true'] { + color: var(--brand-primary); + } + + button[aria-selected='true']::after { + background: var(--brand-primary); + } + + button:focus-visible { + border-radius: 6px; + outline: 2px solid var(--brand-primary-glow); + outline-offset: 2px; + } + } + + &__tab-panel[hidden] { + display: none; + } + + &__tab-panel > &__status, + &__tab-panel > &__error, + &__tab-panel > &__result { + margin-bottom: 0; + } + + &__tab-panel > &__result { + padding: 0; + border: 0; + box-shadow: none; + } + &__help { margin-bottom: 1rem; color: var(--ifm-color-emphasis-700); @@ -354,10 +419,17 @@ padding-top: 1rem; &__uploader, - &__result { + &__result, + &__workspace { padding: 1rem; } + &__tabs button { + flex: 1 1 0; + padding-right: 0.5rem; + padding-left: 0.5rem; + } + &__drop-zone { min-height: 150px; padding: 1rem; diff --git a/src/components/profile-analysis/ProfileAnalyzer.tsx b/src/components/profile-analysis/ProfileAnalyzer.tsx index e378b5bfec7d4..662157e5fac7f 100644 --- a/src/components/profile-analysis/ProfileAnalyzer.tsx +++ b/src/components/profile-analysis/ProfileAnalyzer.tsx @@ -1,8 +1,9 @@ -import React, { JSX } from 'react'; +import React, { JSX, useEffect, useId, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { AnalysisResult } from './AnalysisResult'; import { AnalysisStatus } from './AnalysisStatus'; import { ProfileUploader } from './ProfileUploader'; +import { ProfileDag } from './ProfileDag'; import { useProfileAnalysis } from './use-profile-analysis'; import './ProfileAnalysis.scss'; @@ -14,6 +15,8 @@ export function ProfileAnalyzer(): JSX.Element { const hcaptchaSiteKey = typeof configuredHCaptchaSiteKey === 'string' ? configuredHCaptchaSiteKey : ''; const analysis = useProfileAnalysis(apiBaseUrl); + const [activeResultTab, setActiveResultTab] = useState<'graph' | 'analysis'>('graph'); + const tabIdPrefix = useId(); const isBusy = analysis.isBusy; const busyState = analysis.state === 'restoring' || @@ -23,6 +26,28 @@ export function ProfileAnalyzer(): JSX.Element { analysis.state === 'analyzing' ? analysis.state : null; + const hasJob = analysis.jobId !== null; + + useEffect(() => { + setActiveResultTab('graph'); + }, [analysis.jobId]); + + const handleTabKeyDown = (event: React.KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const nextTab = + event.key === 'Home' + ? 'graph' + : event.key === 'End' + ? 'analysis' + : activeResultTab === 'graph' + ? 'analysis' + : 'graph'; + setActiveResultTab(nextTab); + window.requestAnimationFrame(() => { + document.getElementById(`${tabIdPrefix}-${nextTab}-tab`)?.focus(); + }); + }; return (
@@ -45,20 +70,77 @@ export function ProfileAnalyzer(): JSX.Element { onAnalyze={analysis.analyze} /> - {busyState && } - {analysis.state === 'completed' && } + {!hasJob && busyState && } {analysis.recoveryWarning && (
{analysis.recoveryWarning}
)} - {analysis.error && ( + {!hasJob && analysis.error && (
Analysis failed. {analysis.error}
)} - {analysis.result && } + {hasJob && ( +
+

+ Analysis workspace +

+
+ + +
+ + +
+ )}
); } diff --git a/src/components/profile-analysis/ProfileDag.scss b/src/components/profile-analysis/ProfileDag.scss new file mode 100644 index 0000000000000..3e022ad2667fb --- /dev/null +++ b/src/components/profile-analysis/ProfileDag.scss @@ -0,0 +1,332 @@ +.profile-dag { + color: var(--ifm-font-color-base); + + &__summary, + &__legend { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 1rem; + align-items: center; + padding: 0.65rem 0.85rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.85rem; + } + + &__summary { + border-bottom: 1px solid var(--brand-border-soft); + font-weight: 600; + } + + &__issues { + color: var(--ifm-color-warning-darkest); + } + + &__legend { + justify-content: flex-end; + } + + &__legend span { + display: inline-flex; + gap: 0.4rem; + align-items: center; + } + + &__legend-line { + display: inline-block; + width: 1.5rem; + border-top: 2px solid var(--ifm-color-emphasis-600); + } + + &__legend-line--dependency { + border-top-style: dashed; + } + + &__legend-swatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 2px; + } + + &__legend-swatch--exec { + background: var(--ifm-color-danger); + } + + &__legend-swatch--wait { + background: var(--ifm-color-warning); + } + + &__workspace { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + min-height: 680px; + border: 1px solid var(--brand-border-soft); + border-radius: 10px; + overflow: hidden; + background: var(--brand-paper); + } + + &__workspace--details { + grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); + } + + &__canvas { + min-width: 0; + height: 680px; + } + + &__message { + display: flex; + gap: 0.65rem; + align-items: center; + justify-content: center; + min-height: 180px; + padding: 1rem; + border: 1px solid var(--brand-border-soft); + border-radius: 10px; + color: var(--ifm-color-emphasis-700); + background: var(--brand-surface-soft); + text-align: center; + } + + &__message--error { + color: var(--ifm-color-danger-darkest); + background: var(--ifm-color-danger-contrast-background); + } + + .react-flow__node-profileFragment { + z-index: -1 !important; + } + + .react-flow__edge-path { + stroke: var(--ifm-color-emphasis-600); + stroke-width: 1.7; + } + + .react-flow__minimap, + .react-flow__controls { + border: 1px solid var(--brand-border-soft); + background: var(--brand-paper); + } + + .react-flow__controls-button { + border-bottom-color: var(--brand-border-soft); + background: var(--brand-paper); + fill: var(--ifm-font-color-base); + } +} + +.profile-dag-fragment { + width: 100%; + height: 100%; + border: 1px solid var(--brand-border-soft); + border-radius: 12px; + background: color-mix(in srgb, var(--brand-surface-soft) 72%, transparent); + pointer-events: none; + + > span { + position: absolute; + top: 0.6rem; + left: 0.75rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } +} + +.profile-dag-node { + position: relative; + width: 220px; + height: 104px; + box-sizing: border-box; + padding: 0.55rem; + border: 1px solid color-mix(in srgb, var(--ifm-color-danger) var(--profile-dag-heat-border, 0%), var(--brand-border-soft)); + border-radius: 8px; + overflow: hidden; + color: var(--ifm-font-color-base); + background: color-mix(in srgb, var(--ifm-color-danger) var(--profile-dag-heat-background, 0%), var(--brand-paper)); + box-shadow: 0 3px 10px rgb(var(--brand-shadow-rgb) / 10%); + cursor: pointer; + + &--bottleneck { + border: 3px solid var(--ifm-color-danger-dark); + } + + &__heading { + display: flex; + gap: 0.45rem; + align-items: flex-start; + justify-content: space-between; + + strong { + overflow: hidden; + font-size: 0.85rem; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__bottleneck { + flex: 0 0 auto; + padding: 0.12rem 0.3rem; + border-radius: 3px; + color: var(--brand-cream-light); + background: var(--ifm-color-danger-dark); + font-size: 0.58rem; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + &__location { + display: flex; + flex-wrap: wrap; + gap: 0.2rem 0.55rem; + margin: 0.3rem 0; + color: var(--ifm-color-emphasis-700); + font-size: 0.68rem; + } + + &__timing { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.3rem; + margin: 0; + + div { + min-width: 0; + } + + dt { + color: var(--ifm-color-emphasis-600); + font-size: 0.6rem; + } + + dd { + margin: 0.08rem 0 0; + overflow: hidden; + font-size: 0.68rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__wait { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 5px; + background: linear-gradient(90deg, var(--ifm-color-warning) var(--profile-dag-wait-width, 0%), transparent 0); + } + + &__handle { + width: 2px; + height: 2px; + border: 0; + opacity: 0; + pointer-events: none; + } +} + +.profile-dag-details { + height: 680px; + padding: 1rem; + overflow-y: auto; + border-left: 1px solid var(--brand-border-soft); + background: var(--brand-paper); + + header { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; + } + + header p { + margin: 0 0 0.2rem; + color: var(--ifm-color-emphasis-600); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + } + + h3, + h4 { + margin: 0; + overflow-wrap: anywhere; + } + + h4 { + margin: 1.2rem 0 0.5rem; + font-size: 0.95rem; + } + + header button { + width: 2rem; + height: 2rem; + border: 1px solid var(--brand-border-soft); + border-radius: 6px; + color: var(--ifm-font-color-base); + background: var(--brand-surface-soft); + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + } + + dl { + margin: 0; + } + + dl > div { + padding: 0.45rem 0; + border-bottom: 1px solid var(--brand-border-soft); + } + + dt { + color: var(--ifm-color-emphasis-600); + font-size: 0.72rem; + font-weight: 600; + } + + dd { + margin: 0.12rem 0 0; + overflow-wrap: anywhere; + font-size: 0.82rem; + } +} + +@media (max-width: 768px) { + .profile-dag { + &__legend { + justify-content: flex-start; + } + + &__workspace, + &__workspace--details { + grid-template-columns: minmax(0, 1fr); + min-height: 520px; + } + + &__canvas { + height: 520px; + } + } + + .profile-dag-details { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + height: min(60%, 420px); + border-top: 1px solid var(--brand-border-soft); + border-left: 0; + box-shadow: 0 -8px 24px rgb(var(--brand-shadow-rgb) / 14%); + } +} diff --git a/src/components/profile-analysis/ProfileDag.tsx b/src/components/profile-analysis/ProfileDag.tsx new file mode 100644 index 0000000000000..bd0ca576ce3c5 --- /dev/null +++ b/src/components/profile-analysis/ProfileDag.tsx @@ -0,0 +1,261 @@ +import React, { JSX, useEffect, useMemo, useRef, useState } from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import { + Background, + Controls, + MiniMap, + ReactFlow, + ReactFlowProvider, + useReactFlow, + type NodeMouseHandler, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import type { DagUiState, ProfileDagResponse, ProfileDagNode as ProfileDagNodeData } from './profile-analysis.types'; +import { + formatBytes, + formatCount, + formatDurationNs, + layoutProfileDag, + type ProfileFlowNode, +} from './profile-analysis.dag'; +import { ProfileDagFragmentNode, ProfileDagNode } from './ProfileDagNode'; +import './ProfileDag.scss'; + +export interface ProfileDagProps { + state: DagUiState; + dag: ProfileDagResponse | null; + error?: string | null; +} + +const nodeTypes = { + profileOperator: ProfileDagNode, + profileFragment: ProfileDagFragmentNode, +}; + +const stateMessages: Partial> = { + idle: 'The execution graph will appear after an analysis starts.', + pending: 'Waiting to parse the execution graph…', + parsing: 'Parsing the execution graph…', + loading: 'Laying out the execution graph…', + unavailable: 'An execution graph is not available for this Profile.', + failed: 'The execution graph could not be generated.', +}; + +function safePlanInfoValue(value: unknown): string | null { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + if (Array.isArray(value)) { + const values = value + .filter(item => typeof item === 'string' || typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item))) + .map(String); + return values.length > 0 ? values.join(', ') : null; + } + return null; +} + +function titleFromKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/^./, character => character.toUpperCase()); +} + +function Metric({ label, value, bytes = false }: { label: string; value: { sum?: number | null; avg?: number | null; max?: number | null; min?: number | null } | null | undefined; bytes?: boolean }): JSX.Element | null { + if (!value) { + return null; + } + const format = bytes ? formatBytes : formatCount; + return ( +
+
{label}
+
+ Max {format(value.max ?? null)} · Avg {format(value.avg ?? null)} · Sum {format(value.sum ?? null)} +
+
+ ); +} + +function NodeDetails({ node, onClose }: { node: ProfileDagNodeData; onClose: () => void }): JSX.Element { + const planInfo = Object.entries(node.planInfo ?? {}) + .map(([key, value]) => [key, safePlanInfoValue(value)] as const) + .filter((entry): entry is readonly [string, string] => entry[1] !== null); + const breakdown = node.timing?.waitTime?.breakdown; + + return ( + + ); +} + +function ProfileDagCanvas({ dag }: { dag: ProfileDagResponse }): JSX.Element { + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState>['edges']>([]); + const [selectedNode, setSelectedNode] = useState(null); + const [layoutError, setLayoutError] = useState(null); + const canvasRef = useRef(null); + const hasFitVisibleCanvasRef = useRef(false); + const { fitView } = useReactFlow(); + + useEffect(() => { + let cancelled = false; + setNodes([]); + setEdges([]); + setSelectedNode(null); + setLayoutError(null); + hasFitVisibleCanvasRef.current = false; + layoutProfileDag(dag) + .then(layout => { + if (!cancelled) { + setNodes(layout.nodes); + setEdges(layout.edges); + } + }) + .catch(() => { + if (!cancelled) { + setLayoutError('The execution graph could not be laid out.'); + } + }); + return () => { + cancelled = true; + }; + }, [dag]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || nodes.length === 0) return; + + const fitWhenVisible = () => { + if (hasFitVisibleCanvasRef.current || canvas.clientWidth === 0 || canvas.clientHeight === 0) return; + hasFitVisibleCanvasRef.current = true; + requestAnimationFrame(() => void fitView({ padding: 0.12 })); + }; + const observer = new ResizeObserver(fitWhenVisible); + observer.observe(canvas); + fitWhenVisible(); + return () => observer.disconnect(); + }, [fitView, nodes.length]); + + const onNodeClick = useMemo>( + () => (_event, flowNode) => { + if (flowNode.data.kind === 'operator') { + setSelectedNode(flowNode.data.node); + } + }, + [], + ); + + if (layoutError) { + return
{layoutError}
; + } + if (nodes.length === 0) { + return
; + } + + return ( +
+
+ + + + + +
+ {selectedNode && setSelectedNode(null)} />} +
+ ); +} + +export function ProfileDag({ state, dag, error = null }: ProfileDagProps): JSX.Element { + if (state !== 'ready' || !dag) { + const message = error || stateMessages[state] || 'The execution graph is not available.'; + const isError = state === 'failed' || state === 'unavailable'; + return
{message}
; + } + + const issueCount = dag.unresolvedReferences.length + dag.warnings.length; + return ( +
+
+ {dag.summary.fragmentCount} fragments + {dag.summary.pipelineCount} pipelines + {dag.summary.nodeCount} operators + {dag.summary.edgeCount} connections + {issueCount > 0 && {issueCount} non-blocking parsing {issueCount === 1 ? 'notice' : 'notices'}} +
+
+ Data flow + Execution dependency + Longer execution + Longer wait +
+ Loading the execution graph…}> + {() => } + +
+ ); +} diff --git a/src/components/profile-analysis/ProfileDagNode.tsx b/src/components/profile-analysis/ProfileDagNode.tsx new file mode 100644 index 0000000000000..6a75510506b18 --- /dev/null +++ b/src/components/profile-analysis/ProfileDagNode.tsx @@ -0,0 +1,74 @@ +import React, { CSSProperties, JSX } from 'react'; +import { Handle, Position, type NodeProps } from '@xyflow/react'; +import type { ProfileFlowNode } from './profile-analysis.dag'; +import { formatDurationNs } from './profile-analysis.dag'; + +type HeatStyle = CSSProperties & { + '--profile-dag-heat-border'?: string; + '--profile-dag-heat-background'?: string; + '--profile-dag-wait-width'?: string; +}; + +export function ProfileDagNode({ data }: NodeProps): JSX.Element { + if (data.kind !== 'operator') { + return
; + } + + const { node, pipelineLabel, instanceNum } = data; + const execTime = node.timing?.execTime; + const waitTime = node.timing?.waitTime; + const heat = Math.pow(node.analysis?.heat ?? 0, 0.6); + const waitHeat = node.analysis?.waitHeat ?? 0; + const style: HeatStyle = { + '--profile-dag-heat-border': `${Math.round(heat * 70)}%`, + '--profile-dag-heat-background': `${Math.round(heat * 22)}%`, + '--profile-dag-wait-width': `${Math.round(waitHeat * 100)}%`, + }; + + return ( +
+ + +
+ {node.label} + {node.analysis?.isBottleneck && Bottleneck} +
+
+ {node.fragmentId.replace('fragment:', 'Fragment ')} + {pipelineLabel} + {instanceNum !== null && {instanceNum} instances} +
+
+
+
Exec max
+
{formatDurationNs(execTime?.maxNs ?? null)}
+
+
+
Exec avg
+
{formatDurationNs(execTime?.avgNs ?? null)}
+
+
+
Wait max
+
{formatDurationNs(waitTime?.maxNs ?? null)}
+
+
+
+ ); +} + +export function ProfileDagFragmentNode({ data }: NodeProps): JSX.Element { + if (data.kind !== 'fragment') { + return
; + } + + return ( +
+ {data.label} +
+ ); +} diff --git a/src/components/profile-analysis/profile-analysis.api.test.js b/src/components/profile-analysis/profile-analysis.api.test.js index 6ee818a1d9d76..e3e8d9fca5076 100644 --- a/src/components/profile-analysis/profile-analysis.api.test.js +++ b/src/components/profile-analysis/profile-analysis.api.test.js @@ -35,6 +35,8 @@ const { createAnalysisJob, getAnalysisJob, getAnalysisJobByClientRequestId, + getProfileDag, + MAX_DAG_RESPONSE_BYTES, MAX_FINAL_ANSWER_BYTES, PRIVACY_NOTICE_VERSION, ProfileAnalysisApiError, @@ -100,18 +102,29 @@ test('parses queued, running, completed, and failed job snapshots', async t => { const originalFetch = global.fetch; t.after(() => { global.fetch = originalFetch; }); const responses = [ - { jobId, status: 'QUEUED', jobsAhead: 3 }, - { jobId, status: 'RUNNING' }, - { jobId, status: 'COMPLETED', result: { id: 'item_26', type: 'agent_message', text: 'Done' } }, - { jobId, status: 'FAILED', error: { code: 'CODEX_EXECUTION_FAILED', message: 'Failed safely.' } }, + { jobId, status: 'QUEUED', jobsAhead: 3, dagStatus: 'PENDING' }, + { jobId, status: 'RUNNING', dagStatus: 'PARSING', dagError: null }, + { jobId, status: 'COMPLETED', result: { id: 'item_26', type: 'agent_message', text: 'Done' }, dagStatus: 'READY' }, + { jobId, status: 'FAILED', error: { code: 'CODEX_EXECUTION_FAILED', message: 'Failed safely.' }, dagStatus: 'UNAVAILABLE', dagError: 'DAG_UNAVAILABLE' }, ]; global.fetch = async (url, options) => { assert.equal(url, `/api/profile/analysis-jobs/${jobId}`); assert.equal(options.method, 'GET'); return jsonResponse(responses.shift()); }; - assert.deepEqual(await getAnalysisJob('', jobId), { jobId, status: 'QUEUED', jobsAhead: 3 }); - assert.deepEqual(await getAnalysisJob('', jobId), { jobId, status: 'RUNNING' }); + assert.deepEqual(await getAnalysisJob('', jobId), { + jobId, + status: 'QUEUED', + jobsAhead: 3, + dagStatus: 'PENDING', + dagError: null, + }); + assert.deepEqual(await getAnalysisJob('', jobId), { + jobId, + status: 'RUNNING', + dagStatus: 'PARSING', + dagError: null, + }); assert.equal((await getAnalysisJob('', jobId)).status, 'COMPLETED'); assert.equal((await getAnalysisJob('', jobId)).status, 'FAILED'); }); @@ -263,6 +276,7 @@ test('rejects a completed answer over the frontend UTF-8 byte limit', async t => jobId, status: 'COMPLETED', result: { id: 'item_26', type: 'agent_message', text: oversized }, + dagStatus: 'READY', }); await assert.rejects(getAnalysisJob('', jobId), error => { @@ -338,3 +352,238 @@ test('rejects a missing hCaptcha token before sending the Profile', async t => { ); assert.equal(fetchCalls, 0); }); + +function validDag() { + return { + schemaVersion: '1.0', + parserVersion: '0.2.0', + jobId, + profile: {}, + graph: { + direction: 'BOTTOM_TO_TOP', + nodes: [ + { + id: 'fragment:0/pipeline:0/operator:0', + fragmentId: 'fragment:0', + pipelineId: 'fragment:0/pipeline:0', + ordinal: 0, + operatorType: 'RESULT_SINK_OPERATOR', + operatorFamily: 'RESULT', + role: 'SINK', + label: 'RESULT', + planNodeId: 1, + nereidsId: null, + destId: null, + destIds: [], + known: true, + lineNumber: 10, + planInfo: {}, + timing: { + execTime: { sumNs: 10, avgNs: 10, maxNs: 10, minNs: 10, display: '10ns' }, + waitTime: { + totalNs: 0, + maxNs: 0, + avgNs: 0, + breakdown: { waitForDependencyNs: 0 }, + }, + }, + metrics: { inputRows: { sum: 1, avg: 1, max: 1, min: 1 } }, + analysis: { heat: 1, waitHeat: 0, isBottleneck: true }, + }, + { + id: 'fragment:0/pipeline:0/operator:1', + fragmentId: 'fragment:0', + pipelineId: 'fragment:0/pipeline:0', + ordinal: 1, + operatorType: 'OLAP_SCAN_OPERATOR', + operatorFamily: 'SCAN', + role: 'SOURCE', + label: 'OLAP SCAN', + planNodeId: 2, + nereidsId: null, + destId: null, + destIds: [], + known: true, + lineNumber: 20, + planInfo: { table: 'lineitem' }, + }, + ], + edges: [ + { + id: 'edge:0', + kind: 'PIPELINE_DATA', + source: 'fragment:0/pipeline:0/operator:1', + target: 'fragment:0/pipeline:0/operator:0', + relationId: null, + resolved: true, + metadata: { pipelineId: 'fragment:0/pipeline:0' }, + }, + ], + }, + fragments: [ + { + id: 'fragment:0', + number: 0, + pipelineIds: ['fragment:0/pipeline:0'], + nodeIds: [ + 'fragment:0/pipeline:0/operator:0', + 'fragment:0/pipeline:0/operator:1', + ], + }, + ], + pipelines: [ + { + id: 'fragment:0/pipeline:0', + fragmentId: 'fragment:0', + number: 0, + instanceNum: 1, + nodeIds: [ + 'fragment:0/pipeline:0/operator:0', + 'fragment:0/pipeline:0/operator:1', + ], + }, + ], + unresolvedReferences: [], + warnings: [], + summary: { + fragmentCount: 1, + pipelineCount: 1, + nodeCount: 2, + edgeCount: 1, + unresolvedEdgeCount: 0, + criticalNodeId: 'fragment:0/pipeline:0/operator:0', + maxExecTimeNs: 10, + maxWaitTimeNs: 0, + }, + }; +} + +test('fetches and defensively validates a ready Profile DAG', async t => { + const originalFetch = global.fetch; + t.after(() => { global.fetch = originalFetch; }); + const dag = validDag(); + global.fetch = async (url, options) => { + assert.equal(url, `https://agent.velodb.io/api/profile/analysis-jobs/${jobId}/dag`); + assert.equal(options.method, 'GET'); + return jsonResponse(dag); + }; + + assert.deepEqual(await getProfileDag('https://agent.velodb.io/', jobId), { + dagStatus: 'READY', + dag, + }); +}); + +test('accepts omitted nullable DAG fields without treating them as zero', async t => { + const originalFetch = global.fetch; + t.after(() => { global.fetch = originalFetch; }); + const dag = validDag(); + delete dag.graph.nodes[0].planNodeId; + delete dag.graph.nodes[0].nereidsId; + delete dag.graph.nodes[0].destId; + delete dag.graph.nodes[0].timing.execTime.sumNs; + delete dag.graph.nodes[0].timing.waitTime.totalNs; + delete dag.graph.nodes[0].metrics.inputRows.min; + delete dag.graph.edges[0].relationId; + dag.unresolvedReferences.push({ + kind: 'EXCHANGE', + sourceNodeId: dag.graph.nodes[1].id, + reason: 'TARGET_NOT_FOUND', + }); + dag.summary.unresolvedEdgeCount = 1; + global.fetch = async () => jsonResponse(dag); + + const result = await getProfileDag('', jobId); + assert.equal(result.dagStatus, 'READY'); + assert.equal(result.dag.graph.nodes[0].planNodeId, undefined); + assert.equal(result.dag.graph.nodes[0].timing.execTime.sumNs, undefined); + assert.equal(result.dag.graph.nodes[0].metrics.inputRows.min, undefined); + assert.equal(result.dag.graph.edges[0].relationId, undefined); +}); + +test('accepts signed internal ids used by local exchange and multicast operators', async t => { + const originalFetch = global.fetch; + t.after(() => { global.fetch = originalFetch; }); + const dag = validDag(); + dag.graph.nodes[1].operatorType = 'MULTI_CAST_DATA_STREAM_SINK_OPERATOR'; + dag.graph.nodes[1].operatorFamily = 'MULTICAST'; + dag.graph.nodes[1].planNodeId = -5; + dag.graph.nodes[1].destId = -7; + dag.graph.nodes[1].destIds = [-7, -8, -9]; + global.fetch = async () => jsonResponse(dag); + + const result = await getProfileDag('', jobId); + assert.equal(result.dag.graph.nodes[1].planNodeId, -5); + assert.equal(result.dag.graph.nodes[1].destId, -7); + assert.deepEqual(result.dag.graph.nodes[1].destIds, [-7, -8, -9]); +}); + +test('accepts a valid DAG above the ordinary 128 KiB response limit', async t => { + const originalFetch = global.fetch; + t.after(() => { global.fetch = originalFetch; }); + const dag = validDag(); + dag.warnings = Array.from({ length: 10 }, (_, index) => ({ + kind: 'PARSER_NOTE', + nodeId: dag.graph.nodes[0].id, + message: `${index}:${'x'.repeat(15 * 1024)}`, + })); + global.fetch = async () => jsonResponse(dag); + + const result = await getProfileDag('', jobId); + assert.equal(result.dagStatus, 'READY'); + assert.equal(result.dag.warnings.length, 10); +}); + +test('returns a recoverable result when the Profile DAG is still parsing', async t => { + const originalFetch = global.fetch; + t.after(() => { global.fetch = originalFetch; }); + global.fetch = async () => + new Response(JSON.stringify({ jobId, dagStatus: 'PARSING' }), { + status: 202, + headers: { 'Content-Type': 'application/json', 'Retry-After': '2' }, + }); + + assert.deepEqual(await getProfileDag('', jobId), { + jobId, + dagStatus: 'PARSING', + retryAfterMs: 2000, + }); +}); + +test('rejects a DAG with duplicate node ids or a dangling edge', async t => { + const originalFetch = global.fetch; + t.after(() => { global.fetch = originalFetch; }); + const duplicate = validDag(); + duplicate.graph.nodes[1].id = duplicate.graph.nodes[0].id; + global.fetch = async () => jsonResponse(duplicate); + await assert.rejects(getProfileDag('', jobId), error => { + assert.ok(error instanceof ProfileAnalysisApiError); + assert.equal(error.code, 'INVALID_SERVER_RESPONSE'); + return true; + }); + + const dangling = validDag(); + dangling.graph.edges[0].target = 'fragment:99/pipeline:0/operator:0'; + global.fetch = async () => jsonResponse(dangling); + await assert.rejects(getProfileDag('', jobId), error => { + assert.ok(error instanceof ProfileAnalysisApiError); + assert.equal(error.code, 'INVALID_SERVER_RESPONSE'); + return true; + }); +}); + +test('rejects a DAG response over the dedicated 5 MiB client limit', async t => { + const originalFetch = global.fetch; + t.after(() => { global.fetch = originalFetch; }); + global.fetch = async () => + new Response(JSON.stringify({ padding: 'x'.repeat(MAX_DAG_RESPONSE_BYTES + 1) }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + await assert.rejects(getProfileDag('', jobId), error => { + assert.ok(error instanceof ProfileAnalysisApiError); + assert.equal(error.code, 'INVALID_SERVER_RESPONSE'); + return true; + }); +}); diff --git a/src/components/profile-analysis/profile-analysis.api.ts b/src/components/profile-analysis/profile-analysis.api.ts index cbc3619f5916b..6a1145293f0ee 100644 --- a/src/components/profile-analysis/profile-analysis.api.ts +++ b/src/components/profile-analysis/profile-analysis.api.ts @@ -4,6 +4,9 @@ import type { AnalysisJobStatus, ApiErrorBody, CreateAnalysisJobResponse, + DagStatus, + ProfileDagFetchResult, + ProfileDag, RecoveredAnalysisJobResponse, ResponseLanguage, } from './profile-analysis.types'; @@ -16,6 +19,11 @@ const DEFAULT_POLL_INTERVAL_MS = 2_000; export const PRIVACY_NOTICE_VERSION = '2026-07-22'; export const MAX_FINAL_ANSWER_BYTES = 64 * 1024; const MAX_API_RESPONSE_BYTES = 128 * 1024; +export const MAX_DAG_RESPONSE_BYTES = 5 * 1024 * 1024; +const MAX_DAG_NODES = 500; +const MAX_DAG_EDGES = 1_000; +const MAX_DAG_STRING_BYTES = 16 * 1024; +const MAX_DAG_ID_BYTES = 512; export class ProfileAnalysisApiError extends Error { constructor( @@ -58,6 +66,281 @@ function isAnalysisJobStatus(value: unknown): value is AnalysisJobStatus { return value === 'QUEUED' || value === 'RUNNING' || value === 'COMPLETED' || value === 'FAILED'; } +function isDagStatus(value: unknown): value is DagStatus { + return ( + value === 'PENDING' || + value === 'PARSING' || + value === 'READY' || + value === 'UNAVAILABLE' || + value === 'FAILED' + ); +} + +function parseDagJobState(body: Record): { dagStatus: DagStatus; dagError: string | null } { + if (!isDagStatus(body.dagStatus)) throw invalidResponse(); + if (body.dagError !== undefined && body.dagError !== null && typeof body.dagError !== 'string') { + throw invalidResponse(); + } + return { + dagStatus: body.dagStatus, + dagError: typeof body.dagError === 'string' ? body.dagError : null, + }; +} + +function isBoundedString(value: unknown, maxBytes = MAX_DAG_STRING_BYTES): value is string { + return typeof value === 'string' && new TextEncoder().encode(value).byteLength <= maxBytes; +} + +function isDagId(value: unknown): value is string { + return isBoundedString(value, MAX_DAG_ID_BYTES) && value.length > 0; +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value); +} + +function isNullableNonNegativeInteger(value: unknown): value is number | null { + return value === null || isNonNegativeInteger(value); +} + +function isOptionalNullableNonNegativeInteger(value: unknown): value is number | null | undefined { + return value === undefined || isNullableNonNegativeInteger(value); +} + +function isOptionalNullableSafeInteger(value: unknown): value is number | null | undefined { + return value === undefined || value === null || isSafeInteger(value); +} + +function isBoundedScalar(value: unknown): value is string | number | boolean { + return ( + isBoundedString(value) || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value) && Number.isSafeInteger(value)) + ); +} + +function isBoundedScalarRecord(value: unknown, allowNull = false): boolean { + if (!isRecord(value) || Object.keys(value).length > 128) return false; + return Object.entries(value).every(([key, item]) => { + if (!isBoundedString(key, 256)) return false; + if (allowNull && item === null) return true; + if (isBoundedScalar(item)) return true; + return Array.isArray(item) && item.length <= 128 && item.every(isBoundedScalar); + }); +} + +function isAggregateMetric(value: unknown): boolean { + return ( + isRecord(value) && + isOptionalNullableNonNegativeInteger(value.sum) && + isOptionalNullableNonNegativeInteger(value.avg) && + isOptionalNullableNonNegativeInteger(value.max) && + isOptionalNullableNonNegativeInteger(value.min) + ); +} + +function isExecTime(value: unknown): boolean { + return ( + isRecord(value) && + isOptionalNullableNonNegativeInteger(value.sumNs) && + isOptionalNullableNonNegativeInteger(value.avgNs) && + isOptionalNullableNonNegativeInteger(value.maxNs) && + isOptionalNullableNonNegativeInteger(value.minNs) && + (value.display === undefined || isBoundedString(value.display)) + ); +} + +function isWaitTime(value: unknown): boolean { + return ( + isRecord(value) && + isOptionalNullableNonNegativeInteger(value.totalNs) && + isOptionalNullableNonNegativeInteger(value.maxNs) && + isOptionalNullableNonNegativeInteger(value.avgNs) && + (value.display === undefined || isBoundedString(value.display)) && + (value.breakdown === undefined || + (isRecord(value.breakdown) && + Object.keys(value.breakdown).length <= 64 && + Object.entries(value.breakdown).every( + ([key, item]) => isBoundedString(key, 256) && isNullableNonNegativeInteger(item), + ))) + ); +} + +function isDagNode(value: unknown): boolean { + if (!isRecord(value)) return false; + if ( + !isDagId(value.id) || + !isDagId(value.fragmentId) || + !isDagId(value.pipelineId) || + !isNonNegativeInteger(value.ordinal) || + !isBoundedString(value.operatorType, 512) || + !isBoundedString(value.operatorFamily, 512) || + !isBoundedString(value.role, 128) || + !isBoundedString(value.label, 1_024) || + !isOptionalNullableSafeInteger(value.planNodeId) || + !isOptionalNullableSafeInteger(value.nereidsId) || + !isOptionalNullableSafeInteger(value.destId) || + !Array.isArray(value.destIds) || + value.destIds.length > 128 || + !value.destIds.every(isSafeInteger) || + typeof value.known !== 'boolean' || + !isNonNegativeInteger(value.lineNumber) || + !isBoundedScalarRecord(value.planInfo) + ) { + return false; + } + if (value.headerAttributes !== undefined && !isBoundedScalarRecord(value.headerAttributes)) return false; + if (value.timing !== undefined) { + if (!isRecord(value.timing)) return false; + if (value.timing.execTime !== undefined && !isExecTime(value.timing.execTime)) return false; + if (value.timing.waitTime !== undefined && !isWaitTime(value.timing.waitTime)) return false; + } + if (value.metrics !== undefined) { + if (!isRecord(value.metrics) || Object.keys(value.metrics).length > 64) return false; + if (!Object.values(value.metrics).every(metric => metric === null || isAggregateMetric(metric))) return false; + } + if (value.analysis !== undefined) { + if (!isRecord(value.analysis)) return false; + for (const heat of [value.analysis.heat, value.analysis.waitHeat]) { + if (heat !== undefined && heat !== null && !(typeof heat === 'number' && Number.isFinite(heat) && heat >= 0 && heat <= 1)) { + return false; + } + } + if (value.analysis.isBottleneck !== undefined && typeof value.analysis.isBottleneck !== 'boolean') { + return false; + } + } + return true; +} + +function isDagEdge(value: unknown, nodeIds: Set): boolean { + if (!isRecord(value)) return false; + const kinds = new Set([ + 'PIPELINE_DATA', + 'EXCHANGE', + 'LOCAL_EXCHANGE', + 'MULTICAST', + 'BUILD_DEPENDENCY', + 'BLOCKING_DEPENDENCY', + ]); + return ( + isDagId(value.id) && + typeof value.kind === 'string' && + kinds.has(value.kind) && + isDagId(value.source) && + isDagId(value.target) && + nodeIds.has(value.source) && + nodeIds.has(value.target) && + (value.relationId === undefined || + value.relationId === null || + isBoundedString(value.relationId, MAX_DAG_ID_BYTES)) && + value.resolved === true && + (value.metadata === undefined || isBoundedScalarRecord(value.metadata, true)) + ); +} + +function isDagGroup(value: unknown, kind: 'fragment' | 'pipeline', nodeIds: Set): boolean { + if (!isRecord(value) || !isDagId(value.id) || !isNonNegativeInteger(value.number)) return false; + if (!Array.isArray(value.nodeIds) || value.nodeIds.length > MAX_DAG_NODES) return false; + if (!value.nodeIds.every(nodeId => isDagId(nodeId) && nodeIds.has(nodeId))) return false; + if (kind === 'fragment') { + return ( + Array.isArray(value.pipelineIds) && + value.pipelineIds.length <= MAX_DAG_NODES && + value.pipelineIds.every(isDagId) + ); + } + return ( + isDagId(value.fragmentId) && + isNonNegativeInteger(value.instanceNum) && + (value.waitWorkerTime === undefined || + (isRecord(value.waitWorkerTime) && + Object.values(value.waitWorkerTime).every(isNullableNonNegativeInteger))) + ); +} + +function isUnresolvedReference(value: unknown, nodeIds: Set): boolean { + return ( + isRecord(value) && + isBoundedString(value.kind, 256) && + (value.relationId === undefined || + value.relationId === null || + isBoundedString(value.relationId, MAX_DAG_ID_BYTES)) && + isDagId(value.sourceNodeId) && + nodeIds.has(value.sourceNodeId) && + isBoundedString(value.reason, 1_024) + ); +} + +function isWarning(value: unknown): boolean { + if (!isRecord(value) || Object.keys(value).length > 16) return false; + return Object.entries(value).every(([key, item]) => { + if (!isBoundedString(key, 128)) return false; + if (key === 'lineNumber') return isNonNegativeInteger(item); + return item === null || isBoundedScalar(item); + }); +} + +function isDagSummary(value: unknown, nodeCount: number, edgeCount: number): boolean { + if (!isRecord(value)) return false; + return ( + isNonNegativeInteger(value.fragmentCount) && + isNonNegativeInteger(value.pipelineCount) && + value.nodeCount === nodeCount && + value.edgeCount === edgeCount && + isNonNegativeInteger(value.unresolvedEdgeCount) && + (value.criticalNodeId === undefined || value.criticalNodeId === null || isDagId(value.criticalNodeId)) && + (value.maxExecTimeNs === undefined || isNullableNonNegativeInteger(value.maxExecTimeNs)) && + (value.maxWaitTimeNs === undefined || isNullableNonNegativeInteger(value.maxWaitTimeNs)) + ); +} + +function isProfileDag(value: unknown, expectedJobId: string): value is ProfileDag { + if ( + !isRecord(value) || + value.schemaVersion !== '1.0' || + (value.parserVersion !== undefined && !isBoundedString(value.parserVersion, 128)) || + value.jobId !== expectedJobId || + !isRecord(value.profile) || + !isRecord(value.graph) || + value.graph.direction !== 'BOTTOM_TO_TOP' || + !Array.isArray(value.graph.nodes) || + !Array.isArray(value.graph.edges) || + value.graph.nodes.length > MAX_DAG_NODES || + value.graph.edges.length > MAX_DAG_EDGES || + !value.graph.nodes.every(isDagNode) + ) { + return false; + } + const nodeIds = new Set(value.graph.nodes.map(node => (node as Record).id as string)); + if (nodeIds.size !== value.graph.nodes.length) return false; + if (!value.graph.edges.every(edge => isDagEdge(edge, nodeIds))) return false; + const edgeIds = new Set(value.graph.edges.map(edge => (edge as Record).id as string)); + if (edgeIds.size !== value.graph.edges.length) return false; + if (!Array.isArray(value.fragments) || !value.fragments.every(fragment => isDagGroup(fragment, 'fragment', nodeIds))) { + return false; + } + if (!Array.isArray(value.pipelines) || !value.pipelines.every(pipeline => isDagGroup(pipeline, 'pipeline', nodeIds))) { + return false; + } + if ( + !Array.isArray(value.unresolvedReferences) || + value.unresolvedReferences.length > MAX_DAG_EDGES || + !value.unresolvedReferences.every(reference => isUnresolvedReference(reference, nodeIds)) || + !Array.isArray(value.warnings) || + value.warnings.length > MAX_DAG_NODES + MAX_DAG_EDGES || + !value.warnings.every(isWarning) || + !isDagSummary(value.summary, value.graph.nodes.length, value.graph.edges.length) + ) { + return false; + } + return true; +} + function invalidResponse(): ProfileAnalysisApiError { return new ProfileAnalysisApiError( 502, @@ -75,7 +358,11 @@ function apiUrl(apiBaseUrl: string, path: string): string { return `${apiBaseUrl.replace(/\/+$/, '')}${path}`; } -async function fetchJson(url: string, init: RequestInit): Promise<{ response: Response; body: unknown }> { +async function fetchJson( + url: string, + init: RequestInit, + maxResponseBytes = MAX_API_RESPONSE_BYTES, +): Promise<{ response: Response; body: unknown }> { let response: Response; try { response = await fetch(url, init); @@ -90,13 +377,15 @@ async function fetchJson(url: string, init: RequestInit): Promise<{ response: Re ); } - const body = await readJson(response); + const body = await readJson(response, maxResponseBytes); if (!response.ok) { - if (isApiErrorBody(body)) { + if (isRecord(body) && typeof body.code === 'string') { throw new ProfileAnalysisApiError( response.status, body.code, - body.message, + typeof body.message === 'string' + ? body.message + : `Profile analysis failed (${body.code}). Please try again.`, retryAfterMs(response), ); } @@ -110,7 +399,7 @@ async function fetchJson(url: string, init: RequestInit): Promise<{ response: Re return { response, body }; } -async function readJson(response: Response): Promise { +async function readJson(response: Response, maxResponseBytes: number): Promise { if (!response.body) return undefined; try { @@ -121,7 +410,7 @@ async function readJson(response: Response): Promise { const { done, value } = await reader.read(); if (done) break; totalBytes += value.byteLength; - if (totalBytes > MAX_API_RESPONSE_BYTES) { + if (totalBytes > maxResponseBytes) { await reader.cancel(); throw invalidResponse(); } @@ -213,7 +502,10 @@ export async function getAnalysisJobByClientRequestId( if (!isRecord(body) || !isJobId(body.jobId) || !isAnalysisJobStatus(body.status)) { throw invalidResponse(); } - return { jobId: body.jobId, status: body.status }; + if (body.dagStatus === undefined && body.dagError === undefined) { + return { jobId: body.jobId, status: body.status }; + } + return { jobId: body.jobId, status: body.status, ...parseDagJobState(body) }; } export async function getAnalysisJob( @@ -229,20 +521,51 @@ export async function getAnalysisJob( if (!isRecord(body) || body.jobId !== jobId) { throw invalidResponse(); } + const dagState = parseDagJobState(body); switch (body.status) { case 'QUEUED': if (!Number.isInteger(body.jobsAhead) || (body.jobsAhead as number) < 0) throw invalidResponse(); - return { jobId, status: 'QUEUED', jobsAhead: body.jobsAhead as number }; + return { jobId, status: 'QUEUED', jobsAhead: body.jobsAhead as number, ...dagState }; case 'RUNNING': - return { jobId, status: 'RUNNING' }; + return { jobId, status: 'RUNNING', ...dagState }; case 'COMPLETED': if (!isAgentMessage(body.result)) throw invalidResponse(); - return { jobId, status: 'COMPLETED', result: body.result }; + return { jobId, status: 'COMPLETED', result: body.result, ...dagState }; case 'FAILED': if (!isApiErrorBody(body.error)) throw invalidResponse(); - return { jobId, status: 'FAILED', error: body.error }; + return { jobId, status: 'FAILED', error: body.error, ...dagState }; default: throw invalidResponse(); } } + +export async function getProfileDag( + apiBaseUrl: string, + jobId: string, + signal?: AbortSignal, +): Promise { + if (!isJobId(jobId)) throw invalidResponse(); + const { response, body } = await fetchJson( + apiUrl(apiBaseUrl, `${ANALYSIS_JOBS_PATH}/${encodeURIComponent(jobId)}/dag`), + { method: 'GET', signal }, + MAX_DAG_RESPONSE_BYTES, + ); + + if (response.status === 202) { + if ( + !isRecord(body) || + body.jobId !== jobId || + (body.dagStatus !== 'PENDING' && body.dagStatus !== 'PARSING') + ) { + throw invalidResponse(); + } + return { + jobId, + dagStatus: body.dagStatus, + retryAfterMs: retryAfterMs(response) ?? 1_000, + }; + } + if (response.status !== 200 || !isProfileDag(body, jobId)) throw invalidResponse(); + return { dagStatus: 'READY', dag: body }; +} diff --git a/src/components/profile-analysis/profile-analysis.components.test.js b/src/components/profile-analysis/profile-analysis.components.test.js index 02b43f5cf34bb..df78dc17d6de0 100644 --- a/src/components/profile-analysis/profile-analysis.components.test.js +++ b/src/components/profile-analysis/profile-analysis.components.test.js @@ -235,3 +235,21 @@ test('the page composes the analyzer inside the Doris Layout without adding navi assert.match(pageSource, //); assert.match(pageSource, /
/); }); + +test('adds English result tabs and configures the execution graph as read-only', () => { + const analyzerSource = fs.readFileSync(path.join(__dirname, 'ProfileAnalyzer.tsx'), 'utf8'); + const dagSource = fs.readFileSync(path.join(__dirname, 'ProfileDag.tsx'), 'utf8'); + const dagNodeSource = fs.readFileSync(path.join(__dirname, 'ProfileDagNode.tsx'), 'utf8'); + + assert.match(analyzerSource, />\s*Execution graph\s*\s*AI analysis\s* ({ + id, + fragmentId, + pipelineId, + ordinal: 0, + operatorType: 'OLAP_SCAN_OPERATOR', + operatorFamily: 'SCAN', + role: 'SOURCE', + label: 'OLAP SCAN', + planNodeId: 1, + nereidsId: null, + destId: null, + destIds: [], + known: true, + lineNumber: 10, + planInfo: {}, + timing: {}, + metrics: {}, + analysis: { heat: null, waitHeat: null, isBottleneck: false }, + ...overrides, + }); + return { + schemaVersion: '1.0', + parserVersion: '0.2.0', + jobId: 'job-1', + profile: {}, + graph: { + direction: 'BOTTOM_TO_TOP', + nodes: [ + operator('fragment:0/pipeline:0/operator:0', 'fragment:0', 'fragment:0/pipeline:0'), + operator('fragment:1/pipeline:2/operator:0', 'fragment:1', 'fragment:1/pipeline:2', { + label: 'HASH JOIN', + operatorFamily: 'HASH_JOIN', + }), + ], + edges: [ + { + id: 'edge:data', + kind: 'EXCHANGE', + source: 'fragment:0/pipeline:0/operator:0', + target: 'fragment:1/pipeline:2/operator:0', + relationId: '35', + resolved: true, + metadata: { crossFragment: true, destId: 35 }, + }, + { + id: 'edge:dependency', + kind: 'BUILD_DEPENDENCY', + source: 'fragment:1/pipeline:2/operator:0', + target: 'fragment:0/pipeline:0/operator:0', + relationId: null, + resolved: true, + metadata: {}, + }, + ], + }, + fragments: [ + { + id: 'fragment:0', + number: 0, + pipelineIds: ['fragment:0/pipeline:0'], + nodeIds: ['fragment:0/pipeline:0/operator:0'], + }, + { + id: 'fragment:1', + number: 1, + pipelineIds: ['fragment:1/pipeline:2'], + nodeIds: ['fragment:1/pipeline:2/operator:0'], + }, + ], + pipelines: [ + { + id: 'fragment:0/pipeline:0', + fragmentId: 'fragment:0', + number: 0, + instanceNum: 1, + nodeIds: ['fragment:0/pipeline:0/operator:0'], + }, + { + id: 'fragment:1/pipeline:2', + fragmentId: 'fragment:1', + number: 2, + instanceNum: 64, + nodeIds: ['fragment:1/pipeline:2/operator:0'], + }, + ], + unresolvedReferences: [], + warnings: [], + summary: { + fragmentCount: 2, + pipelineCount: 2, + nodeCount: 2, + edgeCount: 2, + unresolvedEdgeCount: 0, + criticalNodeId: null, + maxExecTimeNs: null, + maxWaitTimeNs: null, + }, + }; +} + +test('builds one ELK compound parent per Fragment and keeps all cross-Fragment edges', () => { + const graph = buildElkGraph(fixture()); + + assert.equal(graph.layoutOptions['elk.direction'], 'UP'); + assert.equal(graph.layoutOptions['elk.hierarchyHandling'], 'INCLUDE_CHILDREN'); + assert.deepEqual(graph.children.map(fragment => fragment.id), ['fragment:0', 'fragment:1']); + assert.equal(graph.children[0].children[0].width, OPERATOR_NODE_WIDTH); + assert.equal(graph.children[0].children[0].height, OPERATOR_NODE_HEIGHT); + assert.deepEqual(graph.edges[0], { + id: 'edge:data', + sources: ['fragment:0/pipeline:0/operator:0'], + targets: ['fragment:1/pipeline:2/operator:0'], + }); +}); + +test('maps laid-out operators to fixed read-only child nodes and keeps pipeline as a badge', async () => { + const dag = fixture(); + const engine = { + async layout(graph) { + return { + ...graph, + children: graph.children.map((fragment, fragmentIndex) => ({ + ...fragment, + x: fragmentIndex * 400, + y: fragmentIndex * 200, + width: 300, + height: 220, + children: fragment.children.map(node => ({ ...node, x: 20, y: 60 })), + })), + }; + }, + }; + + const result = await layoutProfileDag(dag, engine); + const operator = result.nodes.find(node => node.id === 'fragment:1/pipeline:2/operator:0'); + const fragment = result.nodes.find(node => node.id === 'fragment:1'); + + assert.equal(fragment.data.label, 'Fragment 1'); + assert.equal(fragment.draggable, false); + assert.equal(operator.parentId, 'fragment:1'); + assert.equal(operator.extent, 'parent'); + assert.equal(operator.draggable, false); + assert.equal(operator.connectable, false); + assert.equal(operator.data.pipelineLabel, 'Pipeline 2'); + assert.equal(operator.data.instanceNum, 64); + assert.deepEqual(operator.position, { x: 20, y: 60 }); +}); + +test('visually distinguishes data edges from dependency edges without animation', async () => { + const graph = buildElkGraph(fixture()); + const result = await layoutProfileDag(fixture(), { layout: async () => graph }); + const data = result.edges.find(edge => edge.id === 'edge:data'); + const dependency = result.edges.find(edge => edge.id === 'edge:dependency'); + + assert.equal(data.data.dependency, false); + assert.equal(data.data.crossFragment, true); + assert.equal(data.style.strokeDasharray, undefined); + assert.equal(dependency.data.dependency, true); + assert.equal(dependency.data.crossFragment, false); + assert.equal(dependency.style.strokeDasharray, '7 5'); + assert.equal(dependency.animated, false); + assert.equal(isDependencyEdge('BLOCKING_DEPENDENCY'), true); + assert.equal(isDependencyEdge('MULTICAST'), false); +}); + +test('ELK lays out a complete graph containing a cross-Fragment edge', async () => { + const result = await layoutProfileDag(fixture()); + + assert.equal(result.nodes.length, 4); + assert.equal(result.edges.length, 2); + for (const node of result.nodes) { + assert.equal(Number.isFinite(node.position.x), true); + assert.equal(Number.isFinite(node.position.y), true); + } +}); + +test('formats unknown values distinctly from real zero values using English text', () => { + assert.equal(formatDurationNs(null), 'Unknown'); + assert.equal(formatDurationNs(0), '0 ns'); + assert.equal(formatDurationNs(1_500), '1.5 µs'); + assert.equal(formatDurationNs(2_500_000), '2.5 ms'); + assert.equal(formatDurationNs(3_000_000_000), '3 s'); + assert.equal(formatBytes(undefined), 'Unknown'); + assert.equal(formatBytes(0), '0 B'); + assert.equal(formatBytes(1536), '1.5 KiB'); + assert.equal(formatCount(null), 'Unknown'); + assert.equal(formatCount(0), '0'); + assert.equal(formatCount(120500), '120,500'); +}); diff --git a/src/components/profile-analysis/profile-analysis.dag.ts b/src/components/profile-analysis/profile-analysis.dag.ts new file mode 100644 index 0000000000000..d5932adb09dda --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.dag.ts @@ -0,0 +1,240 @@ +import type { Edge, Node } from '@xyflow/react'; + +import type { + ProfileDagEdge, + ProfileDagFragment, + ProfileDagNode, + ProfileDagResponse, +} from './profile-analysis.types'; + +export const OPERATOR_NODE_WIDTH = 224; +export const OPERATOR_NODE_HEIGHT = 104; +export const FRAGMENT_HEADER_HEIGHT = 42; + +const DATA_EDGE_COLOR = 'var(--ifm-color-emphasis-600)'; +const DEPENDENCY_EDGE_COLOR = 'var(--ifm-color-warning-dark)'; + +export type ProfileFlowNodeData = + | { + kind: 'fragment'; + fragmentId: string; + label: string; + } + | { + kind: 'operator'; + node: ProfileDagNode; + pipelineLabel: string; + instanceNum: number | null; + }; + +export interface ProfileFlowEdgeData extends Record { + kind: ProfileDagEdge['kind']; + relationId: string | null; + dependency: boolean; + crossFragment: boolean; +} + +export type ProfileFlowNode = Node; +export type ProfileFlowEdge = Edge; + +interface ElkNode { + id: string; + x?: number; + y?: number; + width?: number; + height?: number; + children?: ElkNode[]; + layoutOptions?: Record; +} + +interface ElkEdge { + id: string; + sources: string[]; + targets: string[]; +} + +export interface ElkGraph extends ElkNode { + children: ElkNode[]; + edges: ElkEdge[]; +} + +interface ElkLayoutEngine { + layout(graph: ElkGraph): Promise; +} + +export function isDependencyEdge(kind: ProfileDagEdge['kind']): boolean { + return kind === 'BUILD_DEPENDENCY' || kind === 'BLOCKING_DEPENDENCY'; +} + +export function formatDurationNs(value: number | null | undefined): string { + if (value == null) return 'Unknown'; + if (value < 1_000) return `${value} ns`; + if (value < 1_000_000) return `${formatDecimal(value / 1_000)} µs`; + if (value < 1_000_000_000) return `${formatDecimal(value / 1_000_000)} ms`; + return `${formatDecimal(value / 1_000_000_000)} s`; +} + +export function formatCount(value: number | null | undefined): string { + if (value == null) return 'Unknown'; + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value); +} + +export function formatBytes(value: number | null | undefined): string { + if (value == null) return 'Unknown'; + if (value < 1024) return `${value} B`; + const units = ['KiB', 'MiB', 'GiB', 'TiB']; + let amount = value / 1024; + let unitIndex = 0; + while (amount >= 1024 && unitIndex < units.length - 1) { + amount /= 1024; + unitIndex += 1; + } + return `${formatDecimal(amount)} ${units[unitIndex]}`; +} + +function formatDecimal(value: number): string { + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value); +} + +function fragmentNumber(fragment: ProfileDagFragment): number { + return fragment.number; +} + +function pipelineNumber(pipelineId: string): string { + const match = /\/pipeline:(\d+)$/.exec(pipelineId); + return match?.[1] ?? pipelineId; +} + +export function buildElkGraph(dag: ProfileDagResponse): ElkGraph { + const nodesByFragment = new Map(); + for (const node of dag.graph.nodes) { + const current = nodesByFragment.get(node.fragmentId) ?? []; + current.push(node); + nodesByFragment.set(node.fragmentId, current); + } + + const fragments = [...dag.fragments].sort((left, right) => fragmentNumber(left) - fragmentNumber(right)); + const knownFragmentIds = new Set(fragments.map(fragment => fragment.id)); + for (const fragmentId of nodesByFragment.keys()) { + if (!knownFragmentIds.has(fragmentId)) { + fragments.push({ id: fragmentId, number: Number.MAX_SAFE_INTEGER, pipelineIds: [], nodeIds: [] }); + } + } + + return { + id: 'profile-dag', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'UP', + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.layered.spacing.nodeNodeBetweenLayers': '90', + 'elk.spacing.nodeNode': '48', + 'elk.spacing.componentComponent': '64', + 'elk.padding': '[top=54,left=28,bottom=28,right=28]', + }, + children: fragments.map(fragment => ({ + id: fragment.id, + layoutOptions: { + 'elk.padding': `[top=${FRAGMENT_HEADER_HEIGHT + 16},left=20,bottom=20,right=20]`, + }, + children: (nodesByFragment.get(fragment.id) ?? []).map(node => ({ + id: node.id, + width: OPERATOR_NODE_WIDTH, + height: OPERATOR_NODE_HEIGHT, + })), + })), + edges: dag.graph.edges.map(edge => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })), + }; +} + +export async function layoutProfileDag( + dag: ProfileDagResponse, + engine?: ElkLayoutEngine, +): Promise<{ nodes: ProfileFlowNode[]; edges: ProfileFlowEdge[] }> { + const elk = engine ?? (await createElkEngine()); + const laidOut = await elk.layout(buildElkGraph(dag)); + const fragmentById = new Map(dag.fragments.map(fragment => [fragment.id, fragment])); + const pipelineById = new Map(dag.pipelines.map(pipeline => [pipeline.id, pipeline])); + const sourceNodeById = new Map(dag.graph.nodes.map(node => [node.id, node])); + const flowNodes: ProfileFlowNode[] = []; + + for (const fragmentLayout of laidOut.children ?? []) { + const fragment = fragmentById.get(fragmentLayout.id); + flowNodes.push({ + id: fragmentLayout.id, + type: 'profileFragment', + position: { x: fragmentLayout.x ?? 0, y: fragmentLayout.y ?? 0 }, + style: { width: fragmentLayout.width ?? OPERATOR_NODE_WIDTH + 40, height: fragmentLayout.height ?? 180 }, + data: { + kind: 'fragment', + fragmentId: fragmentLayout.id, + label: fragment ? `Fragment ${fragment.number}` : fragmentLayout.id, + }, + draggable: false, + selectable: false, + connectable: false, + }); + + for (const operatorLayout of fragmentLayout.children ?? []) { + const node = sourceNodeById.get(operatorLayout.id); + if (!node) continue; + const pipeline = pipelineById.get(node.pipelineId); + flowNodes.push({ + id: node.id, + type: 'profileOperator', + parentId: fragmentLayout.id, + extent: 'parent', + position: { x: operatorLayout.x ?? 0, y: operatorLayout.y ?? FRAGMENT_HEADER_HEIGHT }, + width: OPERATOR_NODE_WIDTH, + height: OPERATOR_NODE_HEIGHT, + data: { + kind: 'operator', + node, + pipelineLabel: `Pipeline ${pipeline?.number ?? pipelineNumber(node.pipelineId)}`, + instanceNum: pipeline?.instanceNum ?? null, + }, + draggable: false, + selectable: true, + connectable: false, + }); + } + } + + return { + nodes: flowNodes, + edges: dag.graph.edges.map(edge => { + const dependency = isDependencyEdge(edge.kind); + return { + id: edge.id, + source: edge.source, + target: edge.target, + type: 'smoothstep', + animated: false, + selectable: false, + reconnectable: false, + style: { + stroke: dependency ? DEPENDENCY_EDGE_COLOR : DATA_EDGE_COLOR, + strokeWidth: dependency ? 1.5 : 2, + strokeDasharray: dependency ? '7 5' : undefined, + }, + data: { + kind: edge.kind, + relationId: edge.relationId, + dependency, + crossFragment: edge.metadata?.crossFragment === true, + }, + }; + }), + }; +} + +async function createElkEngine(): Promise { + const module = await import('elkjs/lib/elk.bundled.js'); + const Elk = module.default; + return new Elk() as unknown as ElkLayoutEngine; +} diff --git a/src/components/profile-analysis/profile-analysis.recovery.test.js b/src/components/profile-analysis/profile-analysis.recovery.test.js index 6caa4592619ca..bcdd46ba41f12 100644 --- a/src/components/profile-analysis/profile-analysis.recovery.test.js +++ b/src/components/profile-analysis/profile-analysis.recovery.test.js @@ -199,6 +199,38 @@ test('keeps polling the same job after three transport failures and recovers on assert.deepEqual(waits, [2000, 4000, 8000, 2000]); }); +test('keeps polling after Codex completes until the independent DAG work is settled', async () => { + let getCalls = 0; + let dagSettled = false; + const snapshots = []; + + const terminal = await pollAnalysisJobWithRecovery({ + get: async () => { + getCalls += 1; + return { + jobId, + status: 'COMPLETED', + result: { id: 'item-1', type: 'agent_message', text: 'done' }, + dagStatus: getCalls === 1 ? 'PARSING' : 'READY', + dagError: null, + }; + }, + wait: async () => {}, + onRecovering: () => {}, + onProgress: () => {}, + onSnapshot: job => { + snapshots.push(job.dagStatus); + dagSettled = job.dagStatus === 'READY'; + }, + isComplete: () => dagSettled, + pollIntervalMs: 2000, + }); + + assert.equal(terminal.status, 'COMPLETED'); + assert.equal(getCalls, 2); + assert.deepEqual(snapshots, ['PARSING', 'READY']); +}); + test('treats a recovery 404 as final after the grace window', async () => { const createdAt = 20_000; await assert.rejects( diff --git a/src/components/profile-analysis/profile-analysis.recovery.ts b/src/components/profile-analysis/profile-analysis.recovery.ts index 257e79b15896b..af0bf42a9cef0 100644 --- a/src/components/profile-analysis/profile-analysis.recovery.ts +++ b/src/components/profile-analysis/profile-analysis.recovery.ts @@ -127,22 +127,30 @@ interface PollWithRecoveryOperations { wait(milliseconds: number): Promise; onRecovering(): void; onProgress(job: Extract): void; + onSnapshot?(job: AnalysisJobSnapshot): void | Promise; + isComplete?(job: AnalysisJobSnapshot): boolean; pollIntervalMs: number; random?: () => number; } export async function pollAnalysisJobWithRecovery( operations: PollWithRecoveryOperations, -): Promise> { +): Promise { let consecutiveFailures = 0; while (true) { try { const job = await operations.get(); consecutiveFailures = 0; - if (job.status === 'COMPLETED' || job.status === 'FAILED') { + await operations.onSnapshot?.(job); + const isComplete = operations.isComplete + ? operations.isComplete(job) + : job.status === 'COMPLETED' || job.status === 'FAILED'; + if (isComplete) { return job; } - operations.onProgress(job); + if (job.status === 'QUEUED' || job.status === 'RUNNING') { + operations.onProgress(job); + } await operations.wait(operations.pollIntervalMs); } catch (reason) { if (!isRetryableTransportFailure(reason)) throw reason; diff --git a/src/components/profile-analysis/profile-analysis.types.ts b/src/components/profile-analysis/profile-analysis.types.ts index 32f7559fcc113..584d685bd99f6 100644 --- a/src/components/profile-analysis/profile-analysis.types.ts +++ b/src/components/profile-analysis/profile-analysis.types.ts @@ -24,6 +24,14 @@ export type AnalysisState = export type AnalysisJobStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED'; +export type DagStatus = 'PENDING' | 'PARSING' | 'READY' | 'UNAVAILABLE' | 'FAILED'; +export type DagUiState = 'idle' | 'pending' | 'parsing' | 'loading' | 'ready' | 'unavailable' | 'failed'; + +export interface DagJobState { + dagStatus: DagStatus; + dagError: string | null; +} + export interface CreateAnalysisJobResponse { jobId: string; status: AnalysisJobStatus; @@ -33,10 +41,158 @@ export interface CreateAnalysisJobResponse { export interface RecoveredAnalysisJobResponse { jobId: string; status: AnalysisJobStatus; + dagStatus?: DagStatus; + dagError?: string | null; } -export type AnalysisJobSnapshot = +export type AnalysisJobSnapshot = DagJobState & ( | { jobId: string; status: 'QUEUED'; jobsAhead: number } | { jobId: string; status: 'RUNNING' } | { jobId: string; status: 'COMPLETED'; result: AgentMessage } - | { jobId: string; status: 'FAILED'; error: ApiErrorBody }; + | { jobId: string; status: 'FAILED'; error: ApiErrorBody } +); + +export type DagOperatorRole = + | 'SOURCE' + | 'SINK' + | 'PROBE' + | 'BUILD' + | 'PRODUCER' + | 'CONSUMER' + | 'UNKNOWN'; + +export interface DagAggregateMetric { + sum?: number | null; + avg?: number | null; + max?: number | null; + min?: number | null; +} + +export interface DagExecTime { + sumNs?: number | null; + avgNs?: number | null; + maxNs?: number | null; + minNs?: number | null; + display?: string; +} + +export interface DagWaitTime { + totalNs?: number | null; + maxNs?: number | null; + avgNs?: number | null; + display?: string; + breakdown?: Record; +} + +export interface ProfileDagNode { + id: string; + fragmentId: string; + pipelineId: string; + ordinal: number; + operatorType: string; + operatorFamily: string; + role: DagOperatorRole | string; + label: string; + planNodeId?: number | null; + nereidsId?: number | null; + destId?: number | null; + destIds: number[]; + known: boolean; + lineNumber: number; + headerAttributes?: Record>; + planInfo: Record>; + timing?: { + execTime?: DagExecTime; + waitTime?: DagWaitTime; + }; + metrics?: Record; + analysis?: { + heat?: number | null; + waitHeat?: number | null; + isBottleneck?: boolean; + }; +} + +export type DagEdgeKind = + | 'PIPELINE_DATA' + | 'EXCHANGE' + | 'LOCAL_EXCHANGE' + | 'MULTICAST' + | 'BUILD_DEPENDENCY' + | 'BLOCKING_DEPENDENCY'; + +export interface ProfileDagEdge { + id: string; + kind: DagEdgeKind; + source: string; + target: string; + relationId?: string | null; + resolved: true; + metadata?: Record; +} + +export interface ProfileDagFragment { + id: string; + number: number; + pipelineIds: string[]; + nodeIds: string[]; +} + +export interface ProfileDagPipeline { + id: string; + fragmentId: string; + number: number; + instanceNum: number; + nodeIds: string[]; + waitWorkerTime?: Record; +} + +export interface ProfileDagUnresolvedReference { + kind: string; + relationId?: string | null; + sourceNodeId: string; + reason: string; +} + +export interface ProfileDagWarning { + kind?: string; + code?: string; + nodeId?: string; + operatorType?: string; + message?: string; + lineNumber?: number; +} + +export interface ProfileDagSummary { + fragmentCount: number; + pipelineCount: number; + nodeCount: number; + edgeCount: number; + unresolvedEdgeCount: number; + criticalNodeId?: string | null; + maxExecTimeNs?: number | null; + maxWaitTimeNs?: number | null; +} + +export interface ProfileDag { + schemaVersion: '1.0'; + parserVersion?: string; + jobId: string; + profile: Record; + graph: { + direction: 'BOTTOM_TO_TOP'; + nodes: ProfileDagNode[]; + edges: ProfileDagEdge[]; + }; + fragments: ProfileDagFragment[]; + pipelines: ProfileDagPipeline[]; + unresolvedReferences: ProfileDagUnresolvedReference[]; + warnings: ProfileDagWarning[]; + summary: ProfileDagSummary; +} + +export type ProfileDagResponse = ProfileDag; + +export type ProfileDagFetchResult = + | { dagStatus: 'READY'; dag: ProfileDagResponse } + | { dagStatus: 'PENDING' | 'PARSING'; jobId: string; retryAfterMs: number }; diff --git a/src/components/profile-analysis/use-profile-analysis.test.js b/src/components/profile-analysis/use-profile-analysis.test.js index f4d6dfd991c87..96428fb06afa4 100644 --- a/src/components/profile-analysis/use-profile-analysis.test.js +++ b/src/components/profile-analysis/use-profile-analysis.test.js @@ -39,6 +39,9 @@ test('moves from idle through ready, analyzing, and completed', () => { jobsAhead: null, result: null, error: null, + dagState: 'idle', + dag: null, + dagError: null, recoveryWarning: null, }); @@ -46,7 +49,7 @@ test('moves from idle through ready, analyzing, and completed', () => { assert.equal(submitting.state, 'submitting'); const queued = profileAnalysisReducer(submitting, { type: 'job_created', jobId: 'job-1', status: 'QUEUED' }); const analyzing = profileAnalysisReducer(queued, { - type: 'job_status', job: { jobId: 'job-1', status: 'RUNNING' }, + type: 'job_status', job: { jobId: 'job-1', status: 'RUNNING', dagStatus: 'PARSING', dagError: null }, }); const completed = profileAnalysisReducer(analyzing, { type: 'complete', result }); @@ -72,6 +75,9 @@ test('stores failures and clears the old result and error when a new file is sel jobsAhead: null, result, error: null, + dagState: 'idle', + dag: null, + dagError: null, recoveryWarning: null, }; const failed = profileAnalysisReducer(completed, { type: 'fail', error: 'Analyzer unavailable' }); @@ -83,6 +89,9 @@ test('stores failures and clears the old result and error when a new file is sel jobsAhead: null, result: null, error: 'Analyzer unavailable', + dagState: 'failed', + dag: null, + dagError: 'The execution graph can no longer be recovered.', recoveryWarning: null, }); @@ -95,6 +104,9 @@ test('stores failures and clears the old result and error when a new file is sel jobsAhead: null, result: null, error: null, + dagState: 'idle', + dag: null, + dagError: null, recoveryWarning: null, }); }); @@ -108,6 +120,9 @@ test('stores response language per request, clears stale output, and freezes it jobsAhead: null, result, error: null, + dagState: 'idle', + dag: null, + dagError: null, recoveryWarning: null, }; const chinese = profileAnalysisReducer(completed, { type: 'set_language', language: 'zh-CN' }); @@ -119,6 +134,9 @@ test('stores response language per request, clears stale output, and freezes it jobsAhead: null, result: null, error: null, + dagState: 'idle', + dag: null, + dagError: null, recoveryWarning: null, }); @@ -145,6 +163,8 @@ test('restores persisted job metadata before polling resumes', () => { job: { jobId: '550e8400-e29b-41d4-a716-446655440000', status: 'RUNNING', + dagStatus: 'PARSING', + dagError: null, }, }); assert.equal(running.state, 'analyzing'); @@ -165,6 +185,9 @@ test('keeps an uncertain analysis busy while its original identifiers are recove jobsAhead: null, result: null, error: null, + dagState: 'parsing', + dag: null, + dagError: null, recoveryWarning: null, }; const recovering = profileAnalysisReducer(running, { type: 'recovering' }); @@ -175,6 +198,72 @@ test('keeps an uncertain analysis busy while its original identifiers are recove assert.equal(profileAnalysisReducer(recovering, { type: 'select', file: secondFile }), recovering); }); +test('keeps the current task busy when Codex completes before the execution graph', () => { + const ready = profileAnalysisReducer(idleSnapshot, { type: 'select', file: firstFile }); + const submitting = profileAnalysisReducer(ready, { type: 'start' }); + const queued = profileAnalysisReducer(submitting, { type: 'job_created', jobId: 'job-1', status: 'QUEUED' }); + const codexCompleted = profileAnalysisReducer(queued, { + type: 'job_status', + job: { + jobId: 'job-1', + status: 'COMPLETED', + result, + dagStatus: 'PARSING', + dagError: null, + }, + }); + + assert.equal(codexCompleted.state, 'completed'); + assert.equal(codexCompleted.dagState, 'parsing'); + assert.equal(profileAnalysisReducer(codexCompleted, { type: 'select', file: secondFile }), codexCompleted); + assert.equal(profileAnalysisReducer(codexCompleted, { type: 'start' }), codexCompleted); +}); + +test('keeps a ready execution graph when the independent AI analysis fails', () => { + const graph = { schemaVersion: '1.0', jobId: 'job-1' }; + const withGraph = profileAnalysisReducer( + { ...idleSnapshot, state: 'analyzing', jobId: 'job-1', dagState: 'loading' }, + { type: 'dag_loaded', dag: graph }, + ); + const failed = profileAnalysisReducer(withGraph, { + type: 'job_status', + job: { + jobId: 'job-1', + status: 'FAILED', + error: { code: 'CODEX_EXECUTION_FAILED', message: 'AI analysis failed.' }, + dagStatus: 'READY', + dagError: null, + }, + }); + + assert.equal(failed.state, 'failed'); + assert.equal(failed.error, 'AI analysis failed.'); + assert.equal(failed.dagState, 'ready'); + assert.equal(failed.dag, graph); +}); + +test('does not turn a terminal DAG client failure back into an endless loading state', () => { + const terminalDagFailure = { + ...idleSnapshot, + state: 'analyzing', + jobId: 'job-1', + dagState: 'failed', + dagError: 'The execution graph could not be loaded.', + }; + const next = profileAnalysisReducer(terminalDagFailure, { + type: 'job_status', + job: { + jobId: 'job-1', + status: 'RUNNING', + dagStatus: 'READY', + dagError: null, + }, + }); + + assert.equal(next.dagState, 'failed'); + assert.equal(next.dagError, terminalDagFailure.dagError); +}); + test('normalizes unknown failures without exposing non-error values', () => { assert.equal(getProfileAnalysisErrorMessage(new Error('Backend timed out')), 'Backend timed out'); assert.equal(getProfileAnalysisErrorMessage({ secret: 'internal detail' }), 'Profile analysis failed. Please try again.'); diff --git a/src/components/profile-analysis/use-profile-analysis.ts b/src/components/profile-analysis/use-profile-analysis.ts index 53cf446e5880f..cffadbe4697d2 100644 --- a/src/components/profile-analysis/use-profile-analysis.ts +++ b/src/components/profile-analysis/use-profile-analysis.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useReducer, useRef } from 'react'; import { createAnalysisJob, + getProfileDag, getAnalysisJob, getAnalysisJobByClientRequestId, ProfileAnalysisApiError, @@ -15,14 +16,19 @@ import { import { createOrRecoverAnalysisJob, DEFAULT_ANALYSIS_POLL_INTERVAL_MS, + isRetryableTransportFailure, pollAnalysisJobWithRecovery, recoverAnalysisJobWithinGrace, + retryDelayMs, } from './profile-analysis.recovery'; import type { AgentMessage, AnalysisJobSnapshot, AnalysisJobStatus, AnalysisState, + DagStatus, + DagUiState, + ProfileDagResponse, ResponseLanguage, } from './profile-analysis.types'; @@ -36,6 +42,9 @@ interface ProfileAnalysisSnapshot { jobsAhead: number | null; result: AgentMessage | null; error: string | null; + dagState: DagUiState; + dag: ProfileDagResponse | null; + dagError: string | null; recoveryWarning: string | null; } @@ -49,6 +58,10 @@ type ProfileAnalysisAction = | { type: 'start' } | { type: 'job_created'; jobId: string; status: AnalysisJobStatus } | { type: 'job_status'; job: AnalysisJobSnapshot } + | { type: 'dag_status'; status: Extract } + | { type: 'dag_loading'; error?: string | null } + | { type: 'dag_loaded'; dag: ProfileDagResponse } + | { type: 'dag_failed'; state: Extract; error: string } | { type: 'complete'; result: AgentMessage } | { type: 'fail'; error: string }; @@ -60,9 +73,42 @@ export const initialProfileAnalysisSnapshot: ProfileAnalysisSnapshot = { jobsAhead: null, result: null, error: null, + dagState: 'idle', + dag: null, + dagError: null, recoveryWarning: null, }; +function dagErrorMessage(status: Extract, code: string | null): string { + if (code === 'DAG_TOO_LARGE') { + return 'This execution graph is too large to display.'; + } + if (status === 'UNAVAILABLE') { + return 'An execution graph is not available for this Profile.'; + } + return 'The execution graph could not be generated.'; +} + +function dagStateFromJob(snapshot: ProfileAnalysisSnapshot, job: AnalysisJobSnapshot): Pick< + ProfileAnalysisSnapshot, + 'dagState' | 'dagError' +> { + if (snapshot.dag) return { dagState: 'ready', dagError: null }; + // A client-side schema or rendering failure is terminal for this DAG. The + // backend continues to report READY on later job polls, but that must not + // turn the terminal error back into an endless local loading state. + if (snapshot.dagState === 'failed' || snapshot.dagState === 'unavailable') { + return { dagState: snapshot.dagState, dagError: snapshot.dagError }; + } + if (job.dagStatus === 'PENDING') return { dagState: 'pending', dagError: null }; + if (job.dagStatus === 'PARSING') return { dagState: 'parsing', dagError: null }; + if (job.dagStatus === 'READY') return { dagState: 'loading', dagError: null }; + if (job.dagStatus === 'UNAVAILABLE') { + return { dagState: 'unavailable', dagError: dagErrorMessage('UNAVAILABLE', job.dagError) }; + } + return { dagState: 'failed', dagError: dagErrorMessage('FAILED', job.dagError) }; +} + export function profileAnalysisReducer( snapshot: ProfileAnalysisSnapshot, action: ProfileAnalysisAction, @@ -80,6 +126,9 @@ export function profileAnalysisReducer( jobsAhead: null, result: null, error: null, + dagState: 'idle', + dag: null, + dagError: null, }; case 'recovering': return { @@ -91,7 +140,7 @@ export function profileAnalysisReducer( case 'storage_unavailable': return { ...snapshot, recoveryWarning: STORAGE_UNAVAILABLE_WARNING }; case 'select': - if (isBusy(snapshot.state)) { + if (isSnapshotBusy(snapshot)) { return snapshot; } return { @@ -100,12 +149,15 @@ export function profileAnalysisReducer( language: snapshot.language, result: null, error: null, + dagState: 'idle', + dag: null, + dagError: null, jobId: null, jobsAhead: null, recoveryWarning: snapshot.recoveryWarning, }; case 'set_language': - if (isBusy(snapshot.state)) { + if (isSnapshotBusy(snapshot)) { return snapshot; } return { @@ -116,9 +168,12 @@ export function profileAnalysisReducer( jobsAhead: null, result: null, error: null, + dagState: 'idle', + dag: null, + dagError: null, }; case 'start': - if (!snapshot.file || isBusy(snapshot.state)) { + if (!snapshot.file || isSnapshotBusy(snapshot)) { return snapshot; } return { @@ -128,6 +183,9 @@ export function profileAnalysisReducer( jobsAhead: null, result: null, error: null, + dagState: 'pending', + dag: null, + dagError: null, }; case 'job_created': return { @@ -137,20 +195,65 @@ export function profileAnalysisReducer( state: action.status === 'QUEUED' ? 'queued' : 'analyzing', jobId: action.jobId, jobsAhead: null, + dagState: 'pending', + dag: null, + dagError: null, }; - case 'job_status': + case 'job_status': { + const dagSnapshot = dagStateFromJob(snapshot, action.job); if (action.job.status === 'QUEUED') { return { ...snapshot, + ...dagSnapshot, state: 'queued', jobId: action.job.jobId, jobsAhead: action.job.jobsAhead, }; } if (action.job.status === 'RUNNING') { - return { ...snapshot, state: 'analyzing', jobId: action.job.jobId, jobsAhead: null }; + return { + ...snapshot, + ...dagSnapshot, + state: 'analyzing', + jobId: action.job.jobId, + jobsAhead: null, + }; } - return snapshot; + if (action.job.status === 'COMPLETED') { + return { + ...snapshot, + ...dagSnapshot, + state: 'completed', + jobId: action.job.jobId, + jobsAhead: null, + result: action.job.result, + error: null, + }; + } + return { + ...snapshot, + ...dagSnapshot, + state: 'failed', + jobId: action.job.jobId, + jobsAhead: null, + result: null, + error: action.job.error.message, + }; + } + case 'dag_status': + return snapshot.dag + ? snapshot + : { ...snapshot, dagState: action.status === 'PENDING' ? 'pending' : 'parsing', dagError: null }; + case 'dag_loading': + return snapshot.dag + ? snapshot + : { ...snapshot, dagState: 'loading', dagError: action.error ?? null }; + case 'dag_loaded': + return { ...snapshot, dagState: 'ready', dag: action.dag, dagError: null }; + case 'dag_failed': + return snapshot.dag + ? snapshot + : { ...snapshot, dagState: action.state, dag: null, dagError: action.error }; case 'complete': return { ...snapshot, @@ -166,6 +269,12 @@ export function profileAnalysisReducer( result: null, error: action.error, jobsAhead: null, + dagState: snapshot.dag ? 'ready' : snapshot.jobId ? 'failed' : 'idle', + dagError: snapshot.dag + ? null + : snapshot.jobId + ? 'The execution graph can no longer be recovered.' + : null, }; } } @@ -180,6 +289,14 @@ function isBusy(state: AnalysisState): boolean { ); } +function isDagBusy(state: DagUiState): boolean { + return state === 'pending' || state === 'parsing' || state === 'loading'; +} + +function isSnapshotBusy(snapshot: ProfileAnalysisSnapshot): boolean { + return isBusy(snapshot.state) || isDagBusy(snapshot.dagState); +} + function wait(milliseconds: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const handleAbort = () => { @@ -228,27 +345,85 @@ export function useProfileAnalysis(apiBaseUrl: string) { const pollJob = useCallback( async (jobId: string, pollIntervalMs: number, controller: AbortController): Promise => { - const terminal = await pollAnalysisJobWithRecovery({ + let codexSettled = false; + let dagSettled = false; + let dagFailureCount = 0; + await pollAnalysisJobWithRecovery({ get: () => getAnalysisJob(apiBaseUrl, jobId, controller.signal), wait: milliseconds => wait(milliseconds, controller.signal), onRecovering: () => { if (mountedRef.current && abortControllerRef.current === controller) { - dispatch({ type: 'recovering' }); + if (codexSettled) { + dispatch({ + type: 'dag_loading', + error: 'Connection interrupted. Retrying the execution graph…', + }); + } else { + dispatch({ type: 'recovering' }); + } } }, - onProgress: job => { - if (mountedRef.current && abortControllerRef.current === controller) { - dispatch({ type: 'job_status', job }); + onProgress: () => {}, + onSnapshot: async job => { + if (!mountedRef.current || abortControllerRef.current !== controller) return; + codexSettled = job.status === 'COMPLETED' || job.status === 'FAILED'; + dispatch({ type: 'job_status', job }); + + if (job.dagStatus === 'UNAVAILABLE' || job.dagStatus === 'FAILED') { + dagSettled = true; + return; + } + if (job.dagStatus !== 'READY' || dagSettled) return; + + dispatch({ type: 'dag_loading' }); + try { + const dagResult = await getProfileDag(apiBaseUrl, jobId, controller.signal); + dagFailureCount = 0; + if (!mountedRef.current || abortControllerRef.current !== controller) return; + if (dagResult.dagStatus === 'READY') { + dagSettled = true; + dispatch({ type: 'dag_loaded', dag: dagResult.dag }); + } else { + dispatch({ type: 'dag_status', status: dagResult.dagStatus }); + } + } catch (reason) { + if (isAbortError(reason)) throw reason; + if (reason instanceof ProfileAnalysisApiError && reason.status === 404) { + throw reason; + } + if (reason instanceof ProfileAnalysisApiError && reason.status === 409) { + dagSettled = true; + dispatch({ + type: 'dag_failed', + state: 'unavailable', + error: dagErrorMessage('UNAVAILABLE', reason.code), + }); + return; + } + if ( + reason instanceof ProfileAnalysisApiError && + reason.code !== 'INVALID_SERVER_RESPONSE' && + isRetryableTransportFailure(reason) + ) { + dagFailureCount += 1; + dispatch({ + type: 'dag_loading', + error: 'Connection interrupted. Retrying the execution graph…', + }); + await wait(retryDelayMs(dagFailureCount, pollIntervalMs), controller.signal); + return; + } + dagSettled = true; + dispatch({ + type: 'dag_failed', + state: 'failed', + error: 'The execution graph could not be loaded.', + }); } }, + isComplete: () => codexSettled && dagSettled, pollIntervalMs, }); - if (!mountedRef.current || abortControllerRef.current !== controller) return; - if (terminal.status === 'COMPLETED') { - dispatch({ type: 'complete', result: terminal.result }); - } else { - dispatch({ type: 'fail', error: terminal.error.message }); - } }, [apiBaseUrl], ); @@ -437,7 +612,7 @@ export function useProfileAnalysis(apiBaseUrl: string) { return { ...snapshot, - isBusy: isBusy(snapshot.state), + isBusy: isSnapshotBusy(snapshot), selectFile, setLanguage, analyze, diff --git a/yarn.lock b/yarn.lock index 6c9ce497ef728..181ef1e74c533 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3565,6 +3565,45 @@ dependencies: "@types/node" "*" +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-drag@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-selection@*", "@types/d3-selection@^3.0.10": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" + integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== + +"@types/d3-transition@^3.0.8": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706" + integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -4037,6 +4076,30 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@xyflow/react@^12.11.2": + version "12.11.2" + resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.11.2.tgz#1c492c7ed6544a33fec8014862e9e662ac20084e" + integrity sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA== + dependencies: + "@xyflow/system" "0.0.79" + classcat "^5.0.3" + zustand "^4.4.0" + +"@xyflow/system@0.0.79": + version "0.0.79" + resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.79.tgz#3459a0b365776f4a579df54962e42e201567c192" + integrity sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA== + dependencies: + "@types/d3-drag" "^3.0.7" + "@types/d3-interpolate" "^3.0.4" + "@types/d3-selection" "^3.0.10" + "@types/d3-transition" "^3.0.8" + "@types/d3-zoom" "^3.0.8" + d3-drag "^3.0.0" + d3-interpolate "^3.0.1" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + "@yang1666204/docusaurus-search-local@0.0.7": version "0.0.7" resolved "https://registry.yarnpkg.com/@yang1666204/docusaurus-search-local/-/docusaurus-search-local-0.0.7.tgz#09adbf405414da87d4f6aa9b2dd582037b94729e" @@ -4819,6 +4882,11 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +classcat@^5.0.3: + version "5.0.5" + resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" + integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w== + classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2, classnames@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" @@ -5347,6 +5415,68 @@ csstype@^3.0.2, csstype@^3.1.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3", d3-drag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-ease@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-interpolate@1 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +"d3-timer@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +"d3-transition@2 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +d3-zoom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + data-view-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" @@ -5734,6 +5864,11 @@ electron-to-chromium@^1.5.73: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz#3d02025bc16e96e5edb3ed3ffa2538a11ae682dc" integrity sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA== +elkjs@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.12.0.tgz#7dc1bc71ab8f402d1b6564e2fa509ca1caee276c" + integrity sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -12323,6 +12458,11 @@ url-loader@^4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" +use-sync-external-store@^1.2.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -12925,6 +13065,13 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110" integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== +zustand@^4.4.0: + version "4.5.7" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55" + integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw== + dependencies: + use-sync-external-store "^1.2.2" + zwitch@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" From 33f8fc4ab38a91fc8f5aa26f93bd67928e31e7f5 Mon Sep 17 00:00:00 2001 From: mary <2723253023@qq.com> Date: Fri, 7 Aug 2026 11:18:18 +0800 Subject: [PATCH 2/5] fix: render directed orthogonal profile edges --- .../profile-analysis/ProfileDag.scss | 20 ++++- .../profile-analysis/ProfileDag.tsx | 8 +- .../profile-analysis/ProfileDagEdge.tsx | 22 +++++ .../profile-analysis.components.test.js | 2 + .../profile-analysis.dag.test.js | 43 +++++++++ .../profile-analysis/profile-analysis.dag.ts | 89 ++++++++++++++++++- 6 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 src/components/profile-analysis/ProfileDagEdge.tsx diff --git a/src/components/profile-analysis/ProfileDag.scss b/src/components/profile-analysis/ProfileDag.scss index 3e022ad2667fb..4b210ec413aea 100644 --- a/src/components/profile-analysis/ProfileDag.scss +++ b/src/components/profile-analysis/ProfileDag.scss @@ -32,13 +32,31 @@ } &__legend-line { + position: relative; display: inline-block; width: 1.5rem; - border-top: 2px solid var(--ifm-color-emphasis-600); + border-top: 2px solid #667085; + + &::after { + position: absolute; + top: -4px; + right: -1px; + width: 0; + height: 0; + border-top: 3px solid transparent; + border-bottom: 3px solid transparent; + border-left: 6px solid #667085; + content: ''; + } } &__legend-line--dependency { + border-top-color: #d98b00; border-top-style: dashed; + + &::after { + border-left-color: #d98b00; + } } &__legend-swatch { diff --git a/src/components/profile-analysis/ProfileDag.tsx b/src/components/profile-analysis/ProfileDag.tsx index bd0ca576ce3c5..3beac37750a47 100644 --- a/src/components/profile-analysis/ProfileDag.tsx +++ b/src/components/profile-analysis/ProfileDag.tsx @@ -19,6 +19,7 @@ import { type ProfileFlowNode, } from './profile-analysis.dag'; import { ProfileDagFragmentNode, ProfileDagNode } from './ProfileDagNode'; +import { ProfileDagEdge } from './ProfileDagEdge'; import './ProfileDag.scss'; export interface ProfileDagProps { @@ -32,6 +33,10 @@ const nodeTypes = { profileFragment: ProfileDagFragmentNode, }; +const edgeTypes = { + profileElk: ProfileDagEdge, +}; + const stateMessages: Partial> = { idle: 'The execution graph will appear after an analysis starts.', pending: 'Waiting to parse the execution graph…', @@ -207,6 +212,7 @@ function ProfileDagCanvas({ dag }: { dag: ProfileDagResponse }): JSX.Element { nodes={nodes} edges={edges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodeClick={onNodeClick} nodesDraggable={false} nodesConnectable={false} @@ -249,7 +255,7 @@ export function ProfileDag({ state, dag, error = null }: ProfileDagProps): JSX.E
Data flow - Execution dependency + Execution dependency (prerequisite → dependent) Longer execution Longer wait
diff --git a/src/components/profile-analysis/ProfileDagEdge.tsx b/src/components/profile-analysis/ProfileDagEdge.tsx new file mode 100644 index 0000000000000..20b6188e506d4 --- /dev/null +++ b/src/components/profile-analysis/ProfileDagEdge.tsx @@ -0,0 +1,22 @@ +import React, { JSX } from 'react'; +import { BaseEdge, StepEdge, type EdgeProps } from '@xyflow/react'; +import type { ProfileFlowEdge } from './profile-analysis.dag'; + +export function ProfileDagEdge(props: EdgeProps): JSX.Element { + const { data, id, interactionWidth, markerEnd, markerStart, style } = props; + + if (!data?.elkPath) { + return ; + } + + return ( + + ); +} diff --git a/src/components/profile-analysis/profile-analysis.components.test.js b/src/components/profile-analysis/profile-analysis.components.test.js index df78dc17d6de0..f270a24896ddd 100644 --- a/src/components/profile-analysis/profile-analysis.components.test.js +++ b/src/components/profile-analysis/profile-analysis.components.test.js @@ -248,6 +248,8 @@ test('adds English result tabs and configures the execution graph as read-only', assert.match(dagSource, /nodesDraggable=\{false\}/); assert.match(dagSource, /nodesConnectable=\{false\}/); assert.match(dagSource, /edgesReconnectable=\{false\}/); + assert.match(dagSource, /edgeTypes=\{edgeTypes\}/); + assert.match(dagSource, /Execution dependency \(prerequisite → dependent\)/); assert.match(dagSource, /deleteKeyCode=\{null\}/); assert.match(dagSource, /panOnDrag/); assert.match(dagSource, /zoomOnScroll/); diff --git a/src/components/profile-analysis/profile-analysis.dag.test.js b/src/components/profile-analysis/profile-analysis.dag.test.js index 4801e15a3271e..752040b2fecd3 100644 --- a/src/components/profile-analysis/profile-analysis.dag.test.js +++ b/src/components/profile-analysis/profile-analysis.dag.test.js @@ -187,15 +187,58 @@ test('visually distinguishes data edges from dependency edges without animation' assert.equal(data.data.dependency, false); assert.equal(data.data.crossFragment, true); + assert.equal(data.type, 'profileElk'); + assert.equal(data.data.elkPath, null); + assert.equal(data.markerEnd.type, 'arrowclosed'); assert.equal(data.style.strokeDasharray, undefined); assert.equal(dependency.data.dependency, true); assert.equal(dependency.data.crossFragment, false); + assert.equal(dependency.markerEnd.type, 'arrow'); assert.equal(dependency.style.strokeDasharray, '7 5'); assert.equal(dependency.animated, false); assert.equal(isDependencyEdge('BLOCKING_DEPENDENCY'), true); assert.equal(isDependencyEdge('MULTICAST'), false); }); +test('uses simplified orthogonal ELK sections as absolute React Flow paths', async () => { + const dag = fixture(); + const graph = buildElkGraph(dag); + const result = await layoutProfileDag(dag, { + async layout() { + return { + ...graph, + children: graph.children.map((fragment, index) => ({ + ...fragment, + x: index * 500, + y: index * 300, + width: 300, + height: 240, + children: fragment.children.map(node => ({ ...node, x: 20, y: 60 })), + })), + edges: graph.edges.map((edge, index) => ({ + ...edge, + container: index === 0 ? 'profile-dag' : 'fragment:1', + sections: [{ + startPoint: { x: 10, y: 50 }, + bendPoints: [ + { x: 10, y: 40 }, + { x: 10, y: 30 }, + { x: 80, y: 30 }, + ], + endPoint: { x: 80, y: 20 }, + }], + })), + }; + }, + }); + + const rootEdge = result.edges.find(edge => edge.id === 'edge:data'); + const fragmentEdge = result.edges.find(edge => edge.id === 'edge:dependency'); + assert.equal(rootEdge.data.elkPath, 'M 10 50 L 10 30 L 80 30 L 80 20'); + assert.equal(fragmentEdge.data.elkPath, 'M 510 350 L 510 330 L 580 330 L 580 320'); + assert.doesNotMatch(rootEdge.data.elkPath, /[CQ]/); +}); + test('ELK lays out a complete graph containing a cross-Fragment edge', async () => { const result = await layoutProfileDag(fixture()); diff --git a/src/components/profile-analysis/profile-analysis.dag.ts b/src/components/profile-analysis/profile-analysis.dag.ts index d5932adb09dda..ebdfa232a4e47 100644 --- a/src/components/profile-analysis/profile-analysis.dag.ts +++ b/src/components/profile-analysis/profile-analysis.dag.ts @@ -1,4 +1,4 @@ -import type { Edge, Node } from '@xyflow/react'; +import { MarkerType, type Edge, type Node } from '@xyflow/react'; import type { ProfileDagEdge, @@ -11,8 +11,8 @@ export const OPERATOR_NODE_WIDTH = 224; export const OPERATOR_NODE_HEIGHT = 104; export const FRAGMENT_HEADER_HEIGHT = 42; -const DATA_EDGE_COLOR = 'var(--ifm-color-emphasis-600)'; -const DEPENDENCY_EDGE_COLOR = 'var(--ifm-color-warning-dark)'; +const DATA_EDGE_COLOR = '#667085'; +const DEPENDENCY_EDGE_COLOR = '#d98b00'; export type ProfileFlowNodeData = | { @@ -32,6 +32,7 @@ export interface ProfileFlowEdgeData extends Record { relationId: string | null; dependency: boolean; crossFragment: boolean; + elkPath: string | null; } export type ProfileFlowNode = Node; @@ -51,6 +52,19 @@ interface ElkEdge { id: string; sources: string[]; targets: string[]; + container?: string; + sections?: ElkEdgeSection[]; +} + +interface ElkPoint { + x: number; + y: number; +} + +interface ElkEdgeSection { + startPoint: ElkPoint; + bendPoints?: ElkPoint[]; + endPoint: ElkPoint; } export interface ElkGraph extends ElkNode { @@ -62,6 +76,53 @@ interface ElkLayoutEngine { layout(graph: ElkGraph): Promise; } +function isFinitePoint(point: ElkPoint | undefined): point is ElkPoint { + return point !== undefined && Number.isFinite(point.x) && Number.isFinite(point.y); +} + +function simplifyOrthogonalPoints(points: ElkPoint[]): ElkPoint[] { + const simplified: ElkPoint[] = []; + for (const point of points) { + const previous = simplified[simplified.length - 1]; + if (previous && previous.x === point.x && previous.y === point.y) continue; + + const beforePrevious = simplified[simplified.length - 2]; + if ( + beforePrevious && + previous && + ((beforePrevious.x === previous.x && previous.x === point.x) || + (beforePrevious.y === previous.y && previous.y === point.y)) + ) { + simplified[simplified.length - 1] = point; + } else { + simplified.push(point); + } + } + return simplified; +} + +function elkEdgePath(edge: ElkEdge | undefined, containerOffset: ElkPoint | undefined): string | null { + if (!edge?.sections?.length || !containerOffset) return null; + const subpaths: string[] = []; + for (const section of edge.sections) { + const points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]; + if (!points.every(isFinitePoint)) return null; + const absolutePoints = simplifyOrthogonalPoints( + points.map(point => ({ + x: point.x + containerOffset.x, + y: point.y + containerOffset.y, + })), + ); + if (absolutePoints.length < 2) return null; + subpaths.push( + absolutePoints + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' '), + ); + } + return subpaths.join(' '); +} + export function isDependencyEdge(kind: ProfileDagEdge['kind']): boolean { return kind === 'BUILD_DEPENDENCY' || kind === 'BLOCKING_DEPENDENCY'; } @@ -161,6 +222,13 @@ export async function layoutProfileDag( const fragmentById = new Map(dag.fragments.map(fragment => [fragment.id, fragment])); const pipelineById = new Map(dag.pipelines.map(pipeline => [pipeline.id, pipeline])); const sourceNodeById = new Map(dag.graph.nodes.map(node => [node.id, node])); + const laidOutEdgeById = new Map((laidOut.edges ?? []).map(edge => [edge.id, edge])); + const containerOffsetById = new Map([ + [laidOut.id, { x: 0, y: 0 }], + ...(laidOut.children ?? []).map( + fragment => [fragment.id, { x: fragment.x ?? 0, y: fragment.y ?? 0 }] as const, + ), + ]); const flowNodes: ProfileFlowNode[] = []; for (const fragmentLayout of laidOut.children ?? []) { @@ -209,11 +277,16 @@ export async function layoutProfileDag( nodes: flowNodes, edges: dag.graph.edges.map(edge => { const dependency = isDependencyEdge(edge.kind); + const laidOutEdge = laidOutEdgeById.get(edge.id); + const elkPath = elkEdgePath( + laidOutEdge, + containerOffsetById.get(laidOutEdge?.container ?? laidOut.id), + ); return { id: edge.id, source: edge.source, target: edge.target, - type: 'smoothstep', + type: 'profileElk', animated: false, selectable: false, reconnectable: false, @@ -222,11 +295,19 @@ export async function layoutProfileDag( strokeWidth: dependency ? 1.5 : 2, strokeDasharray: dependency ? '7 5' : undefined, }, + markerEnd: { + type: dependency ? MarkerType.Arrow : MarkerType.ArrowClosed, + color: dependency ? DEPENDENCY_EDGE_COLOR : DATA_EDGE_COLOR, + width: 10, + height: 10, + strokeWidth: dependency ? 1.5 : 1, + }, data: { kind: edge.kind, relationId: edge.relationId, dependency, crossFragment: edge.metadata?.crossFragment === true, + elkPath, }, }; }), From 369eba61db92e3fc2e8b2330255c7496b38d8aea Mon Sep 17 00:00:00 2001 From: mary <2723253023@qq.com> Date: Fri, 7 Aug 2026 19:22:28 +0800 Subject: [PATCH 3/5] feat: refine profile parser workflow --- .../profile-analysis/ProfileAnalysis.scss | 84 +++ .../profile-analysis/ProfileAnalyzer.tsx | 68 +- .../profile-analysis/ProfileDag.tsx | 4 +- .../profile-analysis/ProfileUploader.tsx | 306 +++++---- .../profile-analysis.api.test.js | 241 ------- .../profile-analysis/profile-analysis.api.ts | 328 +--------- .../profile-analysis.components.test.js | 43 +- .../profile-analysis.parser-client.test.js | 105 +++ .../profile-analysis.parser-client.ts | 92 +++ .../profile-analysis.parser-protocol.ts | 23 + .../profile-analysis.parser.test.js | 187 ++++++ .../profile-analysis.parser.ts | 602 ++++++++++++++++++ .../profile-analysis.parser.worker.ts | 39 ++ .../profile-analysis.recovery.test.js | 32 - .../profile-analysis.types.ts | 26 +- .../use-local-profile-dag.test.js | 43 ++ .../profile-analysis/use-local-profile-dag.ts | 107 ++++ .../use-profile-analysis.test.js | 89 +-- .../profile-analysis/use-profile-analysis.ts | 173 +---- 19 files changed, 1557 insertions(+), 1035 deletions(-) create mode 100644 src/components/profile-analysis/profile-analysis.parser-client.test.js create mode 100644 src/components/profile-analysis/profile-analysis.parser-client.ts create mode 100644 src/components/profile-analysis/profile-analysis.parser-protocol.ts create mode 100644 src/components/profile-analysis/profile-analysis.parser.test.js create mode 100644 src/components/profile-analysis/profile-analysis.parser.ts create mode 100644 src/components/profile-analysis/profile-analysis.parser.worker.ts create mode 100644 src/components/profile-analysis/use-local-profile-dag.test.js create mode 100644 src/components/profile-analysis/use-local-profile-dag.ts diff --git a/src/components/profile-analysis/ProfileAnalysis.scss b/src/components/profile-analysis/ProfileAnalysis.scss index 092eb58d00564..63fcaba73cd6d 100644 --- a/src/components/profile-analysis/ProfileAnalysis.scss +++ b/src/components/profile-analysis/ProfileAnalysis.scss @@ -287,6 +287,72 @@ font-size: 0.9rem; } + &__actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + margin-top: 1.25rem; + } + + &__action-card { + min-width: 0; + padding: 1rem; + border: 1px solid var(--brand-border-soft); + border-radius: 10px; + background: var(--brand-surface-soft); + + h3 { + margin: 0 0 0.4rem; + font-size: 1.05rem; + } + + > p { + margin-bottom: 1rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.9rem; + } + + &--local { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, auto); + grid-template-rows: auto auto; + column-gap: 2rem; + align-items: center; + padding: 1.25rem; + border-left: 4px solid var(--brand-primary); + background: var(--brand-surface-callout); + + h3 { + grid-column: 1; + grid-row: 1; + } + + > p { + grid-column: 1; + grid-row: 2; + margin: 0; + } + + .profile-analysis__action-button { + grid-column: 2; + grid-row: 1 / span 2; + min-width: 220px; + margin: 0; + padding: 0.8rem 1.25rem; + box-shadow: 0 6px 16px rgb(var(--brand-shadow-rgb) / 14%); + } + } + + &--ai { + padding: 1.25rem; + } + } + + &__action-button { + width: 100%; + margin-top: 1rem; + } + &__captcha { max-width: 100%; margin-top: 1.25rem; @@ -441,6 +507,24 @@ gap: 0.5rem; } + &__actions { + grid-template-columns: 1fr; + } + + &__action-card--local { + display: block; + + > p { + margin-bottom: 1rem; + } + + .profile-analysis__action-button { + width: 100%; + min-width: 0; + margin-top: 0.5rem; + } + } + &__file, &__warning, &__error { diff --git a/src/components/profile-analysis/ProfileAnalyzer.tsx b/src/components/profile-analysis/ProfileAnalyzer.tsx index 662157e5fac7f..4d4f1ad0f3592 100644 --- a/src/components/profile-analysis/ProfileAnalyzer.tsx +++ b/src/components/profile-analysis/ProfileAnalyzer.tsx @@ -1,10 +1,12 @@ -import React, { JSX, useEffect, useId, useState } from 'react'; +import React, { JSX, useCallback, useId, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { AnalysisResult } from './AnalysisResult'; import { AnalysisStatus } from './AnalysisStatus'; import { ProfileUploader } from './ProfileUploader'; import { ProfileDag } from './ProfileDag'; import { useProfileAnalysis } from './use-profile-analysis'; +import { useLocalProfileDag } from './use-local-profile-dag'; +import type { ProfileParserWorker } from './profile-analysis.parser-client'; import './ProfileAnalysis.scss'; export function ProfileAnalyzer(): JSX.Element { @@ -15,9 +17,18 @@ export function ProfileAnalyzer(): JSX.Element { const hcaptchaSiteKey = typeof configuredHCaptchaSiteKey === 'string' ? configuredHCaptchaSiteKey : ''; const analysis = useProfileAnalysis(apiBaseUrl); - const [activeResultTab, setActiveResultTab] = useState<'graph' | 'analysis'>('graph'); + const createParserWorker = useCallback( + () => + new Worker(new URL('./profile-analysis.parser.worker.ts', import.meta.url), { + type: 'module', + name: 'doris-profile-parser', + }) as ProfileParserWorker, + [], + ); + const localDag = useLocalProfileDag(createParserWorker); + const [activeResultTab, setActiveResultTab] = useState<'graph' | 'analysis'>('analysis'); const tabIdPrefix = useId(); - const isBusy = analysis.isBusy; + const isAiBusy = analysis.isBusy; const busyState = analysis.state === 'restoring' || analysis.state === 'recovering' || @@ -26,11 +37,32 @@ export function ProfileAnalyzer(): JSX.Element { analysis.state === 'analyzing' ? analysis.state : null; - const hasJob = analysis.jobId !== null; + const hasAiActivity = + analysis.jobId !== null || busyState !== null || analysis.result !== null || analysis.error !== null; + const hasDagActivity = localDag.state !== 'idle'; + const hasWorkspace = hasAiActivity || hasDagActivity; + + const handleFileChange = useCallback( + (file: File | null) => { + localDag.reset(); + analysis.selectFile(file); + }, + [analysis.selectFile, localDag.reset], + ); - useEffect(() => { + const handleBuildGraph = useCallback(() => { + if (!analysis.file) return; setActiveResultTab('graph'); - }, [analysis.jobId]); + void localDag.buildGraph(analysis.file); + }, [analysis.file, localDag.buildGraph]); + + const handleAnalyze = useCallback( + (hcaptchaToken: string, resetCaptcha: () => void) => { + setActiveResultTab('analysis'); + void analysis.analyze(hcaptchaToken, resetCaptcha); + }, + [analysis.analyze], + ); const handleTabKeyDown = (event: React.KeyboardEvent) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; @@ -55,34 +87,30 @@ export function ProfileAnalyzer(): JSX.Element {

Query diagnostics

Apache Doris Profile Analysis

- Upload one Query Profile to receive an independent AI-assisted diagnosis. Each upload starts a - new analysis and does not create a conversation history. + Choose one Query Profile to visualize its execution graph locally or request an independent + AI-assisted diagnosis. Each AI upload starts a new analysis and does not create a conversation + history.

- {!hasJob && busyState && } {analysis.recoveryWarning && (
{analysis.recoveryWarning}
)} - {!hasJob && analysis.error && ( -
- Analysis failed. - {analysis.error} -
- )} - {hasJob && ( + {hasWorkspace && (

Analysis workspace @@ -120,7 +148,7 @@ export function ProfileAnalyzer(): JSX.Element { hidden={activeResultTab !== 'graph'} className="profile-analysis__tab-panel" > - +

> = { - idle: 'The execution graph will appear after an analysis starts.', - pending: 'Waiting to parse the execution graph…', + idle: 'Choose a Profile and select View execution graph.', parsing: 'Parsing the execution graph…', - loading: 'Laying out the execution graph…', unavailable: 'An execution graph is not available for this Profile.', failed: 'The execution graph could not be generated.', }; diff --git a/src/components/profile-analysis/ProfileUploader.tsx b/src/components/profile-analysis/ProfileUploader.tsx index 9b3bb5fefb447..792c114ada7a4 100644 --- a/src/components/profile-analysis/ProfileUploader.tsx +++ b/src/components/profile-analysis/ProfileUploader.tsx @@ -6,10 +6,12 @@ import type { ResponseLanguage } from './profile-analysis.types'; interface ProfileUploaderProps { file: File | null; language: ResponseLanguage; - disabled: boolean; + aiDisabled: boolean; + dagBusy: boolean; hcaptchaSiteKey: string; onFileChange: (file: File | null) => void; onLanguageChange: (language: ResponseLanguage) => void; + onBuildGraph: () => void; onAnalyze: (hcaptchaToken: string, resetCaptcha: () => void) => void; } @@ -36,10 +38,12 @@ export function formatProfileFileSize(sizeInBytes: number): string { export function ProfileUploader({ file, language, - disabled, + aiDisabled, + dagBusy, hcaptchaSiteKey, onFileChange, onLanguageChange, + onBuildGraph, onAnalyze, }: ProfileUploaderProps): JSX.Element { const [validationError, setValidationError] = useState(null); @@ -94,7 +98,7 @@ export function ProfileUploader({ const handleDrop = (event: DragEvent) => { event.preventDefault(); - if (disabled || !consentAccepted) { + if (aiDisabled) { return; } if (event.dataTransfer.files.length > 1) { @@ -107,39 +111,15 @@ export function ProfileUploader({ return (
-

Upload a Query Profile

+

Choose a Query Profile

- Choose one UTF-8 .txt file up to 100 MiB after reviewing and accepting the notice below. Files - over 10 MiB are reduced to their aggregated Profile sections before upload. + Choose one UTF-8 .txt file up to 100 MiB. Files over 10 MiB are reduced to their aggregated + Profile sections before local visualization or AI upload.

-
- Response language - - -
- @@ -172,118 +152,164 @@ export function ProfileUploader({
)} - {consentAccepted && ( -
-

- Complete the human verification before starting the analysis. -

- {hcaptchaSiteKey ? ( - { - setHCaptchaToken(token); - setHCaptchaError(null); - }} - onExpire={() => { - setHCaptchaToken(null); - setHCaptchaError('Verification expired. Complete it again.'); - }} - onChalExpired={() => { - setHCaptchaToken(null); - setHCaptchaError('Verification expired. Complete it again.'); - }} - onError={() => { - setHCaptchaToken(null); - setHCaptchaError( - 'Human verification could not load. Check your connection and try again.', - ); - }} - /> - ) : ( -
- Human verification is not configured. Contact the site administrator. +
+
+

Local execution graph

+

Parsed locally in your browser. The file is not uploaded for this action.

+ +
+ +
+

AI-assisted analysis

+

The prepared Profile is uploaded only when you start this action.

+
+ Response language + + +
+ +
+
+ +

Privacy and AI processing notice

- )} - - This site is protected by hCaptcha and its{' '} - - Privacy Policy - {' '} - and{' '} - - Terms of Service - {' '} - apply. - - {hcaptchaError && ( -
- {hcaptchaError} +

+ This feature is provided by VeloDB and third-party large language model service + providers. It is not an official Apache Doris project feature, so please use it at + your discretion. +

+

+ Do not upload passwords, keys, access tokens, personal information, + customer-confidential data, or any other sensitive content that you are not + authorized to disclose. All uploaded information will be automatically and + permanently deleted within one hour. +

+ +
+ + {consentAccepted && ( +
+

+ Complete the human verification before starting the analysis. +

+ {hcaptchaSiteKey ? ( + { + setHCaptchaToken(token); + setHCaptchaError(null); + }} + onExpire={() => { + setHCaptchaToken(null); + setHCaptchaError('Verification expired. Complete it again.'); + }} + onChalExpired={() => { + setHCaptchaToken(null); + setHCaptchaError('Verification expired. Complete it again.'); + }} + onError={() => { + setHCaptchaToken(null); + setHCaptchaError( + 'Human verification could not load. Check your connection and try again.', + ); + }} + /> + ) : ( +
+ Human verification is not configured. Contact the site administrator. +
+ )} + + This site is protected by hCaptcha and its{' '} + + Privacy Policy + {' '} + and{' '} + + Terms of Service + {' '} + apply. + + {hcaptchaError && ( +
+ {hcaptchaError} +
+ )}
)} -
- )} - - - -
-
- -

Privacy and AI processing notice

-
-

- This feature is provided by VeloDB and third-party large language model service providers. - It is not an official Apache Doris project feature, so please use it at your discretion. -

-

- Do not upload passwords, keys, access tokens, personal information, customer-confidential - data, or any other sensitive content that you are not authorized to disclose. All uploaded - information will be automatically and permanently deleted within one hour. -

-
); diff --git a/src/components/profile-analysis/profile-analysis.api.test.js b/src/components/profile-analysis/profile-analysis.api.test.js index e3e8d9fca5076..a01dbf5c94f9a 100644 --- a/src/components/profile-analysis/profile-analysis.api.test.js +++ b/src/components/profile-analysis/profile-analysis.api.test.js @@ -35,8 +35,6 @@ const { createAnalysisJob, getAnalysisJob, getAnalysisJobByClientRequestId, - getProfileDag, - MAX_DAG_RESPONSE_BYTES, MAX_FINAL_ANSWER_BYTES, PRIVACY_NOTICE_VERSION, ProfileAnalysisApiError, @@ -116,14 +114,10 @@ test('parses queued, running, completed, and failed job snapshots', async t => { jobId, status: 'QUEUED', jobsAhead: 3, - dagStatus: 'PENDING', - dagError: null, }); assert.deepEqual(await getAnalysisJob('', jobId), { jobId, status: 'RUNNING', - dagStatus: 'PARSING', - dagError: null, }); assert.equal((await getAnalysisJob('', jobId)).status, 'COMPLETED'); assert.equal((await getAnalysisJob('', jobId)).status, 'FAILED'); @@ -352,238 +346,3 @@ test('rejects a missing hCaptcha token before sending the Profile', async t => { ); assert.equal(fetchCalls, 0); }); - -function validDag() { - return { - schemaVersion: '1.0', - parserVersion: '0.2.0', - jobId, - profile: {}, - graph: { - direction: 'BOTTOM_TO_TOP', - nodes: [ - { - id: 'fragment:0/pipeline:0/operator:0', - fragmentId: 'fragment:0', - pipelineId: 'fragment:0/pipeline:0', - ordinal: 0, - operatorType: 'RESULT_SINK_OPERATOR', - operatorFamily: 'RESULT', - role: 'SINK', - label: 'RESULT', - planNodeId: 1, - nereidsId: null, - destId: null, - destIds: [], - known: true, - lineNumber: 10, - planInfo: {}, - timing: { - execTime: { sumNs: 10, avgNs: 10, maxNs: 10, minNs: 10, display: '10ns' }, - waitTime: { - totalNs: 0, - maxNs: 0, - avgNs: 0, - breakdown: { waitForDependencyNs: 0 }, - }, - }, - metrics: { inputRows: { sum: 1, avg: 1, max: 1, min: 1 } }, - analysis: { heat: 1, waitHeat: 0, isBottleneck: true }, - }, - { - id: 'fragment:0/pipeline:0/operator:1', - fragmentId: 'fragment:0', - pipelineId: 'fragment:0/pipeline:0', - ordinal: 1, - operatorType: 'OLAP_SCAN_OPERATOR', - operatorFamily: 'SCAN', - role: 'SOURCE', - label: 'OLAP SCAN', - planNodeId: 2, - nereidsId: null, - destId: null, - destIds: [], - known: true, - lineNumber: 20, - planInfo: { table: 'lineitem' }, - }, - ], - edges: [ - { - id: 'edge:0', - kind: 'PIPELINE_DATA', - source: 'fragment:0/pipeline:0/operator:1', - target: 'fragment:0/pipeline:0/operator:0', - relationId: null, - resolved: true, - metadata: { pipelineId: 'fragment:0/pipeline:0' }, - }, - ], - }, - fragments: [ - { - id: 'fragment:0', - number: 0, - pipelineIds: ['fragment:0/pipeline:0'], - nodeIds: [ - 'fragment:0/pipeline:0/operator:0', - 'fragment:0/pipeline:0/operator:1', - ], - }, - ], - pipelines: [ - { - id: 'fragment:0/pipeline:0', - fragmentId: 'fragment:0', - number: 0, - instanceNum: 1, - nodeIds: [ - 'fragment:0/pipeline:0/operator:0', - 'fragment:0/pipeline:0/operator:1', - ], - }, - ], - unresolvedReferences: [], - warnings: [], - summary: { - fragmentCount: 1, - pipelineCount: 1, - nodeCount: 2, - edgeCount: 1, - unresolvedEdgeCount: 0, - criticalNodeId: 'fragment:0/pipeline:0/operator:0', - maxExecTimeNs: 10, - maxWaitTimeNs: 0, - }, - }; -} - -test('fetches and defensively validates a ready Profile DAG', async t => { - const originalFetch = global.fetch; - t.after(() => { global.fetch = originalFetch; }); - const dag = validDag(); - global.fetch = async (url, options) => { - assert.equal(url, `https://agent.velodb.io/api/profile/analysis-jobs/${jobId}/dag`); - assert.equal(options.method, 'GET'); - return jsonResponse(dag); - }; - - assert.deepEqual(await getProfileDag('https://agent.velodb.io/', jobId), { - dagStatus: 'READY', - dag, - }); -}); - -test('accepts omitted nullable DAG fields without treating them as zero', async t => { - const originalFetch = global.fetch; - t.after(() => { global.fetch = originalFetch; }); - const dag = validDag(); - delete dag.graph.nodes[0].planNodeId; - delete dag.graph.nodes[0].nereidsId; - delete dag.graph.nodes[0].destId; - delete dag.graph.nodes[0].timing.execTime.sumNs; - delete dag.graph.nodes[0].timing.waitTime.totalNs; - delete dag.graph.nodes[0].metrics.inputRows.min; - delete dag.graph.edges[0].relationId; - dag.unresolvedReferences.push({ - kind: 'EXCHANGE', - sourceNodeId: dag.graph.nodes[1].id, - reason: 'TARGET_NOT_FOUND', - }); - dag.summary.unresolvedEdgeCount = 1; - global.fetch = async () => jsonResponse(dag); - - const result = await getProfileDag('', jobId); - assert.equal(result.dagStatus, 'READY'); - assert.equal(result.dag.graph.nodes[0].planNodeId, undefined); - assert.equal(result.dag.graph.nodes[0].timing.execTime.sumNs, undefined); - assert.equal(result.dag.graph.nodes[0].metrics.inputRows.min, undefined); - assert.equal(result.dag.graph.edges[0].relationId, undefined); -}); - -test('accepts signed internal ids used by local exchange and multicast operators', async t => { - const originalFetch = global.fetch; - t.after(() => { global.fetch = originalFetch; }); - const dag = validDag(); - dag.graph.nodes[1].operatorType = 'MULTI_CAST_DATA_STREAM_SINK_OPERATOR'; - dag.graph.nodes[1].operatorFamily = 'MULTICAST'; - dag.graph.nodes[1].planNodeId = -5; - dag.graph.nodes[1].destId = -7; - dag.graph.nodes[1].destIds = [-7, -8, -9]; - global.fetch = async () => jsonResponse(dag); - - const result = await getProfileDag('', jobId); - assert.equal(result.dag.graph.nodes[1].planNodeId, -5); - assert.equal(result.dag.graph.nodes[1].destId, -7); - assert.deepEqual(result.dag.graph.nodes[1].destIds, [-7, -8, -9]); -}); - -test('accepts a valid DAG above the ordinary 128 KiB response limit', async t => { - const originalFetch = global.fetch; - t.after(() => { global.fetch = originalFetch; }); - const dag = validDag(); - dag.warnings = Array.from({ length: 10 }, (_, index) => ({ - kind: 'PARSER_NOTE', - nodeId: dag.graph.nodes[0].id, - message: `${index}:${'x'.repeat(15 * 1024)}`, - })); - global.fetch = async () => jsonResponse(dag); - - const result = await getProfileDag('', jobId); - assert.equal(result.dagStatus, 'READY'); - assert.equal(result.dag.warnings.length, 10); -}); - -test('returns a recoverable result when the Profile DAG is still parsing', async t => { - const originalFetch = global.fetch; - t.after(() => { global.fetch = originalFetch; }); - global.fetch = async () => - new Response(JSON.stringify({ jobId, dagStatus: 'PARSING' }), { - status: 202, - headers: { 'Content-Type': 'application/json', 'Retry-After': '2' }, - }); - - assert.deepEqual(await getProfileDag('', jobId), { - jobId, - dagStatus: 'PARSING', - retryAfterMs: 2000, - }); -}); - -test('rejects a DAG with duplicate node ids or a dangling edge', async t => { - const originalFetch = global.fetch; - t.after(() => { global.fetch = originalFetch; }); - const duplicate = validDag(); - duplicate.graph.nodes[1].id = duplicate.graph.nodes[0].id; - global.fetch = async () => jsonResponse(duplicate); - await assert.rejects(getProfileDag('', jobId), error => { - assert.ok(error instanceof ProfileAnalysisApiError); - assert.equal(error.code, 'INVALID_SERVER_RESPONSE'); - return true; - }); - - const dangling = validDag(); - dangling.graph.edges[0].target = 'fragment:99/pipeline:0/operator:0'; - global.fetch = async () => jsonResponse(dangling); - await assert.rejects(getProfileDag('', jobId), error => { - assert.ok(error instanceof ProfileAnalysisApiError); - assert.equal(error.code, 'INVALID_SERVER_RESPONSE'); - return true; - }); -}); - -test('rejects a DAG response over the dedicated 5 MiB client limit', async t => { - const originalFetch = global.fetch; - t.after(() => { global.fetch = originalFetch; }); - global.fetch = async () => - new Response(JSON.stringify({ padding: 'x'.repeat(MAX_DAG_RESPONSE_BYTES + 1) }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - - await assert.rejects(getProfileDag('', jobId), error => { - assert.ok(error instanceof ProfileAnalysisApiError); - assert.equal(error.code, 'INVALID_SERVER_RESPONSE'); - return true; - }); -}); diff --git a/src/components/profile-analysis/profile-analysis.api.ts b/src/components/profile-analysis/profile-analysis.api.ts index 6a1145293f0ee..ccaf0eaf27948 100644 --- a/src/components/profile-analysis/profile-analysis.api.ts +++ b/src/components/profile-analysis/profile-analysis.api.ts @@ -4,9 +4,6 @@ import type { AnalysisJobStatus, ApiErrorBody, CreateAnalysisJobResponse, - DagStatus, - ProfileDagFetchResult, - ProfileDag, RecoveredAnalysisJobResponse, ResponseLanguage, } from './profile-analysis.types'; @@ -19,11 +16,6 @@ const DEFAULT_POLL_INTERVAL_MS = 2_000; export const PRIVACY_NOTICE_VERSION = '2026-07-22'; export const MAX_FINAL_ANSWER_BYTES = 64 * 1024; const MAX_API_RESPONSE_BYTES = 128 * 1024; -export const MAX_DAG_RESPONSE_BYTES = 5 * 1024 * 1024; -const MAX_DAG_NODES = 500; -const MAX_DAG_EDGES = 1_000; -const MAX_DAG_STRING_BYTES = 16 * 1024; -const MAX_DAG_ID_BYTES = 512; export class ProfileAnalysisApiError extends Error { constructor( @@ -66,281 +58,6 @@ function isAnalysisJobStatus(value: unknown): value is AnalysisJobStatus { return value === 'QUEUED' || value === 'RUNNING' || value === 'COMPLETED' || value === 'FAILED'; } -function isDagStatus(value: unknown): value is DagStatus { - return ( - value === 'PENDING' || - value === 'PARSING' || - value === 'READY' || - value === 'UNAVAILABLE' || - value === 'FAILED' - ); -} - -function parseDagJobState(body: Record): { dagStatus: DagStatus; dagError: string | null } { - if (!isDagStatus(body.dagStatus)) throw invalidResponse(); - if (body.dagError !== undefined && body.dagError !== null && typeof body.dagError !== 'string') { - throw invalidResponse(); - } - return { - dagStatus: body.dagStatus, - dagError: typeof body.dagError === 'string' ? body.dagError : null, - }; -} - -function isBoundedString(value: unknown, maxBytes = MAX_DAG_STRING_BYTES): value is string { - return typeof value === 'string' && new TextEncoder().encode(value).byteLength <= maxBytes; -} - -function isDagId(value: unknown): value is string { - return isBoundedString(value, MAX_DAG_ID_BYTES) && value.length > 0; -} - -function isNonNegativeInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) >= 0; -} - -function isSafeInteger(value: unknown): value is number { - return Number.isSafeInteger(value); -} - -function isNullableNonNegativeInteger(value: unknown): value is number | null { - return value === null || isNonNegativeInteger(value); -} - -function isOptionalNullableNonNegativeInteger(value: unknown): value is number | null | undefined { - return value === undefined || isNullableNonNegativeInteger(value); -} - -function isOptionalNullableSafeInteger(value: unknown): value is number | null | undefined { - return value === undefined || value === null || isSafeInteger(value); -} - -function isBoundedScalar(value: unknown): value is string | number | boolean { - return ( - isBoundedString(value) || - typeof value === 'boolean' || - (typeof value === 'number' && Number.isFinite(value) && Number.isSafeInteger(value)) - ); -} - -function isBoundedScalarRecord(value: unknown, allowNull = false): boolean { - if (!isRecord(value) || Object.keys(value).length > 128) return false; - return Object.entries(value).every(([key, item]) => { - if (!isBoundedString(key, 256)) return false; - if (allowNull && item === null) return true; - if (isBoundedScalar(item)) return true; - return Array.isArray(item) && item.length <= 128 && item.every(isBoundedScalar); - }); -} - -function isAggregateMetric(value: unknown): boolean { - return ( - isRecord(value) && - isOptionalNullableNonNegativeInteger(value.sum) && - isOptionalNullableNonNegativeInteger(value.avg) && - isOptionalNullableNonNegativeInteger(value.max) && - isOptionalNullableNonNegativeInteger(value.min) - ); -} - -function isExecTime(value: unknown): boolean { - return ( - isRecord(value) && - isOptionalNullableNonNegativeInteger(value.sumNs) && - isOptionalNullableNonNegativeInteger(value.avgNs) && - isOptionalNullableNonNegativeInteger(value.maxNs) && - isOptionalNullableNonNegativeInteger(value.minNs) && - (value.display === undefined || isBoundedString(value.display)) - ); -} - -function isWaitTime(value: unknown): boolean { - return ( - isRecord(value) && - isOptionalNullableNonNegativeInteger(value.totalNs) && - isOptionalNullableNonNegativeInteger(value.maxNs) && - isOptionalNullableNonNegativeInteger(value.avgNs) && - (value.display === undefined || isBoundedString(value.display)) && - (value.breakdown === undefined || - (isRecord(value.breakdown) && - Object.keys(value.breakdown).length <= 64 && - Object.entries(value.breakdown).every( - ([key, item]) => isBoundedString(key, 256) && isNullableNonNegativeInteger(item), - ))) - ); -} - -function isDagNode(value: unknown): boolean { - if (!isRecord(value)) return false; - if ( - !isDagId(value.id) || - !isDagId(value.fragmentId) || - !isDagId(value.pipelineId) || - !isNonNegativeInteger(value.ordinal) || - !isBoundedString(value.operatorType, 512) || - !isBoundedString(value.operatorFamily, 512) || - !isBoundedString(value.role, 128) || - !isBoundedString(value.label, 1_024) || - !isOptionalNullableSafeInteger(value.planNodeId) || - !isOptionalNullableSafeInteger(value.nereidsId) || - !isOptionalNullableSafeInteger(value.destId) || - !Array.isArray(value.destIds) || - value.destIds.length > 128 || - !value.destIds.every(isSafeInteger) || - typeof value.known !== 'boolean' || - !isNonNegativeInteger(value.lineNumber) || - !isBoundedScalarRecord(value.planInfo) - ) { - return false; - } - if (value.headerAttributes !== undefined && !isBoundedScalarRecord(value.headerAttributes)) return false; - if (value.timing !== undefined) { - if (!isRecord(value.timing)) return false; - if (value.timing.execTime !== undefined && !isExecTime(value.timing.execTime)) return false; - if (value.timing.waitTime !== undefined && !isWaitTime(value.timing.waitTime)) return false; - } - if (value.metrics !== undefined) { - if (!isRecord(value.metrics) || Object.keys(value.metrics).length > 64) return false; - if (!Object.values(value.metrics).every(metric => metric === null || isAggregateMetric(metric))) return false; - } - if (value.analysis !== undefined) { - if (!isRecord(value.analysis)) return false; - for (const heat of [value.analysis.heat, value.analysis.waitHeat]) { - if (heat !== undefined && heat !== null && !(typeof heat === 'number' && Number.isFinite(heat) && heat >= 0 && heat <= 1)) { - return false; - } - } - if (value.analysis.isBottleneck !== undefined && typeof value.analysis.isBottleneck !== 'boolean') { - return false; - } - } - return true; -} - -function isDagEdge(value: unknown, nodeIds: Set): boolean { - if (!isRecord(value)) return false; - const kinds = new Set([ - 'PIPELINE_DATA', - 'EXCHANGE', - 'LOCAL_EXCHANGE', - 'MULTICAST', - 'BUILD_DEPENDENCY', - 'BLOCKING_DEPENDENCY', - ]); - return ( - isDagId(value.id) && - typeof value.kind === 'string' && - kinds.has(value.kind) && - isDagId(value.source) && - isDagId(value.target) && - nodeIds.has(value.source) && - nodeIds.has(value.target) && - (value.relationId === undefined || - value.relationId === null || - isBoundedString(value.relationId, MAX_DAG_ID_BYTES)) && - value.resolved === true && - (value.metadata === undefined || isBoundedScalarRecord(value.metadata, true)) - ); -} - -function isDagGroup(value: unknown, kind: 'fragment' | 'pipeline', nodeIds: Set): boolean { - if (!isRecord(value) || !isDagId(value.id) || !isNonNegativeInteger(value.number)) return false; - if (!Array.isArray(value.nodeIds) || value.nodeIds.length > MAX_DAG_NODES) return false; - if (!value.nodeIds.every(nodeId => isDagId(nodeId) && nodeIds.has(nodeId))) return false; - if (kind === 'fragment') { - return ( - Array.isArray(value.pipelineIds) && - value.pipelineIds.length <= MAX_DAG_NODES && - value.pipelineIds.every(isDagId) - ); - } - return ( - isDagId(value.fragmentId) && - isNonNegativeInteger(value.instanceNum) && - (value.waitWorkerTime === undefined || - (isRecord(value.waitWorkerTime) && - Object.values(value.waitWorkerTime).every(isNullableNonNegativeInteger))) - ); -} - -function isUnresolvedReference(value: unknown, nodeIds: Set): boolean { - return ( - isRecord(value) && - isBoundedString(value.kind, 256) && - (value.relationId === undefined || - value.relationId === null || - isBoundedString(value.relationId, MAX_DAG_ID_BYTES)) && - isDagId(value.sourceNodeId) && - nodeIds.has(value.sourceNodeId) && - isBoundedString(value.reason, 1_024) - ); -} - -function isWarning(value: unknown): boolean { - if (!isRecord(value) || Object.keys(value).length > 16) return false; - return Object.entries(value).every(([key, item]) => { - if (!isBoundedString(key, 128)) return false; - if (key === 'lineNumber') return isNonNegativeInteger(item); - return item === null || isBoundedScalar(item); - }); -} - -function isDagSummary(value: unknown, nodeCount: number, edgeCount: number): boolean { - if (!isRecord(value)) return false; - return ( - isNonNegativeInteger(value.fragmentCount) && - isNonNegativeInteger(value.pipelineCount) && - value.nodeCount === nodeCount && - value.edgeCount === edgeCount && - isNonNegativeInteger(value.unresolvedEdgeCount) && - (value.criticalNodeId === undefined || value.criticalNodeId === null || isDagId(value.criticalNodeId)) && - (value.maxExecTimeNs === undefined || isNullableNonNegativeInteger(value.maxExecTimeNs)) && - (value.maxWaitTimeNs === undefined || isNullableNonNegativeInteger(value.maxWaitTimeNs)) - ); -} - -function isProfileDag(value: unknown, expectedJobId: string): value is ProfileDag { - if ( - !isRecord(value) || - value.schemaVersion !== '1.0' || - (value.parserVersion !== undefined && !isBoundedString(value.parserVersion, 128)) || - value.jobId !== expectedJobId || - !isRecord(value.profile) || - !isRecord(value.graph) || - value.graph.direction !== 'BOTTOM_TO_TOP' || - !Array.isArray(value.graph.nodes) || - !Array.isArray(value.graph.edges) || - value.graph.nodes.length > MAX_DAG_NODES || - value.graph.edges.length > MAX_DAG_EDGES || - !value.graph.nodes.every(isDagNode) - ) { - return false; - } - const nodeIds = new Set(value.graph.nodes.map(node => (node as Record).id as string)); - if (nodeIds.size !== value.graph.nodes.length) return false; - if (!value.graph.edges.every(edge => isDagEdge(edge, nodeIds))) return false; - const edgeIds = new Set(value.graph.edges.map(edge => (edge as Record).id as string)); - if (edgeIds.size !== value.graph.edges.length) return false; - if (!Array.isArray(value.fragments) || !value.fragments.every(fragment => isDagGroup(fragment, 'fragment', nodeIds))) { - return false; - } - if (!Array.isArray(value.pipelines) || !value.pipelines.every(pipeline => isDagGroup(pipeline, 'pipeline', nodeIds))) { - return false; - } - if ( - !Array.isArray(value.unresolvedReferences) || - value.unresolvedReferences.length > MAX_DAG_EDGES || - !value.unresolvedReferences.every(reference => isUnresolvedReference(reference, nodeIds)) || - !Array.isArray(value.warnings) || - value.warnings.length > MAX_DAG_NODES + MAX_DAG_EDGES || - !value.warnings.every(isWarning) || - !isDagSummary(value.summary, value.graph.nodes.length, value.graph.edges.length) - ) { - return false; - } - return true; -} - function invalidResponse(): ProfileAnalysisApiError { return new ProfileAnalysisApiError( 502, @@ -502,10 +219,7 @@ export async function getAnalysisJobByClientRequestId( if (!isRecord(body) || !isJobId(body.jobId) || !isAnalysisJobStatus(body.status)) { throw invalidResponse(); } - if (body.dagStatus === undefined && body.dagError === undefined) { - return { jobId: body.jobId, status: body.status }; - } - return { jobId: body.jobId, status: body.status, ...parseDagJobState(body) }; + return { jobId: body.jobId, status: body.status }; } export async function getAnalysisJob( @@ -521,51 +235,19 @@ export async function getAnalysisJob( if (!isRecord(body) || body.jobId !== jobId) { throw invalidResponse(); } - const dagState = parseDagJobState(body); - switch (body.status) { case 'QUEUED': if (!Number.isInteger(body.jobsAhead) || (body.jobsAhead as number) < 0) throw invalidResponse(); - return { jobId, status: 'QUEUED', jobsAhead: body.jobsAhead as number, ...dagState }; + return { jobId, status: 'QUEUED', jobsAhead: body.jobsAhead as number }; case 'RUNNING': - return { jobId, status: 'RUNNING', ...dagState }; + return { jobId, status: 'RUNNING' }; case 'COMPLETED': if (!isAgentMessage(body.result)) throw invalidResponse(); - return { jobId, status: 'COMPLETED', result: body.result, ...dagState }; + return { jobId, status: 'COMPLETED', result: body.result }; case 'FAILED': if (!isApiErrorBody(body.error)) throw invalidResponse(); - return { jobId, status: 'FAILED', error: body.error, ...dagState }; + return { jobId, status: 'FAILED', error: body.error }; default: throw invalidResponse(); } } - -export async function getProfileDag( - apiBaseUrl: string, - jobId: string, - signal?: AbortSignal, -): Promise { - if (!isJobId(jobId)) throw invalidResponse(); - const { response, body } = await fetchJson( - apiUrl(apiBaseUrl, `${ANALYSIS_JOBS_PATH}/${encodeURIComponent(jobId)}/dag`), - { method: 'GET', signal }, - MAX_DAG_RESPONSE_BYTES, - ); - - if (response.status === 202) { - if ( - !isRecord(body) || - body.jobId !== jobId || - (body.dagStatus !== 'PENDING' && body.dagStatus !== 'PARSING') - ) { - throw invalidResponse(); - } - return { - jobId, - dagStatus: body.dagStatus, - retryAfterMs: retryAfterMs(response) ?? 1_000, - }; - } - if (response.status !== 200 || !isProfileDag(body, jobId)) throw invalidResponse(); - return { dagStatus: 'READY', dag: body }; -} diff --git a/src/components/profile-analysis/profile-analysis.components.test.js b/src/components/profile-analysis/profile-analysis.components.test.js index f270a24896ddd..dc9cec0f20629 100644 --- a/src/components/profile-analysis/profile-analysis.components.test.js +++ b/src/components/profile-analysis/profile-analysis.components.test.js @@ -58,10 +58,12 @@ test('explains the raw file limit and large-profile reduction in English', () => React.createElement(ProfileUploader, { file: null, language: 'en', - disabled: false, + aiDisabled: false, + dagBusy: false, hcaptchaSiteKey, onFileChange() {}, onLanguageChange() {}, + onBuildGraph() {}, onAnalyze() {}, }), ); @@ -70,44 +72,52 @@ test('explains the raw file limit and large-profile reduction in English', () => assert.match(markup, /Files over 10 MiB are reduced to their aggregated Profile sections/); }); -test('disables Analyze until a file exists and while analysis is running', () => { +test('disables both actions without a file and keeps local graph available during AI processing', () => { const withoutFile = renderToStaticMarkup( React.createElement(ProfileUploader, { file: null, language: 'en', - disabled: false, + aiDisabled: false, + dagBusy: false, hcaptchaSiteKey, onFileChange() {}, onLanguageChange() {}, + onBuildGraph() {}, onAnalyze() {}, }), ); - assert.match(withoutFile, /]*disabled=""[^>]*>Analyze Profile<\/button>/); + assert.match(withoutFile, /]*disabled=""[^>]*>View execution graph<\/button>/); + assert.match(withoutFile, /]*disabled=""[^>]*>Analyze with AI<\/button>/); const analyzing = renderToStaticMarkup( React.createElement(ProfileUploader, { file: new File(['profile'], 'query.txt'), language: 'zh-CN', - disabled: true, + aiDisabled: true, + dagBusy: false, hcaptchaSiteKey, onFileChange() {}, onLanguageChange() {}, + onBuildGraph() {}, onAnalyze() {}, }), ); assert.match(analyzing, /]*disabled=""/); assert.match(analyzing, /]*disabled=""[^>]*>Processing…<\/button>/); + assert.doesNotMatch(analyzing, /]*disabled=""[^>]*>View execution graph<\/button>/); }); -test('places an unchecked privacy consent after Analyze and displays provider, prohibited-content, and deletion notices', () => { +test('keeps local file selection independent from unchecked AI consent and displays the required notice', () => { const markup = renderToStaticMarkup( React.createElement(ProfileUploader, { file: null, language: 'en', - disabled: false, + aiDisabled: false, + dagBusy: false, hcaptchaSiteKey, onFileChange() {}, onLanguageChange() {}, + onBuildGraph() {}, onAnalyze() {}, }), ); @@ -118,10 +128,17 @@ test('places an unchecked privacy consent after Analyze and displays provider, p assert.match(markup, /not an official Apache Doris project feature/); assert.match(markup, /Do not upload passwords, keys, access tokens, personal information/); assert.match(markup, /automatically and permanently deleted within one hour/); - assert.ok(markup.indexOf('Analyze Profile') < markup.indexOf('Privacy and AI processing notice')); + assert.ok(markup.indexOf('Privacy and AI processing notice') < markup.indexOf('Analyze with AI')); assert.match(markup, /role="note"/); assert.match(markup, /profile-analysis__privacy-notice-icon" aria-hidden="true">!]*disabled=""/); + assert.doesNotMatch(markup, /type="file"[^>]*disabled=""/); + assert.match(markup, /Parsed locally in your browser\. The file is not uploaded for this action\./); + assert.match(markup, /profile-analysis__action-card--local/); + assert.match(markup, /button button--primary profile-analysis__action-button[^>]*>View execution graph/); + + const styles = fs.readFileSync(path.join(__dirname, 'ProfileAnalysis.scss'), 'utf8'); + assert.match(styles, /&__actions\s*{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s); + assert.match(styles, /&--local\s*{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) minmax\(220px, auto\)/s); }); test('uses an English accessible label instead of exposing localized native file-input text', () => { @@ -129,10 +146,12 @@ test('uses an English accessible label instead of exposing localized native file React.createElement(ProfileUploader, { file: null, language: 'en', - disabled: false, + aiDisabled: false, + dagBusy: false, hcaptchaSiteKey, onFileChange() {}, onLanguageChange() {}, + onBuildGraph() {}, onAnalyze() {}, }), ); @@ -148,10 +167,12 @@ test('renders an English response-language selector with English selected by def React.createElement(ProfileUploader, { file: null, language: 'en', - disabled: false, + aiDisabled: false, + dagBusy: false, hcaptchaSiteKey, onFileChange() {}, onLanguageChange() {}, + onBuildGraph() {}, onAnalyze() {}, }), ); diff --git a/src/components/profile-analysis/profile-analysis.parser-client.test.js b/src/components/profile-analysis/profile-analysis.parser-client.test.js new file mode 100644 index 0000000000000..3ccf8684cc8ef --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.parser-client.test.js @@ -0,0 +1,105 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { File } = require('node:buffer'); +const typescript = require('typescript'); + +const previousTypeScriptLoader = require.extensions['.ts']; +require.extensions['.ts'] = (module, filename) => { + const output = typescript.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: typescript.ModuleKind.CommonJS, target: typescript.ScriptTarget.ES2020 }, + }).outputText; + module._compile(output, filename); +}; +const client = require('./profile-analysis.parser-client.ts'); +const parser = require('./profile-analysis.parser.ts'); +const workerParser = require('./profile-analysis.parser.worker.ts'); +require.extensions['.ts'] = previousTypeScriptLoader; + +class FakeWorker { + onmessage = null; + onerror = null; + posted = []; + terminated = 0; + + postMessage(message) { + this.posted.push(message); + } + + terminate() { + this.terminated += 1; + } +} + +const file = new File(['MergedProfile:'], 'profile.txt', { type: 'text/plain' }); +const dag = { + schemaVersion: '1.0', + graph: { direction: 'BOTTOM_TO_TOP', nodes: [], edges: [] }, + profile: {}, fragments: [], pipelines: [], unresolvedReferences: [], warnings: [], + summary: { fragmentCount: 0, pipelineCount: 0, nodeCount: 0, edgeCount: 0, unresolvedEdgeCount: 0 }, +}; + +test('posts one file request, accepts only its request id, and terminates after success', async () => { + const worker = new FakeWorker(); + const operation = client.startProfileParse(file, () => worker, 1_000); + + assert.equal(worker.posted.length, 1); + assert.equal(worker.posted[0].type, 'PARSE_PROFILE'); + assert.equal(worker.posted[0].file, file); + worker.onmessage({ data: { type: 'PARSE_SUCCESS', requestId: 'stale', dag } }); + assert.equal(worker.terminated, 0); + worker.onmessage({ data: { type: 'PARSE_SUCCESS', requestId: operation.requestId, dag } }); + + assert.equal(await operation.promise, dag); + assert.equal(worker.terminated, 1); +}); + +test('maps stable parser failures and terminates the worker', async () => { + const worker = new FakeWorker(); + const operation = client.startProfileParse(file, () => worker, 1_000); + worker.onmessage({ data: { type: 'PARSE_FAILURE', requestId: operation.requestId, code: 'DAG_UNAVAILABLE' } }); + + await assert.rejects(operation.promise, error => error.code === 'DAG_UNAVAILABLE'); + assert.equal(worker.terminated, 1); +}); + +test('terminates and rejects an operation on timeout', async () => { + const worker = new FakeWorker(); + const operation = client.startProfileParse(file, () => worker, 5); + + await assert.rejects(operation.promise, /timed out/); + assert.equal(worker.terminated, 1); +}); + +test('cancels an in-flight operation and ignores late worker responses', async () => { + const worker = new FakeWorker(); + const operation = client.startProfileParse(file, () => worker, 1_000); + operation.cancel(); + worker.onmessage({ data: { type: 'PARSE_SUCCESS', requestId: operation.requestId, dag } }); + + await assert.rejects(operation.promise, error => error.name === 'AbortError'); + assert.equal(worker.terminated, 1); +}); + +test('rejects oversized prepared files before creating a worker', async () => { + let workerCalls = 0; + const oversized = { name: 'large.txt', size: parser.MAX_PARSER_BYTES + 1 }; + const operation = client.startProfileParse(oversized, () => { + workerCalls += 1; + return new FakeWorker(); + }); + + await assert.rejects(operation.promise, error => error.code === 'DAG_TOO_LARGE'); + assert.equal(workerCalls, 0); +}); + +test('rejects invalid UTF-8 before parsing Profile structure', async () => { + const invalidUtf8 = new File([new Uint8Array([0xc3, 0x28])], 'invalid.txt', { type: 'text/plain' }); + + await assert.rejects(workerParser.parseProfileFile(invalidUtf8), error => { + assert.equal(error.code, 'DAG_PARSE_FAILED'); + assert.match(error.message, /valid UTF-8/); + return true; + }); +}); diff --git a/src/components/profile-analysis/profile-analysis.parser-client.ts b/src/components/profile-analysis/profile-analysis.parser-client.ts new file mode 100644 index 0000000000000..21efbc841c891 --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.parser-client.ts @@ -0,0 +1,92 @@ +import { MAX_PARSER_BYTES, ProfileParserError, type ProfileParserErrorCode } from './profile-analysis.parser'; +import type { ProfileParseRequest, ProfileParseResponse } from './profile-analysis.parser-protocol'; +import type { ProfileGraphIR } from './profile-analysis.types'; + +export const DEFAULT_PROFILE_PARSE_TIMEOUT_MS = 8_000; + +export interface ProfileParserWorker { + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; + postMessage(message: ProfileParseRequest): void; + terminate(): void; +} + +export type ProfileParserWorkerFactory = () => ProfileParserWorker; + +export interface ProfileParseOperation { + requestId: string; + promise: Promise; + cancel(): void; +} + +export function profileParserErrorMessage(code: ProfileParserErrorCode): string { + if (code === 'DAG_TOO_LARGE') return 'This execution graph is too large to display.'; + if (code === 'DAG_UNAVAILABLE') return 'An execution graph is not available for this Profile.'; + return 'The execution graph could not be generated.'; +} + +export function startProfileParse( + file: File, + createWorker: ProfileParserWorkerFactory, + timeoutMs = DEFAULT_PROFILE_PARSE_TIMEOUT_MS, +): ProfileParseOperation { + const requestId = crypto.randomUUID(); + let worker: ProfileParserWorker | null = null; + let settled = false; + let rejectPromise: ((reason: unknown) => void) | null = null; + let timeout: ReturnType | null = null; + + const finish = () => { + if (timeout !== null) clearTimeout(timeout); + timeout = null; + worker?.terminate(); + worker = null; + }; + const promise = new Promise((resolve, reject) => { + rejectPromise = reject; + if (file.size > MAX_PARSER_BYTES) { + settled = true; + reject(new ProfileParserError('DAG_TOO_LARGE', profileParserErrorMessage('DAG_TOO_LARGE'))); + return; + } + try { + worker = createWorker(); + } catch { + settled = true; + reject(new ProfileParserError('DAG_PARSE_FAILED', profileParserErrorMessage('DAG_PARSE_FAILED'))); + return; + } + worker.onmessage = event => { + if (settled || event.data.requestId !== requestId) return; + settled = true; + finish(); + if (event.data.type === 'PARSE_SUCCESS') resolve(event.data.dag); + else reject(new ProfileParserError(event.data.code, profileParserErrorMessage(event.data.code))); + }; + worker.onerror = () => { + if (settled) return; + settled = true; + finish(); + reject(new ProfileParserError('DAG_PARSE_FAILED', profileParserErrorMessage('DAG_PARSE_FAILED'))); + }; + timeout = setTimeout(() => { + if (settled) return; + settled = true; + finish(); + reject(new ProfileParserError('DAG_PARSE_FAILED', 'The execution graph parser timed out.')); + }, timeoutMs); + worker.postMessage({ type: 'PARSE_PROFILE', requestId, file }); + }); + + return { + requestId, + promise, + cancel() { + if (settled) return; + settled = true; + finish(); + rejectPromise?.(new DOMException('The operation was aborted.', 'AbortError')); + }, + }; +} + diff --git a/src/components/profile-analysis/profile-analysis.parser-protocol.ts b/src/components/profile-analysis/profile-analysis.parser-protocol.ts new file mode 100644 index 0000000000000..1fc2c0d3fe66f --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.parser-protocol.ts @@ -0,0 +1,23 @@ +import type { ProfileGraphIR } from './profile-analysis.types'; +import type { ProfileParserErrorCode } from './profile-analysis.parser'; + +export interface ProfileParseRequest { + type: 'PARSE_PROFILE'; + requestId: string; + file: File; +} + +export interface ProfileParseSuccess { + type: 'PARSE_SUCCESS'; + requestId: string; + dag: ProfileGraphIR; +} + +export interface ProfileParseFailure { + type: 'PARSE_FAILURE'; + requestId: string; + code: ProfileParserErrorCode; +} + +export type ProfileParseResponse = ProfileParseSuccess | ProfileParseFailure; + diff --git a/src/components/profile-analysis/profile-analysis.parser.test.js b/src/components/profile-analysis/profile-analysis.parser.test.js new file mode 100644 index 0000000000000..45e3a698f1f3f --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.parser.test.js @@ -0,0 +1,187 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const Module = require('node:module'); +const path = require('node:path'); +const test = require('node:test'); +const typescript = require('typescript'); + +const previousTypeScriptLoader = require.extensions['.ts']; +require.extensions['.ts'] = (module, filename) => { + const output = typescript.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2020, + }, + }).outputText; + module._compile(output, filename); +}; + +const parserPath = path.join(__dirname, 'profile-analysis.parser.ts'); +const parserModule = new Module(parserPath, module); +parserModule.filename = parserPath; +parserModule.paths = Module._nodeModulePaths(path.dirname(parserPath)); +parserModule._compile( + typescript.transpileModule(fs.readFileSync(parserPath, 'utf8'), { + compilerOptions: { + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2020, + }, + }).outputText, + parserPath, +); +require.extensions['.ts'] = previousTypeScriptLoader; + +const { + MAX_PARSER_LINE_BYTES, + parseCounter, + parseProfileText, + ProfileParserError, +} = parserModule.exports; + +function representativeProfile() { + return [ + 'Summary:', + ' Query ID: test', + 'MergedProfile:', + ' Fragments:', + ' Fragment 0:', + ' Pipeline 0(instance_num=1):', + ' RESULT_SINK_OPERATOR(id=2147483647):', + ' CommonCounters:', + ' - ExecTime: avg 2.472ms, max 2.472ms, min 2.472ms', + ' EXCHANGE_OPERATOR(id=35):', + ' - PlanInfo', + ' - limit: 100', + ' CommonCounters:', + ' - ExecTime: avg 1.894ms, max 1.894ms, min 1.894ms', + ' - RowsProduced: sum 100, avg 100, max 100, min 100', + ' - WaitForDependencyTime: avg 0ns, max 0ns, min 0ns', + ' - WaitForData0: avg 183.936ms, max 183.936ms, min 183.936ms', + ' Fragment 1:', + ' Pipeline 0(instance_num=8):', + ' DATA_STREAM_SINK_OPERATOR(dest_id=35):', + ' CommonCounters:', + ' - ExecTime: avg 495.552us, max 1.111ms, min 14.647us', + ' HASH_JOIN_OPERATOR(nereids_id=22)(id=7):', + ' - PlanInfo', + ' - join op: INNER JOIN(PARTITIONED)[]', + ' CommonCounters:', + ' - ExecTime: avg 1.2ms, max 2ms, min 1ms', + ' Pipeline 1(instance_num=8):', + ' HASH_JOIN_SINK_OPERATOR(nereids_id=22)(id=7):', + ' CommonCounters:', + ' - ExecTime: avg 3ms, max 4ms, min 2ms', + 'DetailProfile(test):', + ' ignored instance detail', + ].join('\n'); +} + +test('parses MergedProfile nodes, data flow, exchange, and dependency edges', () => { + const dag = parseProfileText(representativeProfile()); + + assert.equal(dag.schemaVersion, '1.0'); + assert.equal(dag.jobId, undefined); + assert.deepEqual(dag.summary, { + fragmentCount: 2, + pipelineCount: 3, + nodeCount: 5, + edgeCount: 4, + unresolvedEdgeCount: 0, + criticalNodeId: 'fragment:1/pipeline:1/operator:0', + maxExecTimeNs: 4_000_000, + maxWaitTimeNs: 0, + }); + assert.deepEqual( + Object.fromEntries(dag.graph.edges.map(edge => [edge.kind, (dag.graph.edges.filter(item => item.kind === edge.kind)).length])), + { PIPELINE_DATA: 2, EXCHANGE: 1, BUILD_DEPENDENCY: 1 }, + ); + const exchange = dag.graph.nodes.find(node => node.operatorType === 'EXCHANGE_OPERATOR'); + assert.equal(exchange.planInfo.limit, '100'); + assert.equal(exchange.metrics.inputRows.max, 100); + assert.equal(exchange.timing.waitTime.maxNs, 0); + assert.equal(exchange.timing.waitTime.breakdown.waitForDataNs, 0); +}); + +test('preserves multicast branches and their branch indexes', () => { + const profile = [ + 'MergedProfile:', + ' Fragment 0:', + ' Pipeline 0(instance_num=1):', + ' MULTI_CAST_DATA_STREAM_SINK_OPERATOR(dest_id=-7, dest_id=-8)(id=-5):', + ' Pipeline 1(instance_num=1):', + ' MULTI_CAST_DATA_STREAM_SOURCE_OPERATOR(id=-7):', + ' Pipeline 2(instance_num=1):', + ' MULTI_CAST_DATA_STREAM_SOURCE_OPERATOR(id=-8):', + ].join('\n'); + const dag = parseProfileText(profile); + const edges = dag.graph.edges.filter(edge => edge.kind === 'MULTICAST'); + + assert.equal(edges.length, 2); + assert.deepEqual(edges.map(edge => edge.relationId), ['-7', '-8']); + assert.deepEqual(edges.map(edge => edge.metadata.branchIndex), [0, 1]); +}); + +test('keeps unknown operator text inert and reports a non-blocking warning', () => { + const dag = parseProfileText([ + 'MergedProfile:', + ' Fragment 0:', + ' Pipeline 0(instance_num=1):', + ' EVIL_', + ].join('\n')); + + assert.equal(dag.graph.nodes.length, 1); + assert.equal(dag.graph.nodes[0].known, false); + assert.equal(dag.graph.nodes[0].planInfo.table, ''); + assert.deepEqual(dag.warnings[0], { + kind: 'UNKNOWN_OPERATOR', + nodeId: 'fragment:0/pipeline:0/operator:0', + operatorType: 'FUTURE_OPERATOR', + }); +}); + +test('parses compound Doris durations and exact abbreviated counts', () => { + assert.deepEqual(parseCounter('WaitForDependencyTime', 'avg 13sec796ms, max 13sec796ms, min 1us'), { + sum: undefined, + avg: 13_796_000_000, + max: 13_796_000_000, + min: 1_000, + }); + assert.deepEqual(parseCounter('RowsProduced', 'sum 2.232K (2232), avg 279, max 309, min 253'), { + sum: 2232, + avg: 279, + max: 309, + min: 253, + }); +}); + +test('fails with stable errors for missing MergedProfile and oversized lines', () => { + assert.throws( + () => parseProfileText('Summary:\nNo graph'), + error => error instanceof ProfileParserError && error.code === 'DAG_UNAVAILABLE', + ); + const oversized = `MergedProfile:\n${'x'.repeat(MAX_PARSER_LINE_BYTES + 1)}`; + assert.throws( + () => parseProfileText(oversized), + error => error instanceof ProfileParserError && error.code === 'DAG_TOO_LARGE', + ); +}); + +test('records unresolved exchange references instead of inventing an edge', () => { + const dag = parseProfileText([ + 'MergedProfile:', + ' Fragment 0:', + ' Pipeline 0(instance_num=1):', + ' DATA_STREAM_SINK_OPERATOR(dest_id=99):', + ].join('\n')); + + assert.equal(dag.graph.edges.length, 0); + assert.deepEqual(dag.unresolvedReferences, [{ + kind: 'EXCHANGE', + relationId: '99', + sourceNodeId: 'fragment:0/pipeline:0/operator:0', + reason: 'TARGET_NOT_FOUND', + }]); +}); diff --git a/src/components/profile-analysis/profile-analysis.parser.ts b/src/components/profile-analysis/profile-analysis.parser.ts new file mode 100644 index 0000000000000..3b4553d4f92a2 --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.parser.ts @@ -0,0 +1,602 @@ +import type { + DagAggregateMetric, + DagEdgeKind, + DagOperatorRole, + ProfileDagEdge, + ProfileDagFragment, + ProfileDagNode, + ProfileDagPipeline, + ProfileDagUnresolvedReference, + ProfileDagWarning, + ProfileGraphIR, +} from './profile-analysis.types'; + +export const PROFILE_PARSER_VERSION = '0.2.0-ts.1'; +export const MAX_PARSER_BYTES = 10 * 1024 * 1024; +export const MAX_PARSER_LINES = 200_000; +export const MAX_PARSER_LINE_BYTES = 64 * 1024; +export const MAX_DAG_NODES = 500; +export const MAX_DAG_EDGES = 1_000; +const MAX_FRAGMENTS = 512; +const MAX_PIPELINES = 4_096; +const MAX_PLAN_INFO_VALUE_LENGTH = 300; + +export type ProfileParserErrorCode = 'DAG_UNAVAILABLE' | 'DAG_TOO_LARGE' | 'DAG_PARSE_FAILED'; + +export class ProfileParserError extends Error { + constructor(public readonly code: ProfileParserErrorCode, message: string) { + super(message); + this.name = 'ProfileParserError'; + } +} + +const MERGED_RE = /^\s*MergedProfile:\s*$/; +const DETAIL_RE = /^\s*DetailProfile(?:\([^)]*\))?:\s*$/; +const EXEC_PROFILE_RE = /^\s*Execution\s+Profile/; +const FRAGMENT_RE = /^\s*Fragment\s+(\d+):\s*$/; +const PIPELINE_RE = /^\s*Pipeline\s+(\d+)\s*\(\s*instance_num\s*=\s*(\d+)\s*\):\s*$/; +const OPERATOR_RE = /^\s+([A-Z][A-Z0-9_]*_OPERATOR)(.*):\s*$/; +const ATTRIBUTE_RE = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(-?\d+)/g; +const TABLE_NAME_RE = /table_name=([^,)]+(?:\([^)]*\))?)/; +const COUNTERS_RE = /^\s*(?:Common|Custom)Counters:\s*$/; +const PLANINFO_RE = /^\s*-\s*PlanInfo\s*$/; +const COUNTER_RE = /^\s*-\s*([A-Za-z0-9_\[\]]+?)\s*:\s*(.+?)\s*$/; +const PLAN_KV_RE = /^\s*-\s*([^:=]+?)\s*[:=]\s*(.+?)\s*$/; + +type OperatorSpec = readonly [family: string, role: DagOperatorRole, label: string]; + +export const OPERATOR_SPECS: Readonly> = { + RESULT_SINK_OPERATOR: ['RESULT', 'SINK', 'RESULT'], + DATA_STREAM_SINK_OPERATOR: ['REMOTE_EXCHANGE', 'SINK', 'DATA STREAM'], + EXCHANGE_OPERATOR: ['REMOTE_EXCHANGE', 'SOURCE', 'EXCHANGE'], + LOCAL_EXCHANGE_OPERATOR: ['LOCAL_EXCHANGE', 'SOURCE', 'LOCAL EXCHANGE'], + LOCAL_EXCHANGE_SINK_OPERATOR: ['LOCAL_EXCHANGE', 'SINK', 'LOCAL EXCHANGE SINK'], + HASH_JOIN_OPERATOR: ['HASH_JOIN', 'PROBE', 'HASH JOIN'], + HASH_JOIN_SINK_OPERATOR: ['HASH_JOIN', 'BUILD', 'HASH JOIN BUILD'], + PARTITIONED_HASH_JOIN_OPERATOR: ['PARTITIONED_HASH_JOIN', 'PROBE', 'PARTITIONED HASH JOIN'], + PARTITIONED_HASH_JOIN_SINK_OPERATOR: ['PARTITIONED_HASH_JOIN', 'BUILD', 'PARTITIONED HASH JOIN BUILD'], + NESTED_LOOP_JOIN_OPERATOR: ['CROSS_JOIN', 'PROBE', 'NESTED LOOP JOIN'], + NESTED_LOOP_JOIN_SINK_OPERATOR: ['CROSS_JOIN', 'BUILD', 'NESTED LOOP JOIN BUILD'], + CROSS_JOIN_OPERATOR: ['CROSS_JOIN', 'PROBE', 'CROSS JOIN'], + CROSS_JOIN_SINK_OPERATOR: ['CROSS_JOIN', 'BUILD', 'CROSS JOIN BUILD'], + AGGREGATION_OPERATOR: ['AGGREGATION', 'SOURCE', 'AGGREGATION'], + AGGREGATION_SINK_OPERATOR: ['AGGREGATION', 'SINK', 'AGGREGATION SINK'], + STREAMING_AGGREGATION_OPERATOR: ['STREAMING_AGGREGATION', 'SOURCE', 'STREAMING AGGREGATION'], + DISTINCT_STREAMING_AGGREGATION_OPERATOR: ['STREAMING_AGGREGATION', 'SOURCE', 'DISTINCT STREAMING AGGREGATION'], + SORT_OPERATOR: ['SORT', 'SOURCE', 'SORT'], + SORT_SINK_OPERATOR: ['SORT', 'SINK', 'SORT SINK'], + LOCAL_MERGE_SORT_SOURCE_OPERATOR: ['SORT', 'SOURCE', 'LOCAL MERGE SORT'], + PARTITION_SORT_OPERATOR: ['PARTITION_SORT', 'SOURCE', 'PARTITION SORT'], + PARTITION_SORT_SINK_OPERATOR: ['PARTITION_SORT', 'SINK', 'PARTITION SORT SINK'], + ANALYTIC_EVAL_OPERATOR: ['ANALYTIC_EVAL', 'SOURCE', 'ANALYTIC EVAL'], + ANALYTIC_EVAL_SINK_OPERATOR: ['ANALYTIC_EVAL', 'SINK', 'ANALYTIC EVAL SINK'], + MULTI_CAST_DATA_STREAM_SINK_OPERATOR: ['MULTICAST', 'PRODUCER', 'MULTI CAST PRODUCER'], + MULTI_CAST_DATA_STREAM_SOURCE_OPERATOR: ['MULTICAST', 'CONSUMER', 'MULTI CAST CONSUMER'], + OLAP_SCAN_OPERATOR: ['SCAN', 'SOURCE', 'OLAP SCAN'], + FILE_SCAN_OPERATOR: ['SCAN', 'SOURCE', 'FILE SCAN'], + GROUP_COMMIT_SCAN_OPERATOR: ['SCAN', 'SOURCE', 'GROUP COMMIT SCAN'], + ES_SCAN_OPERATOR: ['SCAN', 'SOURCE', 'ES SCAN'], + JDBC_SCAN_OPERATOR: ['SCAN', 'SOURCE', 'JDBC SCAN'], + SELECT_OPERATOR: ['SELECT', 'SOURCE', 'SELECT'], + UNION_OPERATOR: ['UNION', 'SOURCE', 'UNION'], + UNION_SINK_OPERATOR: ['UNION', 'SINK', 'UNION SINK'], + REPEAT_OPERATOR: ['REPEAT', 'SOURCE', 'REPEAT'], + TABLE_FUNCTION_OPERATOR: ['TABLE_FUNCTION', 'SOURCE', 'TABLE FUNCTION'], + ASSERT_NUM_ROWS_OPERATOR: ['ASSERT_NUM_ROWS', 'SOURCE', 'ASSERT NUM ROWS'], +}; + +const PLAN_INFO_WHITELIST: Readonly> = { + table: 'table', + table_name: 'table', + 'join op': 'joinOp', + 'equal join conjunct': 'joinConjunct', + 'other join predicates': 'joinOtherPredicates', + cardinality: 'cardinality', + 'group by': 'groupBy', + 'order by': 'orderBy', + limit: 'limit', + offset: 'offset', + algorithm: 'algorithm', + 'runtime filters': 'runtimeFilters', + partitions: 'partitions', + tablet: 'tablets', +}; + +type CounterValues = Record<'sum' | 'avg' | 'max' | 'min', number | undefined>; + +interface ParsedNode extends ProfileDagNode { + counters: Record; +} + +type PairingRule = readonly [ + family: string, + sourceRoles: readonly DagOperatorRole[], + targetRoles: readonly DagOperatorRole[], + kind: DagEdgeKind, +]; + +const PAIRING_RULES: readonly PairingRule[] = [ + ['LOCAL_EXCHANGE', ['SINK'], ['SOURCE'], 'LOCAL_EXCHANGE'], + ['HASH_JOIN', ['BUILD'], ['PROBE'], 'BUILD_DEPENDENCY'], + ['PARTITIONED_HASH_JOIN', ['BUILD'], ['PROBE'], 'BUILD_DEPENDENCY'], + ['CROSS_JOIN', ['BUILD'], ['PROBE'], 'BUILD_DEPENDENCY'], + ['AGGREGATION', ['SINK'], ['SOURCE'], 'BLOCKING_DEPENDENCY'], + ['SORT', ['SINK'], ['SOURCE'], 'BLOCKING_DEPENDENCY'], + ['PARTITION_SORT', ['SINK'], ['SOURCE'], 'BLOCKING_DEPENDENCY'], + ['ANALYTIC_EVAL', ['SINK'], ['SOURCE'], 'BLOCKING_DEPENDENCY'], + ['UNION', ['SINK'], ['SOURCE'], 'BLOCKING_DEPENDENCY'], +]; + +const utf8Encoder = new TextEncoder(); + +function fail(code: ProfileParserErrorCode, message: string): never { + throw new ProfileParserError(code, message); +} + +function utf8Length(value: string): number { + return utf8Encoder.encode(value).byteLength; +} + +function safeInteger(value: number, code: ProfileParserErrorCode = 'DAG_PARSE_FAILED'): number { + if (!Number.isSafeInteger(value) || value < 0) { + fail(code, 'The Profile contains a metric outside the supported numeric range.'); + } + return value; +} + +function parseAttributes(value: string): Record { + const attributes: Record = {}; + for (const match of value.matchAll(ATTRIBUTE_RE)) { + const parsed = Number(match[2]); + if (!Number.isSafeInteger(parsed)) continue; + (attributes[match[1]] ??= []).push(parsed); + } + return attributes; +} + +function counterKind(name: string): 'time' | 'bytes' | 'count' { + if (name === 'ExecTime' || name.includes('Time')) return 'time'; + if (name.includes('Memory') || name.includes('Bytes')) return 'bytes'; + return 'count'; +} + +function parseTimeToken(token: string): number | null { + const compact = token.replace(/\s+/g, ''); + const part = /([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|sec|s|min|h)/g; + const factors: Record = { + ns: 1, + us: 1e3, + 'µs': 1e3, + ms: 1e6, + sec: 1e9, + s: 1e9, + min: 60e9, + h: 3_600e9, + }; + let total = 0; + let consumed = ''; + for (const match of compact.matchAll(part)) { + consumed += match[0]; + total += Number(match[1]) * factors[match[2]]; + } + if (!consumed || consumed !== compact || !Number.isFinite(total)) return null; + return safeInteger(Math.round(total)); +} + +function parseToken(token: string, kind: ReturnType): number | null { + if (kind === 'time') return parseTimeToken(token); + if (kind === 'bytes') { + const match = token.match(/^([0-9]+(?:\.[0-9]+)?)\s*(B|KB|MB|GB|TB)?\s*$/); + if (!match) return null; + const factor = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 }[ + match[2] as 'B' | 'KB' | 'MB' | 'GB' | 'TB' + ] ?? 1; + return safeInteger(Math.round(Number(match[1]) * factor)); + } + const match = token.match(/^([0-9]+(?:\.[0-9]+)?)\s*([KMGB]?)(?:\s*\(([0-9]+)\))?\s*$/); + if (!match) return null; + if (match[3]) return safeInteger(Number(match[3])); + const factor = { '': 1, K: 1e3, M: 1e6, G: 1e9, B: 1e9 }[match[2]] ?? 1; + return safeInteger(Math.round(Number(match[1]) * factor)); +} + +export function parseCounter(name: string, value: string): CounterValues { + const values: CounterValues = { sum: undefined, avg: undefined, max: undefined, min: undefined }; + const kind = counterKind(name); + for (const part of value.split(',')) { + const match = part.trim().match(/^(sum|avg|max|min)\s+(.+)$/); + if (!match) continue; + const parsed = parseToken(match[2].trim(), kind); + if (parsed !== null) values[match[1] as keyof CounterValues] = parsed; + } + return values; +} + +function humanizeNanoseconds(value: number): string { + if (value >= 1e9) return `${(value / 1e9).toFixed(2)}s`; + if (value >= 1e6) return `${(value / 1e6).toFixed(2)}ms`; + if (value >= 1e3) return `${(value / 1e3).toFixed(2)}us`; + return `${value}ns`; +} + +function aggregateMetric(counter: CounterValues | undefined): DagAggregateMetric | null { + if (!counter || Object.values(counter).every(value => value === undefined)) return null; + return { + sum: counter.sum ?? null, + avg: counter.avg ?? null, + max: counter.max ?? null, + min: counter.min ?? null, + }; +} + +function finalizeNode(node: ParsedNode): ProfileDagNode { + const exec = node.counters.ExecTime; + const waitCounters = Object.entries(node.counters).filter(([name]) => name.startsWith('WaitFor') && name.endsWith('Time')); + const waitMaxValues = waitCounters.map(([, counter]) => counter.max).filter((value): value is number => value !== undefined); + const waitAverageValues = waitCounters.map(([, counter]) => counter.avg).filter((value): value is number => value !== undefined); + const breakdown = { + waitForDependencyNs: 0, + waitForDataNs: 0, + waitForRpcBufferQueueNs: 0, + }; + for (const [name, counter] of waitCounters) { + const value = counter.max ?? 0; + if (name.startsWith('WaitForRpcBufferQueue')) breakdown.waitForRpcBufferQueueNs += value; + else if (name.startsWith('WaitForData')) breakdown.waitForDataNs += value; + else breakdown.waitForDependencyNs += value; + } + + const timing: ProfileDagNode['timing'] = {}; + if (exec && Object.values(exec).some(value => value !== undefined)) { + timing.execTime = { + sumNs: exec.sum ?? null, + avgNs: exec.avg ?? null, + maxNs: exec.max ?? null, + minNs: exec.min ?? null, + display: + exec.max !== undefined && exec.avg !== undefined + ? `max ${humanizeNanoseconds(exec.max)} / avg ${humanizeNanoseconds(exec.avg)}` + : undefined, + }; + } + if (waitCounters.length > 0) { + const maxNs = waitMaxValues.length > 0 ? Math.max(...waitMaxValues) : 0; + const totalNs = waitMaxValues.reduce((total, value) => total + value, 0); + const avgNs = + waitAverageValues.length > 0 + ? Math.round(waitAverageValues.reduce((total, value) => total + value, 0) / waitAverageValues.length) + : 0; + timing.waitTime = { + totalNs: safeInteger(totalNs), + maxNs, + avgNs: safeInteger(avgNs), + display: `max ${humanizeNanoseconds(maxNs)}`, + breakdown, + }; + } + + const { counters: _counters, ...output } = node; + return { + ...output, + timing, + metrics: { + inputRows: aggregateMetric(node.counters.RowsProduced), + outputRows: aggregateMetric(node.counters.RowsReturned), + memoryUsageBytes: aggregateMetric(node.counters.MemoryUsage), + memoryPeakBytes: aggregateMetric(node.counters.MemoryUsagePeak), + }, + analysis: { heat: null, waitHeat: null, isBottleneck: false }, + }; +} + +function relationKey(fragmentId: string, family: string, idKind: 'nereids' | 'plan', id: number): string { + return `${fragmentId}\0${family}\0${idKind}\0${id}`; +} + +function validateGraph(nodes: ProfileDagNode[], edges: ProfileDagEdge[]): void { + const nodeIds = new Set(nodes.map(node => node.id)); + if (nodeIds.size !== nodes.length) fail('DAG_PARSE_FAILED', 'The execution graph contains duplicate node IDs.'); + + const outgoing = new Map(); + const indegree = new Map(nodes.map(node => [node.id, 0])); + const edgeKeys = new Set(); + for (const edge of edges) { + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target) || edge.source === edge.target) { + fail('DAG_PARSE_FAILED', 'The execution graph contains an invalid edge.'); + } + const key = `${edge.source}\0${edge.target}\0${edge.kind}`; + if (edgeKeys.has(key)) fail('DAG_PARSE_FAILED', 'The execution graph contains a duplicate edge.'); + edgeKeys.add(key); + (outgoing.get(edge.source) ?? outgoing.set(edge.source, []).get(edge.source) as string[]).push(edge.target); + indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); + } + + const queue = [...indegree.entries()].filter(([, degree]) => degree === 0).map(([id]) => id); + let visited = 0; + for (let index = 0; index < queue.length; index += 1) { + const id = queue[index]; + visited += 1; + for (const target of outgoing.get(id) ?? []) { + const next = (indegree.get(target) ?? 0) - 1; + indegree.set(target, next); + if (next === 0) queue.push(target); + } + } + if (visited !== nodes.length) fail('DAG_PARSE_FAILED', 'The execution graph contains a cycle.'); +} + +function addPerformanceAnalysis(nodes: ProfileDagNode[]): { + criticalNodeId: string | null; + maxExecTimeNs: number | null; + maxWaitTimeNs: number | null; +} { + const execNodes = nodes.filter(node => node.timing?.execTime?.maxNs !== null && node.timing?.execTime?.maxNs !== undefined); + const waitNodes = nodes.filter(node => node.timing?.waitTime?.maxNs !== null && node.timing?.waitTime?.maxNs !== undefined); + const maxExecTimeNs = execNodes.length > 0 ? Math.max(...execNodes.map(node => node.timing?.execTime?.maxNs as number)) : null; + const maxWaitTimeNs = waitNodes.length > 0 ? Math.max(...waitNodes.map(node => node.timing?.waitTime?.maxNs as number)) : null; + const criticalNodeId = + maxExecTimeNs !== null ? execNodes.find(node => node.timing?.execTime?.maxNs === maxExecTimeNs)?.id ?? null : null; + + for (const node of nodes) { + const execMax = node.timing?.execTime?.maxNs; + const waitMax = node.timing?.waitTime?.maxNs; + node.analysis = { + heat: + execMax === null || execMax === undefined || maxExecTimeNs === null || maxExecTimeNs === 0 + ? null + : Math.round((execMax / maxExecTimeNs) * 10_000) / 10_000, + waitHeat: + waitMax === null || waitMax === undefined || maxWaitTimeNs === null || maxWaitTimeNs === 0 + ? null + : Math.round((waitMax / maxWaitTimeNs) * 10_000) / 10_000, + isBottleneck: maxExecTimeNs !== null && maxExecTimeNs > 0 && node.id === criticalNodeId, + }; + } + return { criticalNodeId, maxExecTimeNs, maxWaitTimeNs }; +} + +export function parseProfileText(text: string): ProfileGraphIR { + if (utf8Length(text) > MAX_PARSER_BYTES) fail('DAG_TOO_LARGE', 'The prepared Profile is larger than 10 MiB.'); + const lines = text.split(/\r?\n/); + if (lines.length > MAX_PARSER_LINES) fail('DAG_TOO_LARGE', 'The Profile contains too many lines.'); + + const fragments: ProfileDagFragment[] = []; + const pipelines: ProfileDagPipeline[] = []; + const parsedNodes: ParsedNode[] = []; + const warnings: ProfileDagWarning[] = []; + let inMerged = false; + let currentFragment: ProfileDagFragment | null = null; + let currentPipeline: ProfileDagPipeline | null = null; + let currentNode: ParsedNode | null = null; + let section: 'plan' | 'counters' | null = null; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.length > MAX_PARSER_LINE_BYTES || (line.length > MAX_PARSER_LINE_BYTES / 4 && utf8Length(line) > MAX_PARSER_LINE_BYTES)) { + fail('DAG_TOO_LARGE', 'The Profile contains a line larger than 64 KiB.'); + } + if (!inMerged) { + if (MERGED_RE.test(line)) inMerged = true; + continue; + } + if (DETAIL_RE.test(line) || EXEC_PROFILE_RE.test(line)) break; + + const fragmentMatch = line.match(FRAGMENT_RE); + if (fragmentMatch) { + const number = Number(fragmentMatch[1]); + currentFragment = { + id: `fragment:${number}`, + number, + pipelineIds: [], + nodeIds: [], + }; + fragments.push(currentFragment); + if (fragments.length > MAX_FRAGMENTS) fail('DAG_TOO_LARGE', 'The Profile contains too many Fragments.'); + currentPipeline = null; + currentNode = null; + section = null; + continue; + } + + const pipelineMatch = line.match(PIPELINE_RE); + if (pipelineMatch && currentFragment) { + const number = Number(pipelineMatch[1]); + currentPipeline = { + id: `${currentFragment.id}/pipeline:${number}`, + fragmentId: currentFragment.id, + number, + instanceNum: Number(pipelineMatch[2]), + nodeIds: [], + }; + pipelines.push(currentPipeline); + currentFragment.pipelineIds.push(currentPipeline.id); + if (pipelines.length > MAX_PIPELINES) fail('DAG_TOO_LARGE', 'The Profile contains too many Pipelines.'); + currentNode = null; + section = null; + continue; + } + + const operatorMatch = line.match(OPERATOR_RE); + if (operatorMatch && currentFragment && currentPipeline) { + const operatorType = operatorMatch[1]; + const spec = OPERATOR_SPECS[operatorType]; + const attributes = parseAttributes(operatorMatch[2]); + const ordinal = currentPipeline.nodeIds.length; + const id = `${currentPipeline.id}/operator:${ordinal}`; + const table = operatorMatch[2].match(TABLE_NAME_RE)?.[1]?.trim(); + currentNode = { + id, + fragmentId: currentFragment.id, + pipelineId: currentPipeline.id, + ordinal, + operatorType, + operatorFamily: spec?.[0] ?? 'UNKNOWN', + role: spec?.[1] ?? 'UNKNOWN', + label: spec?.[2] ?? operatorType.replace(/_OPERATOR$/, '').replace(/_/g, ' '), + planNodeId: attributes.id?.[0] ?? null, + nereidsId: attributes.nereids_id?.[0] ?? null, + destId: attributes.dest_id?.[0] ?? null, + destIds: attributes.dest_id ?? [], + known: Boolean(spec), + lineNumber: index + 1, + headerAttributes: attributes, + planInfo: table ? { table } : {}, + timing: {}, + metrics: {}, + analysis: { heat: null, waitHeat: null, isBottleneck: false }, + counters: {}, + }; + parsedNodes.push(currentNode); + currentPipeline.nodeIds.push(id); + currentFragment.nodeIds.push(id); + if (!spec) warnings.push({ kind: 'UNKNOWN_OPERATOR', nodeId: id, operatorType }); + if (parsedNodes.length > MAX_DAG_NODES) fail('DAG_TOO_LARGE', 'The execution graph contains too many nodes.'); + section = null; + continue; + } + if (!currentNode) continue; + if (PLANINFO_RE.test(line)) { + section = 'plan'; + continue; + } + if (COUNTERS_RE.test(line)) { + section = 'counters'; + continue; + } + if (section === 'counters') { + const counterMatch = line.match(COUNTER_RE); + if (counterMatch) currentNode.counters[counterMatch[1]] = parseCounter(counterMatch[1], counterMatch[2]); + continue; + } + if (section === 'plan') { + const planMatch = line.match(PLAN_KV_RE); + if (!planMatch) continue; + const outputKey = PLAN_INFO_WHITELIST[planMatch[1].trim().toLowerCase()]; + if (outputKey && currentNode.planInfo[outputKey] === undefined) { + currentNode.planInfo[outputKey] = planMatch[2].trim().slice(0, MAX_PLAN_INFO_VALUE_LENGTH); + } + } + } + + if (!inMerged || parsedNodes.length === 0) fail('DAG_UNAVAILABLE', 'The Profile has no usable MergedProfile operators.'); + const nodes = parsedNodes.map(finalizeNode); + const edges: ProfileDagEdge[] = []; + const unresolvedReferences: ProfileDagUnresolvedReference[] = []; + + const addEdge = ( + kind: DagEdgeKind, + source: string, + target: string, + relationId: number | null = null, + metadata: ProfileDagEdge['metadata'] = {}, + ) => { + edges.push({ + id: `edge:${edges.length}`, + kind, + source, + target, + relationId: relationId === null ? null : String(relationId), + resolved: true, + metadata, + }); + if (edges.length > MAX_DAG_EDGES) fail('DAG_TOO_LARGE', 'The execution graph contains too many edges.'); + }; + const addUnresolved = (kind: string, relationId: number | null, sourceNodeId: string, count: number) => { + unresolvedReferences.push({ + kind, + relationId: relationId === null ? null : String(relationId), + sourceNodeId, + reason: count === 0 ? 'TARGET_NOT_FOUND' : 'AMBIGUOUS_TARGET', + }); + }; + + for (const pipeline of pipelines) { + for (let index = pipeline.nodeIds.length - 1; index > 0; index -= 1) { + addEdge('PIPELINE_DATA', pipeline.nodeIds[index], pipeline.nodeIds[index - 1], null, { pipelineId: pipeline.id }); + } + } + + const receiversByPlanNode = new Map(); + for (const node of nodes.filter(candidate => candidate.operatorType === 'EXCHANGE_OPERATOR' && candidate.planNodeId !== null)) { + const id = node.planNodeId as number; + (receiversByPlanNode.get(id) ?? receiversByPlanNode.set(id, []).get(id) as ProfileDagNode[]).push(node); + } + for (const sink of nodes.filter(candidate => candidate.operatorType === 'DATA_STREAM_SINK_OPERATOR')) { + for (const destId of sink.destIds) { + const targets = receiversByPlanNode.get(destId) ?? []; + if (targets.length === 1) { + addEdge('EXCHANGE', sink.id, targets[0].id, destId, { destId, crossFragment: sink.fragmentId !== targets[0].fragmentId }); + } else addUnresolved('EXCHANGE', destId, sink.id, targets.length); + } + } + + const semanticIndex = new Map(); + for (const node of nodes) { + if (node.nereidsId !== null && node.nereidsId !== undefined) { + const key = relationKey(node.fragmentId, node.operatorFamily, 'nereids', node.nereidsId); + (semanticIndex.get(key) ?? semanticIndex.set(key, []).get(key) as ProfileDagNode[]).push(node); + } + if (node.planNodeId !== null && node.planNodeId !== undefined) { + const key = relationKey(node.fragmentId, node.operatorFamily, 'plan', node.planNodeId); + (semanticIndex.get(key) ?? semanticIndex.set(key, []).get(key) as ProfileDagNode[]).push(node); + } + } + for (const [family, sourceRoles, targetRoles, kind] of PAIRING_RULES) { + for (const source of nodes.filter(node => node.operatorFamily === family && sourceRoles.includes(node.role as DagOperatorRole))) { + let relationId: number | null = null; + let candidates: ProfileDagNode[] = []; + if (source.nereidsId !== null && source.nereidsId !== undefined) { + relationId = source.nereidsId; + candidates = semanticIndex.get(relationKey(source.fragmentId, family, 'nereids', relationId)) ?? []; + } + candidates = candidates.filter(node => node.id !== source.id && targetRoles.includes(node.role as DagOperatorRole)); + if (candidates.length === 0 && source.planNodeId !== null && source.planNodeId !== undefined) { + relationId = source.planNodeId; + candidates = (semanticIndex.get(relationKey(source.fragmentId, family, 'plan', relationId)) ?? []).filter( + node => node.id !== source.id && targetRoles.includes(node.role as DagOperatorRole), + ); + } + if (candidates.length === 1) addEdge(kind, source.id, candidates[0].id, relationId); + else addUnresolved(kind, relationId, source.id, candidates.length); + } + } + + const multicastConsumers = new Map(); + for (const node of nodes.filter(candidate => candidate.operatorType === 'MULTI_CAST_DATA_STREAM_SOURCE_OPERATOR' && candidate.planNodeId !== null)) { + const id = node.planNodeId as number; + (multicastConsumers.get(id) ?? multicastConsumers.set(id, []).get(id) as ProfileDagNode[]).push(node); + } + for (const producer of nodes.filter(candidate => candidate.operatorType === 'MULTI_CAST_DATA_STREAM_SINK_OPERATOR')) { + producer.destIds.forEach((destId, branchIndex) => { + const targets = multicastConsumers.get(destId) ?? []; + if (targets.length === 1) { + addEdge('MULTICAST', producer.id, targets[0].id, destId, { + destId, + branchIndex, + crossFragment: producer.fragmentId !== targets[0].fragmentId, + }); + } else addUnresolved('MULTICAST', destId, producer.id, targets.length); + }); + } + + validateGraph(nodes, edges); + const performance = addPerformanceAnalysis(nodes); + return { + schemaVersion: '1.0', + parserVersion: PROFILE_PARSER_VERSION, + profile: {}, + graph: { direction: 'BOTTOM_TO_TOP', nodes, edges }, + fragments, + pipelines, + unresolvedReferences, + warnings, + summary: { + fragmentCount: fragments.length, + pipelineCount: pipelines.length, + nodeCount: nodes.length, + edgeCount: edges.length, + unresolvedEdgeCount: unresolvedReferences.length, + ...performance, + }, + }; +} diff --git a/src/components/profile-analysis/profile-analysis.parser.worker.ts b/src/components/profile-analysis/profile-analysis.parser.worker.ts new file mode 100644 index 0000000000000..4bab10a3939b8 --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.parser.worker.ts @@ -0,0 +1,39 @@ +/// + +import { MAX_PARSER_BYTES, parseProfileText, ProfileParserError } from './profile-analysis.parser'; +import type { ProfileParseRequest, ProfileParseResponse } from './profile-analysis.parser-protocol'; + +export async function parseProfileFile(file: File) { + if (file.size > MAX_PARSER_BYTES) { + throw new ProfileParserError('DAG_TOO_LARGE', 'The prepared Profile is larger than 10 MiB.'); + } + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer()); + } catch { + throw new ProfileParserError('DAG_PARSE_FAILED', 'The Profile is not valid UTF-8.'); + } + return parseProfileText(text); +} + +const workerScope = globalThis as typeof globalThis & { + postMessage?: (message: ProfileParseResponse) => void; + onmessage?: ((event: MessageEvent) => void) | null; +}; + +if (typeof WorkerGlobalScope !== 'undefined' && globalThis instanceof WorkerGlobalScope) { + workerScope.onmessage = event => { + if (event.data?.type !== 'PARSE_PROFILE') return; + const { requestId, file } = event.data; + void parseProfileFile(file) + .then(dag => workerScope.postMessage?.({ type: 'PARSE_SUCCESS', requestId, dag })) + .catch(reason => { + workerScope.postMessage?.({ + type: 'PARSE_FAILURE', + requestId, + code: reason instanceof ProfileParserError ? reason.code : 'DAG_PARSE_FAILED', + }); + }); + }; +} + diff --git a/src/components/profile-analysis/profile-analysis.recovery.test.js b/src/components/profile-analysis/profile-analysis.recovery.test.js index bcdd46ba41f12..6caa4592619ca 100644 --- a/src/components/profile-analysis/profile-analysis.recovery.test.js +++ b/src/components/profile-analysis/profile-analysis.recovery.test.js @@ -199,38 +199,6 @@ test('keeps polling the same job after three transport failures and recovers on assert.deepEqual(waits, [2000, 4000, 8000, 2000]); }); -test('keeps polling after Codex completes until the independent DAG work is settled', async () => { - let getCalls = 0; - let dagSettled = false; - const snapshots = []; - - const terminal = await pollAnalysisJobWithRecovery({ - get: async () => { - getCalls += 1; - return { - jobId, - status: 'COMPLETED', - result: { id: 'item-1', type: 'agent_message', text: 'done' }, - dagStatus: getCalls === 1 ? 'PARSING' : 'READY', - dagError: null, - }; - }, - wait: async () => {}, - onRecovering: () => {}, - onProgress: () => {}, - onSnapshot: job => { - snapshots.push(job.dagStatus); - dagSettled = job.dagStatus === 'READY'; - }, - isComplete: () => dagSettled, - pollIntervalMs: 2000, - }); - - assert.equal(terminal.status, 'COMPLETED'); - assert.equal(getCalls, 2); - assert.deepEqual(snapshots, ['PARSING', 'READY']); -}); - test('treats a recovery 404 as final after the grace window', async () => { const createdAt = 20_000; await assert.rejects( diff --git a/src/components/profile-analysis/profile-analysis.types.ts b/src/components/profile-analysis/profile-analysis.types.ts index 584d685bd99f6..c70c04516d610 100644 --- a/src/components/profile-analysis/profile-analysis.types.ts +++ b/src/components/profile-analysis/profile-analysis.types.ts @@ -24,13 +24,7 @@ export type AnalysisState = export type AnalysisJobStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED'; -export type DagStatus = 'PENDING' | 'PARSING' | 'READY' | 'UNAVAILABLE' | 'FAILED'; -export type DagUiState = 'idle' | 'pending' | 'parsing' | 'loading' | 'ready' | 'unavailable' | 'failed'; - -export interface DagJobState { - dagStatus: DagStatus; - dagError: string | null; -} +export type DagUiState = 'idle' | 'parsing' | 'ready' | 'unavailable' | 'failed'; export interface CreateAnalysisJobResponse { jobId: string; @@ -41,16 +35,13 @@ export interface CreateAnalysisJobResponse { export interface RecoveredAnalysisJobResponse { jobId: string; status: AnalysisJobStatus; - dagStatus?: DagStatus; - dagError?: string | null; } -export type AnalysisJobSnapshot = DagJobState & ( +export type AnalysisJobSnapshot = | { jobId: string; status: 'QUEUED'; jobsAhead: number } | { jobId: string; status: 'RUNNING' } | { jobId: string; status: 'COMPLETED'; result: AgentMessage } - | { jobId: string; status: 'FAILED'; error: ApiErrorBody } -); + | { jobId: string; status: 'FAILED'; error: ApiErrorBody }; export type DagOperatorRole = | 'SOURCE' @@ -174,10 +165,10 @@ export interface ProfileDagSummary { maxWaitTimeNs?: number | null; } -export interface ProfileDag { +export interface ProfileGraphIR { schemaVersion: '1.0'; parserVersion?: string; - jobId: string; + jobId?: string; profile: Record; graph: { direction: 'BOTTOM_TO_TOP'; @@ -191,8 +182,5 @@ export interface ProfileDag { summary: ProfileDagSummary; } -export type ProfileDagResponse = ProfileDag; - -export type ProfileDagFetchResult = - | { dagStatus: 'READY'; dag: ProfileDagResponse } - | { dagStatus: 'PENDING' | 'PARSING'; jobId: string; retryAfterMs: number }; +export type ProfileDag = ProfileGraphIR; +export type ProfileDagResponse = ProfileGraphIR; diff --git a/src/components/profile-analysis/use-local-profile-dag.test.js b/src/components/profile-analysis/use-local-profile-dag.test.js new file mode 100644 index 0000000000000..3b93ebddccdb9 --- /dev/null +++ b/src/components/profile-analysis/use-local-profile-dag.test.js @@ -0,0 +1,43 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); +const typescript = require('typescript'); + +const previousTypeScriptLoader = require.extensions['.ts']; +require.extensions['.ts'] = (module, filename) => { + const output = typescript.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { + esModuleInterop: true, + jsx: typescript.JsxEmit.React, + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2020, + }, + }).outputText; + module._compile(output, filename); +}; +const { initialLocalProfileDagSnapshot, localProfileDagReducer } = require('./use-local-profile-dag.ts'); +require.extensions['.ts'] = previousTypeScriptLoader; + +const dag = { + schemaVersion: '1.0', profile: {}, graph: { direction: 'BOTTOM_TO_TOP', nodes: [], edges: [] }, + fragments: [], pipelines: [], unresolvedReferences: [], warnings: [], + summary: { fragmentCount: 0, pipelineCount: 0, nodeCount: 0, edgeCount: 0, unresolvedEdgeCount: 0 }, +}; + +test('local DAG reducer has an independent parsing and success lifecycle', () => { + const parsing = localProfileDagReducer(initialLocalProfileDagSnapshot, { type: 'start' }); + assert.deepEqual(parsing, { state: 'parsing', dag: null, error: null }); + assert.deepEqual(localProfileDagReducer(parsing, { type: 'success', dag }), { + state: 'ready', dag, error: null, + }); +}); + +test('local DAG failure never carries a stale graph and reset returns to idle', () => { + const ready = { state: 'ready', dag, error: null }; + const failed = localProfileDagReducer(ready, { + type: 'failure', state: 'unavailable', error: 'No MergedProfile.', + }); + assert.deepEqual(failed, { state: 'unavailable', dag: null, error: 'No MergedProfile.' }); + assert.equal(localProfileDagReducer(failed, { type: 'reset' }), initialLocalProfileDagSnapshot); +}); + diff --git a/src/components/profile-analysis/use-local-profile-dag.ts b/src/components/profile-analysis/use-local-profile-dag.ts new file mode 100644 index 0000000000000..07cd7b5ae7160 --- /dev/null +++ b/src/components/profile-analysis/use-local-profile-dag.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { + profileParserErrorMessage, + startProfileParse, + type ProfileParseOperation, + type ProfileParserWorkerFactory, +} from './profile-analysis.parser-client'; +import { ProfileParserError } from './profile-analysis.parser'; +import type { DagUiState, ProfileGraphIR } from './profile-analysis.types'; + +export interface LocalProfileDagSnapshot { + state: DagUiState; + dag: ProfileGraphIR | null; + error: string | null; +} + +type LocalProfileDagAction = + | { type: 'reset' } + | { type: 'start' } + | { type: 'success'; dag: ProfileGraphIR } + | { type: 'failure'; state: Extract; error: string }; + +export const initialLocalProfileDagSnapshot: LocalProfileDagSnapshot = { + state: 'idle', + dag: null, + error: null, +}; + +export function localProfileDagReducer( + snapshot: LocalProfileDagSnapshot, + action: LocalProfileDagAction, +): LocalProfileDagSnapshot { + switch (action.type) { + case 'reset': + return initialLocalProfileDagSnapshot; + case 'start': + return { state: 'parsing', dag: null, error: null }; + case 'success': + return { state: 'ready', dag: action.dag, error: null }; + case 'failure': + return { state: action.state, dag: null, error: action.error }; + } +} + +function isAbortError(reason: unknown): boolean { + return reason instanceof Error && reason.name === 'AbortError'; +} + +export function useLocalProfileDag(createWorker: ProfileParserWorkerFactory) { + const [snapshot, dispatch] = useReducer(localProfileDagReducer, initialLocalProfileDagSnapshot); + const operationRef = useRef(null); + const mountedRef = useRef(true); + + const cancel = useCallback(() => { + operationRef.current?.cancel(); + operationRef.current = null; + }, []); + + const reset = useCallback(() => { + cancel(); + dispatch({ type: 'reset' }); + }, [cancel]); + + const buildGraph = useCallback( + async (file: File) => { + cancel(); + dispatch({ type: 'start' }); + const operation = startProfileParse(file, createWorker); + operationRef.current = operation; + try { + const dag = await operation.promise; + if (mountedRef.current && operationRef.current === operation) { + operationRef.current = null; + dispatch({ type: 'success', dag }); + } + } catch (reason) { + if (isAbortError(reason)) return; + if (mountedRef.current && operationRef.current === operation) { + operationRef.current = null; + const code = reason instanceof ProfileParserError ? reason.code : 'DAG_PARSE_FAILED'; + dispatch({ + type: 'failure', + state: code === 'DAG_UNAVAILABLE' || code === 'DAG_TOO_LARGE' ? 'unavailable' : 'failed', + error: reason instanceof Error ? reason.message : profileParserErrorMessage(code), + }); + } + } + }, + [cancel, createWorker], + ); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + cancel(); + }; + }, [cancel]); + + return { + ...snapshot, + isBusy: snapshot.state === 'parsing', + buildGraph, + reset, + }; +} + diff --git a/src/components/profile-analysis/use-profile-analysis.test.js b/src/components/profile-analysis/use-profile-analysis.test.js index 96428fb06afa4..90805df0c2dc6 100644 --- a/src/components/profile-analysis/use-profile-analysis.test.js +++ b/src/components/profile-analysis/use-profile-analysis.test.js @@ -39,9 +39,6 @@ test('moves from idle through ready, analyzing, and completed', () => { jobsAhead: null, result: null, error: null, - dagState: 'idle', - dag: null, - dagError: null, recoveryWarning: null, }); @@ -49,7 +46,7 @@ test('moves from idle through ready, analyzing, and completed', () => { assert.equal(submitting.state, 'submitting'); const queued = profileAnalysisReducer(submitting, { type: 'job_created', jobId: 'job-1', status: 'QUEUED' }); const analyzing = profileAnalysisReducer(queued, { - type: 'job_status', job: { jobId: 'job-1', status: 'RUNNING', dagStatus: 'PARSING', dagError: null }, + type: 'job_status', job: { jobId: 'job-1', status: 'RUNNING' }, }); const completed = profileAnalysisReducer(analyzing, { type: 'complete', result }); @@ -75,9 +72,6 @@ test('stores failures and clears the old result and error when a new file is sel jobsAhead: null, result, error: null, - dagState: 'idle', - dag: null, - dagError: null, recoveryWarning: null, }; const failed = profileAnalysisReducer(completed, { type: 'fail', error: 'Analyzer unavailable' }); @@ -89,9 +83,6 @@ test('stores failures and clears the old result and error when a new file is sel jobsAhead: null, result: null, error: 'Analyzer unavailable', - dagState: 'failed', - dag: null, - dagError: 'The execution graph can no longer be recovered.', recoveryWarning: null, }); @@ -104,9 +95,6 @@ test('stores failures and clears the old result and error when a new file is sel jobsAhead: null, result: null, error: null, - dagState: 'idle', - dag: null, - dagError: null, recoveryWarning: null, }); }); @@ -120,9 +108,6 @@ test('stores response language per request, clears stale output, and freezes it jobsAhead: null, result, error: null, - dagState: 'idle', - dag: null, - dagError: null, recoveryWarning: null, }; const chinese = profileAnalysisReducer(completed, { type: 'set_language', language: 'zh-CN' }); @@ -134,9 +119,6 @@ test('stores response language per request, clears stale output, and freezes it jobsAhead: null, result: null, error: null, - dagState: 'idle', - dag: null, - dagError: null, recoveryWarning: null, }); @@ -163,8 +145,6 @@ test('restores persisted job metadata before polling resumes', () => { job: { jobId: '550e8400-e29b-41d4-a716-446655440000', status: 'RUNNING', - dagStatus: 'PARSING', - dagError: null, }, }); assert.equal(running.state, 'analyzing'); @@ -185,9 +165,6 @@ test('keeps an uncertain analysis busy while its original identifiers are recove jobsAhead: null, result: null, error: null, - dagState: 'parsing', - dag: null, - dagError: null, recoveryWarning: null, }; const recovering = profileAnalysisReducer(running, { type: 'recovering' }); @@ -198,70 +175,18 @@ test('keeps an uncertain analysis busy while its original identifiers are recove assert.equal(profileAnalysisReducer(recovering, { type: 'select', file: secondFile }), recovering); }); -test('keeps the current task busy when Codex completes before the execution graph', () => { +test('settles the AI state as soon as Codex completes without waiting for a DAG status', () => { const ready = profileAnalysisReducer(idleSnapshot, { type: 'select', file: firstFile }); const submitting = profileAnalysisReducer(ready, { type: 'start' }); const queued = profileAnalysisReducer(submitting, { type: 'job_created', jobId: 'job-1', status: 'QUEUED' }); - const codexCompleted = profileAnalysisReducer(queued, { + const completed = profileAnalysisReducer(queued, { type: 'job_status', - job: { - jobId: 'job-1', - status: 'COMPLETED', - result, - dagStatus: 'PARSING', - dagError: null, - }, - }); - - assert.equal(codexCompleted.state, 'completed'); - assert.equal(codexCompleted.dagState, 'parsing'); - assert.equal(profileAnalysisReducer(codexCompleted, { type: 'select', file: secondFile }), codexCompleted); - assert.equal(profileAnalysisReducer(codexCompleted, { type: 'start' }), codexCompleted); -}); - -test('keeps a ready execution graph when the independent AI analysis fails', () => { - const graph = { schemaVersion: '1.0', jobId: 'job-1' }; - const withGraph = profileAnalysisReducer( - { ...idleSnapshot, state: 'analyzing', jobId: 'job-1', dagState: 'loading' }, - { type: 'dag_loaded', dag: graph }, - ); - const failed = profileAnalysisReducer(withGraph, { - type: 'job_status', - job: { - jobId: 'job-1', - status: 'FAILED', - error: { code: 'CODEX_EXECUTION_FAILED', message: 'AI analysis failed.' }, - dagStatus: 'READY', - dagError: null, - }, - }); - - assert.equal(failed.state, 'failed'); - assert.equal(failed.error, 'AI analysis failed.'); - assert.equal(failed.dagState, 'ready'); - assert.equal(failed.dag, graph); -}); - -test('does not turn a terminal DAG client failure back into an endless loading state', () => { - const terminalDagFailure = { - ...idleSnapshot, - state: 'analyzing', - jobId: 'job-1', - dagState: 'failed', - dagError: 'The execution graph could not be loaded.', - }; - const next = profileAnalysisReducer(terminalDagFailure, { - type: 'job_status', - job: { - jobId: 'job-1', - status: 'RUNNING', - dagStatus: 'READY', - dagError: null, - }, + job: { jobId: 'job-1', status: 'COMPLETED', result }, }); - assert.equal(next.dagState, 'failed'); - assert.equal(next.dagError, terminalDagFailure.dagError); + assert.equal(completed.state, 'completed'); + assert.equal(completed.result, result); + assert.equal(profileAnalysisReducer(completed, { type: 'select', file: secondFile }).file, secondFile); }); test('normalizes unknown failures without exposing non-error values', () => { diff --git a/src/components/profile-analysis/use-profile-analysis.ts b/src/components/profile-analysis/use-profile-analysis.ts index cffadbe4697d2..f2b9e33020a20 100644 --- a/src/components/profile-analysis/use-profile-analysis.ts +++ b/src/components/profile-analysis/use-profile-analysis.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useReducer, useRef } from 'react'; import { createAnalysisJob, - getProfileDag, getAnalysisJob, getAnalysisJobByClientRequestId, ProfileAnalysisApiError, @@ -16,19 +15,14 @@ import { import { createOrRecoverAnalysisJob, DEFAULT_ANALYSIS_POLL_INTERVAL_MS, - isRetryableTransportFailure, pollAnalysisJobWithRecovery, recoverAnalysisJobWithinGrace, - retryDelayMs, } from './profile-analysis.recovery'; import type { AgentMessage, AnalysisJobSnapshot, AnalysisJobStatus, AnalysisState, - DagStatus, - DagUiState, - ProfileDagResponse, ResponseLanguage, } from './profile-analysis.types'; @@ -42,9 +36,6 @@ interface ProfileAnalysisSnapshot { jobsAhead: number | null; result: AgentMessage | null; error: string | null; - dagState: DagUiState; - dag: ProfileDagResponse | null; - dagError: string | null; recoveryWarning: string | null; } @@ -58,10 +49,6 @@ type ProfileAnalysisAction = | { type: 'start' } | { type: 'job_created'; jobId: string; status: AnalysisJobStatus } | { type: 'job_status'; job: AnalysisJobSnapshot } - | { type: 'dag_status'; status: Extract } - | { type: 'dag_loading'; error?: string | null } - | { type: 'dag_loaded'; dag: ProfileDagResponse } - | { type: 'dag_failed'; state: Extract; error: string } | { type: 'complete'; result: AgentMessage } | { type: 'fail'; error: string }; @@ -73,42 +60,9 @@ export const initialProfileAnalysisSnapshot: ProfileAnalysisSnapshot = { jobsAhead: null, result: null, error: null, - dagState: 'idle', - dag: null, - dagError: null, recoveryWarning: null, }; -function dagErrorMessage(status: Extract, code: string | null): string { - if (code === 'DAG_TOO_LARGE') { - return 'This execution graph is too large to display.'; - } - if (status === 'UNAVAILABLE') { - return 'An execution graph is not available for this Profile.'; - } - return 'The execution graph could not be generated.'; -} - -function dagStateFromJob(snapshot: ProfileAnalysisSnapshot, job: AnalysisJobSnapshot): Pick< - ProfileAnalysisSnapshot, - 'dagState' | 'dagError' -> { - if (snapshot.dag) return { dagState: 'ready', dagError: null }; - // A client-side schema or rendering failure is terminal for this DAG. The - // backend continues to report READY on later job polls, but that must not - // turn the terminal error back into an endless local loading state. - if (snapshot.dagState === 'failed' || snapshot.dagState === 'unavailable') { - return { dagState: snapshot.dagState, dagError: snapshot.dagError }; - } - if (job.dagStatus === 'PENDING') return { dagState: 'pending', dagError: null }; - if (job.dagStatus === 'PARSING') return { dagState: 'parsing', dagError: null }; - if (job.dagStatus === 'READY') return { dagState: 'loading', dagError: null }; - if (job.dagStatus === 'UNAVAILABLE') { - return { dagState: 'unavailable', dagError: dagErrorMessage('UNAVAILABLE', job.dagError) }; - } - return { dagState: 'failed', dagError: dagErrorMessage('FAILED', job.dagError) }; -} - export function profileAnalysisReducer( snapshot: ProfileAnalysisSnapshot, action: ProfileAnalysisAction, @@ -126,9 +80,6 @@ export function profileAnalysisReducer( jobsAhead: null, result: null, error: null, - dagState: 'idle', - dag: null, - dagError: null, }; case 'recovering': return { @@ -140,7 +91,7 @@ export function profileAnalysisReducer( case 'storage_unavailable': return { ...snapshot, recoveryWarning: STORAGE_UNAVAILABLE_WARNING }; case 'select': - if (isSnapshotBusy(snapshot)) { + if (isBusy(snapshot.state)) { return snapshot; } return { @@ -149,15 +100,12 @@ export function profileAnalysisReducer( language: snapshot.language, result: null, error: null, - dagState: 'idle', - dag: null, - dagError: null, jobId: null, jobsAhead: null, recoveryWarning: snapshot.recoveryWarning, }; case 'set_language': - if (isSnapshotBusy(snapshot)) { + if (isBusy(snapshot.state)) { return snapshot; } return { @@ -168,12 +116,9 @@ export function profileAnalysisReducer( jobsAhead: null, result: null, error: null, - dagState: 'idle', - dag: null, - dagError: null, }; case 'start': - if (!snapshot.file || isSnapshotBusy(snapshot)) { + if (!snapshot.file || isBusy(snapshot.state)) { return snapshot; } return { @@ -183,9 +128,6 @@ export function profileAnalysisReducer( jobsAhead: null, result: null, error: null, - dagState: 'pending', - dag: null, - dagError: null, }; case 'job_created': return { @@ -195,16 +137,11 @@ export function profileAnalysisReducer( state: action.status === 'QUEUED' ? 'queued' : 'analyzing', jobId: action.jobId, jobsAhead: null, - dagState: 'pending', - dag: null, - dagError: null, }; case 'job_status': { - const dagSnapshot = dagStateFromJob(snapshot, action.job); if (action.job.status === 'QUEUED') { return { ...snapshot, - ...dagSnapshot, state: 'queued', jobId: action.job.jobId, jobsAhead: action.job.jobsAhead, @@ -213,7 +150,6 @@ export function profileAnalysisReducer( if (action.job.status === 'RUNNING') { return { ...snapshot, - ...dagSnapshot, state: 'analyzing', jobId: action.job.jobId, jobsAhead: null, @@ -222,7 +158,6 @@ export function profileAnalysisReducer( if (action.job.status === 'COMPLETED') { return { ...snapshot, - ...dagSnapshot, state: 'completed', jobId: action.job.jobId, jobsAhead: null, @@ -232,7 +167,6 @@ export function profileAnalysisReducer( } return { ...snapshot, - ...dagSnapshot, state: 'failed', jobId: action.job.jobId, jobsAhead: null, @@ -240,20 +174,6 @@ export function profileAnalysisReducer( error: action.job.error.message, }; } - case 'dag_status': - return snapshot.dag - ? snapshot - : { ...snapshot, dagState: action.status === 'PENDING' ? 'pending' : 'parsing', dagError: null }; - case 'dag_loading': - return snapshot.dag - ? snapshot - : { ...snapshot, dagState: 'loading', dagError: action.error ?? null }; - case 'dag_loaded': - return { ...snapshot, dagState: 'ready', dag: action.dag, dagError: null }; - case 'dag_failed': - return snapshot.dag - ? snapshot - : { ...snapshot, dagState: action.state, dag: null, dagError: action.error }; case 'complete': return { ...snapshot, @@ -269,12 +189,6 @@ export function profileAnalysisReducer( result: null, error: action.error, jobsAhead: null, - dagState: snapshot.dag ? 'ready' : snapshot.jobId ? 'failed' : 'idle', - dagError: snapshot.dag - ? null - : snapshot.jobId - ? 'The execution graph can no longer be recovered.' - : null, }; } } @@ -289,14 +203,6 @@ function isBusy(state: AnalysisState): boolean { ); } -function isDagBusy(state: DagUiState): boolean { - return state === 'pending' || state === 'parsing' || state === 'loading'; -} - -function isSnapshotBusy(snapshot: ProfileAnalysisSnapshot): boolean { - return isBusy(snapshot.state) || isDagBusy(snapshot.dagState); -} - function wait(milliseconds: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const handleAbort = () => { @@ -345,83 +251,22 @@ export function useProfileAnalysis(apiBaseUrl: string) { const pollJob = useCallback( async (jobId: string, pollIntervalMs: number, controller: AbortController): Promise => { - let codexSettled = false; - let dagSettled = false; - let dagFailureCount = 0; + let settled = false; await pollAnalysisJobWithRecovery({ get: () => getAnalysisJob(apiBaseUrl, jobId, controller.signal), wait: milliseconds => wait(milliseconds, controller.signal), onRecovering: () => { if (mountedRef.current && abortControllerRef.current === controller) { - if (codexSettled) { - dispatch({ - type: 'dag_loading', - error: 'Connection interrupted. Retrying the execution graph…', - }); - } else { - dispatch({ type: 'recovering' }); - } + dispatch({ type: 'recovering' }); } }, onProgress: () => {}, - onSnapshot: async job => { + onSnapshot: job => { if (!mountedRef.current || abortControllerRef.current !== controller) return; - codexSettled = job.status === 'COMPLETED' || job.status === 'FAILED'; + settled = job.status === 'COMPLETED' || job.status === 'FAILED'; dispatch({ type: 'job_status', job }); - - if (job.dagStatus === 'UNAVAILABLE' || job.dagStatus === 'FAILED') { - dagSettled = true; - return; - } - if (job.dagStatus !== 'READY' || dagSettled) return; - - dispatch({ type: 'dag_loading' }); - try { - const dagResult = await getProfileDag(apiBaseUrl, jobId, controller.signal); - dagFailureCount = 0; - if (!mountedRef.current || abortControllerRef.current !== controller) return; - if (dagResult.dagStatus === 'READY') { - dagSettled = true; - dispatch({ type: 'dag_loaded', dag: dagResult.dag }); - } else { - dispatch({ type: 'dag_status', status: dagResult.dagStatus }); - } - } catch (reason) { - if (isAbortError(reason)) throw reason; - if (reason instanceof ProfileAnalysisApiError && reason.status === 404) { - throw reason; - } - if (reason instanceof ProfileAnalysisApiError && reason.status === 409) { - dagSettled = true; - dispatch({ - type: 'dag_failed', - state: 'unavailable', - error: dagErrorMessage('UNAVAILABLE', reason.code), - }); - return; - } - if ( - reason instanceof ProfileAnalysisApiError && - reason.code !== 'INVALID_SERVER_RESPONSE' && - isRetryableTransportFailure(reason) - ) { - dagFailureCount += 1; - dispatch({ - type: 'dag_loading', - error: 'Connection interrupted. Retrying the execution graph…', - }); - await wait(retryDelayMs(dagFailureCount, pollIntervalMs), controller.signal); - return; - } - dagSettled = true; - dispatch({ - type: 'dag_failed', - state: 'failed', - error: 'The execution graph could not be loaded.', - }); - } }, - isComplete: () => codexSettled && dagSettled, + isComplete: () => settled, pollIntervalMs, }); }, @@ -612,7 +457,7 @@ export function useProfileAnalysis(apiBaseUrl: string) { return { ...snapshot, - isBusy: isSnapshotBusy(snapshot), + isBusy: isBusy(snapshot.state), selectFile, setLanguage, analyze, From 9d9069ff70fbbf0d825796e856b2817728b8287b Mon Sep 17 00:00:00 2001 From: morningman Date: Fri, 7 Aug 2026 23:39:57 +0800 Subject: [PATCH 4/5] feat: split profile analysis into visualize and AI tabs Co-Authored-By: Claude Opus 5 (1M context) --- .../profile-analysis/AiAnalysisForm.tsx | 161 ++++++++++++++ .../profile-analysis/ProfileAnalysis.scss | 93 ++------ .../profile-analysis/ProfileAnalyzer.tsx | 181 +++++++++------- .../profile-analysis/ProfileDag.tsx | 2 +- .../profile-analysis/ProfileUploader.tsx | 200 +----------------- .../profile-analysis.components.test.js | 136 +++++------- 6 files changed, 330 insertions(+), 443 deletions(-) create mode 100644 src/components/profile-analysis/AiAnalysisForm.tsx diff --git a/src/components/profile-analysis/AiAnalysisForm.tsx b/src/components/profile-analysis/AiAnalysisForm.tsx new file mode 100644 index 0000000000000..c47afb06d8bc5 --- /dev/null +++ b/src/components/profile-analysis/AiAnalysisForm.tsx @@ -0,0 +1,161 @@ +import HCaptcha from '@hcaptcha/react-hcaptcha'; +import React, { JSX, useCallback, useRef, useState } from 'react'; +import type { ResponseLanguage } from './profile-analysis.types'; + +interface AiAnalysisFormProps { + file: File | null; + language: ResponseLanguage; + disabled: boolean; + hcaptchaSiteKey: string; + onLanguageChange: (language: ResponseLanguage) => void; + onAnalyze: (hcaptchaToken: string, resetCaptcha: () => void) => void; +} + +export function AiAnalysisForm({ + file, + language, + disabled, + hcaptchaSiteKey, + onLanguageChange, + onAnalyze, +}: AiAnalysisFormProps): JSX.Element { + const [consentAccepted, setConsentAccepted] = useState(false); + const [hcaptchaToken, setHCaptchaToken] = useState(null); + const [hcaptchaError, setHCaptchaError] = useState(null); + const hcaptchaRef = useRef(null); + + const resetCaptcha = useCallback(() => { + hcaptchaRef.current?.resetCaptcha(); + setHCaptchaToken(null); + setHCaptchaError(null); + }, []); + + return ( +
+

+ The prepared Profile is uploaded only when you start this action. +

+ +
+ Response language + + +
+ +
+
+ +

Privacy and AI processing notice

+
+

+ This feature is provided by VeloDB and third-party large language model service providers. It + is not an official Apache Doris project feature, so please use it at your discretion. +

+

+ Do not upload passwords, keys, access tokens, personal information, customer-confidential + data, or any other sensitive content that you are not authorized to disclose. All uploaded + information will be automatically and permanently deleted within one hour. +

+ +
+ + {consentAccepted && ( +
+

+ Complete the human verification before starting the analysis. +

+ {hcaptchaSiteKey ? ( + { + setHCaptchaToken(token); + setHCaptchaError(null); + }} + onExpire={() => { + setHCaptchaToken(null); + setHCaptchaError('Verification expired. Complete it again.'); + }} + onChalExpired={() => { + setHCaptchaToken(null); + setHCaptchaError('Verification expired. Complete it again.'); + }} + onError={() => { + setHCaptchaToken(null); + setHCaptchaError( + 'Human verification could not load. Check your connection and try again.', + ); + }} + /> + ) : ( +
+ Human verification is not configured. Contact the site administrator. +
+ )} + + This site is protected by hCaptcha and its{' '} + + Privacy Policy + {' '} + and{' '} + + Terms of Service + {' '} + apply. + + {hcaptchaError && ( +
+ {hcaptchaError} +
+ )} +
+ )} + +
+ ); +} diff --git a/src/components/profile-analysis/ProfileAnalysis.scss b/src/components/profile-analysis/ProfileAnalysis.scss index 63fcaba73cd6d..575c75e3850bf 100644 --- a/src/components/profile-analysis/ProfileAnalysis.scss +++ b/src/components/profile-analysis/ProfileAnalysis.scss @@ -48,11 +48,6 @@ } } - &__workspace-title { - margin: 0 0 1rem; - font-size: 1.35rem; - } - &__tabs { display: flex; gap: 0.25rem; @@ -103,6 +98,7 @@ &__tab-panel > &__status, &__tab-panel > &__error, &__tab-panel > &__result { + margin-top: 1.25rem; margin-bottom: 0; } @@ -112,6 +108,15 @@ box-shadow: none; } + &__panel-intro { + margin-bottom: 1rem; + color: var(--ifm-color-emphasis-700); + } + + &__panel-intro + &__action-button { + margin-top: 0; + } + &__help { margin-bottom: 1rem; color: var(--ifm-color-emphasis-700); @@ -287,65 +292,9 @@ font-size: 0.9rem; } - &__actions { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 1rem; - margin-top: 1.25rem; - } - - &__action-card { + &__panel { min-width: 0; - padding: 1rem; - border: 1px solid var(--brand-border-soft); - border-radius: 10px; - background: var(--brand-surface-soft); - - h3 { - margin: 0 0 0.4rem; - font-size: 1.05rem; - } - - > p { - margin-bottom: 1rem; - color: var(--ifm-color-emphasis-700); - font-size: 0.9rem; - } - - &--local { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(220px, auto); - grid-template-rows: auto auto; - column-gap: 2rem; - align-items: center; - padding: 1.25rem; - border-left: 4px solid var(--brand-primary); - background: var(--brand-surface-callout); - - h3 { - grid-column: 1; - grid-row: 1; - } - - > p { - grid-column: 1; - grid-row: 2; - margin: 0; - } - - .profile-analysis__action-button { - grid-column: 2; - grid-row: 1 / span 2; - min-width: 220px; - margin: 0; - padding: 0.8rem 1.25rem; - box-shadow: 0 6px 16px rgb(var(--brand-shadow-rgb) / 14%); - } - } - - &--ai { - padding: 1.25rem; - } + margin-bottom: 1.25rem; } &__action-button { @@ -507,24 +456,6 @@ gap: 0.5rem; } - &__actions { - grid-template-columns: 1fr; - } - - &__action-card--local { - display: block; - - > p { - margin-bottom: 1rem; - } - - .profile-analysis__action-button { - width: 100%; - min-width: 0; - margin-top: 0.5rem; - } - } - &__file, &__warning, &__error { diff --git a/src/components/profile-analysis/ProfileAnalyzer.tsx b/src/components/profile-analysis/ProfileAnalyzer.tsx index 4d4f1ad0f3592..4acc867570102 100644 --- a/src/components/profile-analysis/ProfileAnalyzer.tsx +++ b/src/components/profile-analysis/ProfileAnalyzer.tsx @@ -1,5 +1,6 @@ -import React, { JSX, useCallback, useId, useState } from 'react'; +import React, { JSX, useCallback, useEffect, useId, useRef, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { AiAnalysisForm } from './AiAnalysisForm'; import { AnalysisResult } from './AnalysisResult'; import { AnalysisStatus } from './AnalysisStatus'; import { ProfileUploader } from './ProfileUploader'; @@ -9,6 +10,8 @@ import { useLocalProfileDag } from './use-local-profile-dag'; import type { ProfileParserWorker } from './profile-analysis.parser-client'; import './ProfileAnalysis.scss'; +type ProfileAnalysisTab = 'visualize' | 'ai'; + export function ProfileAnalyzer(): JSX.Element { const { siteConfig } = useDocusaurusContext(); const configuredApiBaseUrl = siteConfig.customFields?.profileAnalysisApiBaseUrl; @@ -26,7 +29,8 @@ export function ProfileAnalyzer(): JSX.Element { [], ); const localDag = useLocalProfileDag(createParserWorker); - const [activeResultTab, setActiveResultTab] = useState<'graph' | 'analysis'>('analysis'); + const [activeTab, setActiveTab] = useState('visualize'); + const tabChosenRef = useRef(false); const tabIdPrefix = useId(); const isAiBusy = analysis.isBusy; const busyState = @@ -37,10 +41,18 @@ export function ProfileAnalyzer(): JSX.Element { analysis.state === 'analyzing' ? analysis.state : null; - const hasAiActivity = - analysis.jobId !== null || busyState !== null || analysis.result !== null || analysis.error !== null; - const hasDagActivity = localDag.state !== 'idle'; - const hasWorkspace = hasAiActivity || hasDagActivity; + const hasAiActivity = analysis.jobId !== null || analysis.result !== null || analysis.error !== null; + + const selectTab = useCallback((tab: ProfileAnalysisTab) => { + tabChosenRef.current = true; + setActiveTab(tab); + }, []); + + // An analysis restored after a page refresh resumes on the AI tab so its progress stays visible. + useEffect(() => { + if (tabChosenRef.current || !hasAiActivity) return; + setActiveTab('ai'); + }, [hasAiActivity]); const handleFileChange = useCallback( (file: File | null) => { @@ -50,15 +62,13 @@ export function ProfileAnalyzer(): JSX.Element { [analysis.selectFile, localDag.reset], ); - const handleBuildGraph = useCallback(() => { + const handleVisualize = useCallback(() => { if (!analysis.file) return; - setActiveResultTab('graph'); void localDag.buildGraph(analysis.file); }, [analysis.file, localDag.buildGraph]); const handleAnalyze = useCallback( (hcaptchaToken: string, resetCaptcha: () => void) => { - setActiveResultTab('analysis'); void analysis.analyze(hcaptchaToken, resetCaptcha); }, [analysis.analyze], @@ -67,15 +77,15 @@ export function ProfileAnalyzer(): JSX.Element { const handleTabKeyDown = (event: React.KeyboardEvent) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); - const nextTab = + const nextTab: ProfileAnalysisTab = event.key === 'Home' - ? 'graph' + ? 'visualize' : event.key === 'End' - ? 'analysis' - : activeResultTab === 'graph' - ? 'analysis' - : 'graph'; - setActiveResultTab(nextTab); + ? 'ai' + : activeTab === 'visualize' + ? 'ai' + : 'visualize'; + selectTab(nextTab); window.requestAnimationFrame(() => { document.getElementById(`${tabIdPrefix}-${nextTab}-tab`)?.focus(); }); @@ -93,82 +103,91 @@ export function ProfileAnalyzer(): JSX.Element {

- + {analysis.recoveryWarning && (
{analysis.recoveryWarning}
)} - {hasWorkspace && ( -
-

- Analysis workspace -

-
- + +
+
+ + +
+
- )} + +
+ +
); } diff --git a/src/components/profile-analysis/ProfileDag.tsx b/src/components/profile-analysis/ProfileDag.tsx index c3a691719452e..f8a12be658747 100644 --- a/src/components/profile-analysis/ProfileDag.tsx +++ b/src/components/profile-analysis/ProfileDag.tsx @@ -38,7 +38,7 @@ const edgeTypes = { }; const stateMessages: Partial> = { - idle: 'Choose a Profile and select View execution graph.', + idle: 'Choose a Profile and select Visualize Execution.', parsing: 'Parsing the execution graph…', unavailable: 'An execution graph is not available for this Profile.', failed: 'The execution graph could not be generated.', diff --git a/src/components/profile-analysis/ProfileUploader.tsx b/src/components/profile-analysis/ProfileUploader.tsx index 792c114ada7a4..ebd56c0af209b 100644 --- a/src/components/profile-analysis/ProfileUploader.tsx +++ b/src/components/profile-analysis/ProfileUploader.tsx @@ -1,18 +1,10 @@ -import HCaptcha from '@hcaptcha/react-hcaptcha'; -import React, { ChangeEvent, DragEvent, JSX, useCallback, useRef, useState } from 'react'; +import React, { ChangeEvent, DragEvent, JSX, useRef, useState } from 'react'; import { MAX_RAW_BYTES, prepareProfileFile } from './profile-analysis.file'; -import type { ResponseLanguage } from './profile-analysis.types'; interface ProfileUploaderProps { file: File | null; - language: ResponseLanguage; - aiDisabled: boolean; - dagBusy: boolean; - hcaptchaSiteKey: string; + disabled: boolean; onFileChange: (file: File | null) => void; - onLanguageChange: (language: ResponseLanguage) => void; - onBuildGraph: () => void; - onAnalyze: (hcaptchaToken: string, resetCaptcha: () => void) => void; } export function validateProfileFile(file: File): string | null { @@ -35,30 +27,10 @@ export function formatProfileFileSize(sizeInBytes: number): string { return `${(sizeInBytes / (1024 * 1024)).toFixed(1)} MiB`; } -export function ProfileUploader({ - file, - language, - aiDisabled, - dagBusy, - hcaptchaSiteKey, - onFileChange, - onLanguageChange, - onBuildGraph, - onAnalyze, -}: ProfileUploaderProps): JSX.Element { +export function ProfileUploader({ file, disabled, onFileChange }: ProfileUploaderProps): JSX.Element { const [validationError, setValidationError] = useState(null); - const [consentAccepted, setConsentAccepted] = useState(false); - const [hcaptchaToken, setHCaptchaToken] = useState(null); - const [hcaptchaError, setHCaptchaError] = useState(null); - const hcaptchaRef = useRef(null); const filePreparationIdRef = useRef(0); - const resetCaptcha = useCallback(() => { - hcaptchaRef.current?.resetCaptcha(); - setHCaptchaToken(null); - setHCaptchaError(null); - }, []); - const acceptFile = async (nextFile: File | null) => { const preparationId = ++filePreparationIdRef.current; if (!nextFile) { @@ -98,7 +70,7 @@ export function ProfileUploader({ const handleDrop = (event: DragEvent) => { event.preventDefault(); - if (aiDisabled) { + if (disabled) { return; } if (event.dataTransfer.files.length > 1) { @@ -119,7 +91,7 @@ export function ProfileUploader({ @@ -151,166 +123,6 @@ export function ProfileUploader({ {formatProfileFileSize(file.size)}
)} - -
-
-

Local execution graph

-

Parsed locally in your browser. The file is not uploaded for this action.

- -
- -
-

AI-assisted analysis

-

The prepared Profile is uploaded only when you start this action.

-
- Response language - - -
- -
-
- -

Privacy and AI processing notice

-
-

- This feature is provided by VeloDB and third-party large language model service - providers. It is not an official Apache Doris project feature, so please use it at - your discretion. -

-

- Do not upload passwords, keys, access tokens, personal information, - customer-confidential data, or any other sensitive content that you are not - authorized to disclose. All uploaded information will be automatically and - permanently deleted within one hour. -

- -
- - {consentAccepted && ( -
-

- Complete the human verification before starting the analysis. -

- {hcaptchaSiteKey ? ( - { - setHCaptchaToken(token); - setHCaptchaError(null); - }} - onExpire={() => { - setHCaptchaToken(null); - setHCaptchaError('Verification expired. Complete it again.'); - }} - onChalExpired={() => { - setHCaptchaToken(null); - setHCaptchaError('Verification expired. Complete it again.'); - }} - onError={() => { - setHCaptchaToken(null); - setHCaptchaError( - 'Human verification could not load. Check your connection and try again.', - ); - }} - /> - ) : ( -
- Human verification is not configured. Contact the site administrator. -
- )} - - This site is protected by hCaptcha and its{' '} - - Privacy Policy - {' '} - and{' '} - - Terms of Service - {' '} - apply. - - {hcaptchaError && ( -
- {hcaptchaError} -
- )} -
- )} - -
-
); } diff --git a/src/components/profile-analysis/profile-analysis.components.test.js b/src/components/profile-analysis/profile-analysis.components.test.js index dc9cec0f20629..7a5db2d954542 100644 --- a/src/components/profile-analysis/profile-analysis.components.test.js +++ b/src/components/profile-analysis/profile-analysis.components.test.js @@ -24,6 +24,7 @@ const compileTypeScript = (module, filename) => { require.extensions['.ts'] = compileTypeScript; require.extensions['.tsx'] = compileTypeScript; +const { AiAnalysisForm } = require('./AiAnalysisForm.tsx'); const { AnalysisResult } = require('./AnalysisResult.tsx'); const { AnalysisStatus } = require('./AnalysisStatus.tsx'); const { @@ -53,77 +54,61 @@ test('formats file sizes for display', () => { assert.equal(formatProfileFileSize(2 * 1024 * 1024), '2.0 MiB'); }); -test('explains the raw file limit and large-profile reduction in English', () => { - const markup = renderToStaticMarkup( +const renderUploader = (props = {}) => + renderToStaticMarkup( React.createElement(ProfileUploader, { file: null, - language: 'en', - aiDisabled: false, - dagBusy: false, - hcaptchaSiteKey, + disabled: false, onFileChange() {}, - onLanguageChange() {}, - onBuildGraph() {}, - onAnalyze() {}, + ...props, }), ); - assert.match(markup, /UTF-8 \.txt file up to 100 MiB/); - assert.match(markup, /Files over 10 MiB are reduced to their aggregated Profile sections/); -}); - -test('disables both actions without a file and keeps local graph available during AI processing', () => { - const withoutFile = renderToStaticMarkup( - React.createElement(ProfileUploader, { +const renderAiForm = (props = {}) => + renderToStaticMarkup( + React.createElement(AiAnalysisForm, { file: null, language: 'en', - aiDisabled: false, - dagBusy: false, + disabled: false, hcaptchaSiteKey, - onFileChange() {}, onLanguageChange() {}, - onBuildGraph() {}, onAnalyze() {}, + ...props, }), ); - assert.match(withoutFile, /]*disabled=""[^>]*>View execution graph<\/button>/); - assert.match(withoutFile, /]*disabled=""[^>]*>Analyze with AI<\/button>/); - const analyzing = renderToStaticMarkup( - React.createElement(ProfileUploader, { - file: new File(['profile'], 'query.txt'), - language: 'zh-CN', - aiDisabled: true, - dagBusy: false, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - onBuildGraph() {}, - onAnalyze() {}, - }), - ); +test('explains the raw file limit and large-profile reduction in English', () => { + const markup = renderUploader(); + + assert.match(markup, /UTF-8 \.txt file up to 100 MiB/); + assert.match(markup, /Files over 10 MiB are reduced to their aggregated Profile sections/); +}); + +test('disables both tab actions without a file and keeps visualization available during AI processing', () => { + const analyzerSource = fs.readFileSync(path.join(__dirname, 'ProfileAnalyzer.tsx'), 'utf8'); + + assert.match(analyzerSource, /disabled=\{!analysis\.file \|\| localDag\.isBusy\}/); + assert.match(analyzerSource, /localDag\.isBusy \? 'Visualizing…' : 'Visualize Execution'/); + // The AI job never disables the local visualization button. + assert.doesNotMatch(analyzerSource, /disabled=\{[^}]*isAiBusy[^}]*\}\s*\n\s*onClick=\{handleVisualize\}/); + + assert.match(renderAiForm(), /]*disabled=""[^>]*>Analyze with AI<\/button>/); + + const analyzing = renderAiForm({ + file: new File(['profile'], 'query.txt'), + language: 'zh-CN', + disabled: true, + }); assert.match(analyzing, /]*disabled=""/); assert.match(analyzing, /]*disabled=""[^>]*>Processing…<\/button>/); - assert.doesNotMatch(analyzing, /]*disabled=""[^>]*>View execution graph<\/button>/); }); test('keeps local file selection independent from unchecked AI consent and displays the required notice', () => { - const markup = renderToStaticMarkup( - React.createElement(ProfileUploader, { - file: null, - language: 'en', - aiDisabled: false, - dagBusy: false, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - onBuildGraph() {}, - onAnalyze() {}, - }), - ); + const markup = renderAiForm(); assert.match(markup, /type="checkbox"/); assert.doesNotMatch(markup, /type="checkbox"[^>]*checked/); + assert.match(markup, /The prepared Profile is uploaded only when you start this action\./); assert.match(markup, /provided by VeloDB and third-party large language model service providers/); assert.match(markup, /not an official Apache Doris project feature/); assert.match(markup, /Do not upload passwords, keys, access tokens, personal information/); @@ -131,51 +116,26 @@ test('keeps local file selection independent from unchecked AI consent and displ assert.ok(markup.indexOf('Privacy and AI processing notice') < markup.indexOf('Analyze with AI')); assert.match(markup, /role="note"/); assert.match(markup, /profile-analysis__privacy-notice-icon" aria-hidden="true">!]*disabled=""/); - assert.match(markup, /Parsed locally in your browser\. The file is not uploaded for this action\./); - assert.match(markup, /profile-analysis__action-card--local/); - assert.match(markup, /button button--primary profile-analysis__action-button[^>]*>View execution graph/); + assert.doesNotMatch(renderUploader(), /type="file"[^>]*disabled=""/); + + const analyzerSource = fs.readFileSync(path.join(__dirname, 'ProfileAnalyzer.tsx'), 'utf8'); + assert.match(analyzerSource.replace(/\s+/g, ' '), /The file is not uploaded for this action\./); + assert.match(analyzerSource, /button button--primary profile-analysis__action-button/); const styles = fs.readFileSync(path.join(__dirname, 'ProfileAnalysis.scss'), 'utf8'); - assert.match(styles, /&__actions\s*{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s); - assert.match(styles, /&--local\s*{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) minmax\(220px, auto\)/s); + assert.match(styles, /&__panel-intro\s*{[^}]*color:\s*var\(--ifm-color-emphasis-700\)/s); + assert.match(styles, /&__action-button\s*{[^}]*width:\s*100%/s); }); test('uses an English accessible label instead of exposing localized native file-input text', () => { - const markup = renderToStaticMarkup( - React.createElement(ProfileUploader, { - file: null, - language: 'en', - aiDisabled: false, - dagBusy: false, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - onBuildGraph() {}, - onAnalyze() {}, - }), - ); - - assert.match(markup, /aria-label="Choose an Apache Doris Query Profile file"/); + assert.match(renderUploader(), /aria-label="Choose an Apache Doris Query Profile file"/); const styles = fs.readFileSync(path.join(__dirname, 'ProfileAnalysis.scss'), 'utf8'); assert.match(styles, /&__file-input\s*{[^}]*clip-path:\s*inset\(50%\)/s); }); test('renders an English response-language selector with English selected by default', () => { - const markup = renderToStaticMarkup( - React.createElement(ProfileUploader, { - file: null, - language: 'en', - aiDisabled: false, - dagBusy: false, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - onBuildGraph() {}, - onAnalyze() {}, - }), - ); + const markup = renderAiForm(); assert.match(markup, /Response language<\/legend>/); assert.match(markup, /]*checked=""[^>]*value="en"/); @@ -257,15 +217,19 @@ test('the page composes the analyzer inside the Doris Layout without adding navi assert.match(pageSource, /
/); }); -test('adds English result tabs and configures the execution graph as read-only', () => { +test('adds English action tabs and configures the execution graph as read-only', () => { const analyzerSource = fs.readFileSync(path.join(__dirname, 'ProfileAnalyzer.tsx'), 'utf8'); const dagSource = fs.readFileSync(path.join(__dirname, 'ProfileDag.tsx'), 'utf8'); const dagNodeSource = fs.readFileSync(path.join(__dirname, 'ProfileDagNode.tsx'), 'utf8'); - assert.match(analyzerSource, />\s*Execution graph\s*\s*AI analysis\s*\s*Visualize Execution\s*\s*AI-assisted analysis\s* Date: Fri, 7 Aug 2026 23:39:57 +0800 Subject: [PATCH 5/5] feat: rank the slowest operators on the execution graph Co-Authored-By: Claude Opus 5 (1M context) --- .../profile-analysis/ProfileDag.scss | 124 ++++++++++++++++++ .../profile-analysis/ProfileDag.tsx | 92 ++++++++++++- .../profile-analysis.components.test.js | 22 ++++ .../profile-analysis.dag.test.js | 60 +++++++++ .../profile-analysis/profile-analysis.dag.ts | 41 ++++++ 5 files changed, 336 insertions(+), 3 deletions(-) diff --git a/src/components/profile-analysis/ProfileDag.scss b/src/components/profile-analysis/ProfileDag.scss index 4b210ec413aea..540d7a16235f4 100644 --- a/src/components/profile-analysis/ProfileDag.scss +++ b/src/components/profile-analysis/ProfileDag.scss @@ -132,6 +132,126 @@ background: var(--brand-paper); fill: var(--ifm-font-color-base); } + + .react-flow__node.selected .profile-dag-node { + outline: 3px solid var(--brand-primary); + outline-offset: 2px; + } +} + +.profile-dag-hotspots { + max-width: min(310px, 48%); + padding: 0.55rem 0.65rem; + border: 1px solid var(--brand-border-soft); + border-radius: 10px; + color: var(--ifm-font-color-base); + background: var(--brand-paper); + box-shadow: 0 6px 18px rgb(var(--brand-shadow-rgb) / 14%); + font-size: 0.8rem; + + &__header { + display: flex; + gap: 0.75rem; + align-items: flex-start; + justify-content: space-between; + + h4 { + margin: 0; + font-size: 0.85rem; + } + } + + &__hint { + margin: 0.1rem 0 0; + color: var(--ifm-color-emphasis-600); + font-size: 0.68rem; + } + + &__toggle { + flex: 0 0 auto; + padding: 0 0.15rem; + border: 0; + color: var(--brand-primary); + background: transparent; + cursor: pointer; + font: inherit; + font-size: 0.72rem; + font-weight: 700; + } + + &__list { + display: flex; + flex-direction: column; + gap: 0.15rem; + margin: 0.45rem 0 0; + padding: 0; + list-style: none; + } + + &__item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; + width: 100%; + padding: 0.3rem 0.35rem; + border: 1px solid transparent; + border-radius: 7px; + color: inherit; + background: transparent; + cursor: pointer; + font: inherit; + text-align: left; + + &:hover, + &:focus-visible { + border-color: var(--brand-primary); + background: var(--brand-surface-callout); + } + } + + &__rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + border-radius: 50%; + color: var(--ifm-color-emphasis-700); + background: var(--brand-surface-soft); + font-size: 0.65rem; + font-weight: 800; + } + + &__body { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__name, + &__meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__name { + font-size: 0.78rem; + font-weight: 700; + } + + &__meta { + color: var(--ifm-color-emphasis-600); + font-size: 0.66rem; + } + + &__time { + flex: 0 0 auto; + font-size: 0.74rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + } } .profile-dag-fragment { @@ -336,6 +456,10 @@ } } + .profile-dag-hotspots { + max-width: min(220px, 62%); + } + .profile-dag-details { position: absolute; right: 0; diff --git a/src/components/profile-analysis/ProfileDag.tsx b/src/components/profile-analysis/ProfileDag.tsx index f8a12be658747..704713ea3d1bc 100644 --- a/src/components/profile-analysis/ProfileDag.tsx +++ b/src/components/profile-analysis/ProfileDag.tsx @@ -1,9 +1,10 @@ -import React, { JSX, useEffect, useMemo, useRef, useState } from 'react'; +import React, { JSX, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import BrowserOnly from '@docusaurus/BrowserOnly'; import { Background, Controls, MiniMap, + Panel, ReactFlow, ReactFlowProvider, useReactFlow, @@ -16,7 +17,11 @@ import { formatCount, formatDurationNs, layoutProfileDag, + selectSlowestOperators, + OPERATOR_NODE_HEIGHT, + OPERATOR_NODE_WIDTH, type ProfileFlowNode, + type ProfileHotspot, } from './profile-analysis.dag'; import { ProfileDagFragmentNode, ProfileDagNode } from './ProfileDagNode'; import { ProfileDagEdge } from './ProfileDagEdge'; @@ -37,6 +42,9 @@ const edgeTypes = { profileElk: ProfileDagEdge, }; +/** Zoom applied when a hotspot entry centers its operator on the canvas. */ +const FOCUS_ZOOM = 1; + const stateMessages: Partial> = { idle: 'Choose a Profile and select Visualize Execution.', parsing: 'Parsing the execution graph…', @@ -139,6 +147,64 @@ function NodeDetails({ node, onClose }: { node: ProfileDagNodeData; onClose: () ); } +function HotspotList({ + hotspots, + onFocus, +}: { + hotspots: ProfileHotspot[]; + onFocus: (nodeId: string) => void; +}): JSX.Element { + const [expanded, setExpanded] = useState(true); + const titleId = useId(); + + return ( + +
+
+

Slowest operators

+

Ranked by exec max

+
+ +
+ {expanded && ( +
    + {hotspots.map((hotspot, index) => ( +
  1. + +
  2. + ))} +
+ )} +
+ ); +} + function ProfileDagCanvas({ dag }: { dag: ProfileDagResponse }): JSX.Element { const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState>['edges']>([]); @@ -146,7 +212,8 @@ function ProfileDagCanvas({ dag }: { dag: ProfileDagResponse }): JSX.Element { const [layoutError, setLayoutError] = useState(null); const canvasRef = useRef(null); const hasFitVisibleCanvasRef = useRef(false); - const { fitView } = useReactFlow(); + const { fitView, getInternalNode, setCenter } = useReactFlow(); + const hotspots = useMemo(() => selectSlowestOperators(dag), [dag]); useEffect(() => { let cancelled = false; @@ -187,13 +254,31 @@ function ProfileDagCanvas({ dag }: { dag: ProfileDagResponse }): JSX.Element { return () => observer.disconnect(); }, [fitView, nodes.length]); + const highlightNode = useCallback((nodeId: string) => { + setNodes(current => current.map(node => ({ ...node, selected: node.id === nodeId }))); + }, []); + + const focusNode = useCallback( + (nodeId: string) => { + const internalNode = getInternalNode(nodeId); + if (!internalNode) return; + const { x, y } = internalNode.internals.positionAbsolute; + const width = internalNode.measured.width ?? OPERATOR_NODE_WIDTH; + const height = internalNode.measured.height ?? OPERATOR_NODE_HEIGHT; + highlightNode(nodeId); + void setCenter(x + width / 2, y + height / 2, { zoom: FOCUS_ZOOM, duration: 500 }); + }, + [getInternalNode, highlightNode, setCenter], + ); + const onNodeClick = useMemo>( () => (_event, flowNode) => { if (flowNode.data.kind === 'operator') { setSelectedNode(flowNode.data.node); + highlightNode(flowNode.id); } }, - [], + [highlightNode], ); if (layoutError) { @@ -225,6 +310,7 @@ function ProfileDagCanvas({ dag }: { dag: ProfileDagResponse }): JSX.Element { maxZoom={1.8} > + {hotspots.length > 0 && } diff --git a/src/components/profile-analysis/profile-analysis.components.test.js b/src/components/profile-analysis/profile-analysis.components.test.js index 7a5db2d954542..0595855042105 100644 --- a/src/components/profile-analysis/profile-analysis.components.test.js +++ b/src/components/profile-analysis/profile-analysis.components.test.js @@ -240,3 +240,25 @@ test('adds English action tabs and configures the execution graph as read-only', assert.match(dagSource, /zoomOnScroll/); assert.doesNotMatch(`${analyzerSource}\n${dagSource}\n${dagNodeSource}`, /[\u3400-\u9fff]/); }); + +test('overlays the slowest operators on the canvas and centers the one that is clicked', () => { + const dagSource = fs.readFileSync(path.join(__dirname, 'ProfileDag.tsx'), 'utf8'); + + assert.match(dagSource, /Slowest operators 0 && /); + assert.match(dagSource, /onClick=\{\(\) => onFocus\(hotspot\.id\)\}/); + // Each entry carries the node id, its name, its location, and the measured duration. + assert.match(dagSource, /title=\{hotspot\.id\}/); + assert.match(dagSource, /\{hotspot\.label\}/); + assert.match(dagSource, /id=\$\{hotspot\.planNodeId\}/); + assert.match(dagSource, /formatDurationNs\(hotspot\.execMaxNs\)/); + // Focusing centers the operator and highlights it instead of only scrolling near it. + assert.match(dagSource, /internalNode\.internals\.positionAbsolute/); + assert.match(dagSource, /setCenter\(x \+ width \/ 2, y \+ height \/ 2, \{ zoom: FOCUS_ZOOM, duration: 500 \}\)/); + assert.match(dagSource, /selected: node\.id === nodeId/); + + const styles = fs.readFileSync(path.join(__dirname, 'ProfileDag.scss'), 'utf8'); + assert.match(styles, /\.react-flow__node\.selected \.profile-dag-node\s*{[^}]*outline:\s*3px solid var\(--brand-primary\)/s); + assert.match(styles, /\.profile-dag-hotspots\s*{[^}]*max-width:\s*min\(310px, 48%\)/s); +}); diff --git a/src/components/profile-analysis/profile-analysis.dag.test.js b/src/components/profile-analysis/profile-analysis.dag.test.js index 752040b2fecd3..730db0359992d 100644 --- a/src/components/profile-analysis/profile-analysis.dag.test.js +++ b/src/components/profile-analysis/profile-analysis.dag.test.js @@ -25,6 +25,7 @@ const { formatDurationNs, isDependencyEdge, layoutProfileDag, + selectSlowestOperators, OPERATOR_NODE_HEIGHT, OPERATOR_NODE_WIDTH, } = dagModule.exports; @@ -131,6 +132,65 @@ function fixture() { }; } +function timedFixture(entries) { + const dag = fixture(); + const template = dag.graph.nodes[0]; + dag.graph.nodes = entries.map(([id, maxNs, overrides = {}]) => { + const segments = id.split('/'); + return { + ...template, + id, + fragmentId: segments[0], + pipelineId: segments.slice(0, 2).join('/'), + timing: maxNs === null ? {} : { execTime: { maxNs } }, + ...overrides, + }; + }); + return dag; +} + +test('ranks the slowest operators by exec max and keeps at most five entries', () => { + const hotspots = selectSlowestOperators( + timedFixture([ + ['fragment:0/pipeline:0/operator:0', 5_000_000], + ['fragment:1/pipeline:2/operator:0', 900_000_000, { label: 'HASH JOIN', planNodeId: 10 }], + ['fragment:2/pipeline:0/operator:0', 300_000_000], + ['fragment:3/pipeline:1/operator:0', 80_000_000], + ['fragment:4/pipeline:0/operator:0', 40_000_000], + ['fragment:5/pipeline:0/operator:0', 20_000_000], + ]), + ); + + assert.deepEqual( + hotspots.map(hotspot => hotspot.execMaxNs), + [900_000_000, 300_000_000, 80_000_000, 40_000_000, 20_000_000], + ); + assert.equal(hotspots[0].id, 'fragment:1/pipeline:2/operator:0'); + assert.equal(hotspots[0].label, 'HASH JOIN'); + assert.equal(hotspots[0].location, 'Fragment 1 · Pipeline 2'); + assert.equal(hotspots[0].planNodeId, 10); + assert.equal(formatDurationNs(hotspots[0].execMaxNs), '900 ms'); +}); + +test('lists only operators the Profile timed and orders ties predictably', () => { + const hotspots = selectSlowestOperators( + timedFixture([ + ['fragment:1/pipeline:0/operator:0', null], + ['fragment:0/pipeline:0/operator:0', 0], + ['fragment:2/pipeline:0/operator:0', 12_000, { planNodeId: null }], + ['fragment:3/pipeline:0/operator:0', 12_000], + ]), + ); + + assert.deepEqual( + hotspots.map(hotspot => hotspot.id), + ['fragment:2/pipeline:0/operator:0', 'fragment:3/pipeline:0/operator:0'], + ); + assert.equal(hotspots[0].planNodeId, null); + assert.deepEqual(selectSlowestOperators(timedFixture([['fragment:0/pipeline:0/operator:0', null]])), []); + assert.equal(selectSlowestOperators(timedFixture([['fragment:0/pipeline:0/operator:0', 5]]), 0).length, 0); +}); + test('builds one ELK compound parent per Fragment and keeps all cross-Fragment edges', () => { const graph = buildElkGraph(fixture()); diff --git a/src/components/profile-analysis/profile-analysis.dag.ts b/src/components/profile-analysis/profile-analysis.dag.ts index ebdfa232a4e47..41ae2caa88a85 100644 --- a/src/components/profile-analysis/profile-analysis.dag.ts +++ b/src/components/profile-analysis/profile-analysis.dag.ts @@ -166,6 +166,47 @@ function pipelineNumber(pipelineId: string): string { return match?.[1] ?? pipelineId; } +export function fragmentLabel(fragmentId: string): string { + const match = /^fragment:(\d+)$/.exec(fragmentId); + return match ? `Fragment ${match[1]}` : fragmentId; +} + +export const DEFAULT_HOTSPOT_LIMIT = 5; + +export interface ProfileHotspot { + /** Graph node id, used to focus the operator on the canvas. */ + id: string; + label: string; + location: string; + planNodeId: number | null; + execMaxNs: number; +} + +/** + * Ranks operators by maximum execution time, the same metric that drives the node + * heat colors and the summary bottleneck, so the list and the graph always agree. + */ +export function selectSlowestOperators( + dag: ProfileDagResponse, + limit: number = DEFAULT_HOTSPOT_LIMIT, +): ProfileHotspot[] { + const ranked: ProfileHotspot[] = []; + for (const node of dag.graph.nodes) { + const execMaxNs = node.timing?.execTime?.maxNs; + if (typeof execMaxNs !== 'number' || !Number.isFinite(execMaxNs) || execMaxNs <= 0) continue; + ranked.push({ + id: node.id, + label: node.label, + location: `${fragmentLabel(node.fragmentId)} · Pipeline ${pipelineNumber(node.pipelineId)}`, + planNodeId: node.planNodeId ?? null, + execMaxNs, + }); + } + // Slowest first; the node id keeps ties in a stable order across renders. + ranked.sort((left, right) => right.execMaxNs - left.execMaxNs || left.id.localeCompare(right.id)); + return ranked.slice(0, Math.max(0, limit)); +} + export function buildElkGraph(dag: ProfileDagResponse): ElkGraph { const nodesByFragment = new Map(); for (const node of dag.graph.nodes) {