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/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 66604e2a1fc0a..575c75e3850bf 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,75 @@ } } + &__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-top: 1.25rem; + margin-bottom: 0; + } + + &__tab-panel > &__result { + padding: 0; + border: 0; + 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); @@ -222,6 +292,16 @@ font-size: 0.9rem; } + &__panel { + min-width: 0; + margin-bottom: 1.25rem; + } + + &__action-button { + width: 100%; + margin-top: 1rem; + } + &__captcha { max-width: 100%; margin-top: 1.25rem; @@ -354,10 +434,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..4acc867570102 100644 --- a/src/components/profile-analysis/ProfileAnalyzer.tsx +++ b/src/components/profile-analysis/ProfileAnalyzer.tsx @@ -1,11 +1,17 @@ -import React, { JSX } 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'; +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'; +type ProfileAnalysisTab = 'visualize' | 'ai'; + export function ProfileAnalyzer(): JSX.Element { const { siteConfig } = useDocusaurusContext(); const configuredApiBaseUrl = siteConfig.customFields?.profileAnalysisApiBaseUrl; @@ -14,7 +20,19 @@ export function ProfileAnalyzer(): JSX.Element { const hcaptchaSiteKey = typeof configuredHCaptchaSiteKey === 'string' ? configuredHCaptchaSiteKey : ''; const analysis = useProfileAnalysis(apiBaseUrl); - const isBusy = analysis.isBusy; + 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 [activeTab, setActiveTab] = useState('visualize'); + const tabChosenRef = useRef(false); + const tabIdPrefix = useId(); + const isAiBusy = analysis.isBusy; const busyState = analysis.state === 'restoring' || analysis.state === 'recovering' || @@ -23,6 +41,55 @@ export function ProfileAnalyzer(): JSX.Element { analysis.state === 'analyzing' ? analysis.state : null; + 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) => { + localDag.reset(); + analysis.selectFile(file); + }, + [analysis.selectFile, localDag.reset], + ); + + const handleVisualize = useCallback(() => { + if (!analysis.file) return; + void localDag.buildGraph(analysis.file); + }, [analysis.file, localDag.buildGraph]); + + const handleAnalyze = useCallback( + (hcaptchaToken: string, resetCaptcha: () => void) => { + void analysis.analyze(hcaptchaToken, resetCaptcha); + }, + [analysis.analyze], + ); + + const handleTabKeyDown = (event: React.KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const nextTab: ProfileAnalysisTab = + event.key === 'Home' + ? 'visualize' + : event.key === 'End' + ? 'ai' + : activeTab === 'visualize' + ? 'ai' + : 'visualize'; + selectTab(nextTab); + window.requestAnimationFrame(() => { + document.getElementById(`${tabIdPrefix}-${nextTab}-tab`)?.focus(); + }); + }; return (
@@ -30,35 +97,97 @@ 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.

- + - {busyState && } - {analysis.state === 'completed' && } {analysis.recoveryWarning && (
{analysis.recoveryWarning}
)} - {analysis.error && ( -
- Analysis failed. - {analysis.error} + +
+
+ +
- )} - {analysis.result && } + + +
); } diff --git a/src/components/profile-analysis/ProfileDag.scss b/src/components/profile-analysis/ProfileDag.scss new file mode 100644 index 0000000000000..540d7a16235f4 --- /dev/null +++ b/src/components/profile-analysis/ProfileDag.scss @@ -0,0 +1,474 @@ +.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 { + position: relative; + display: inline-block; + width: 1.5rem; + 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 { + 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); + } + + .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 { + 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-hotspots { + max-width: min(220px, 62%); + } + + .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..704713ea3d1bc --- /dev/null +++ b/src/components/profile-analysis/ProfileDag.tsx @@ -0,0 +1,351 @@ +import React, { JSX, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import { + Background, + Controls, + MiniMap, + Panel, + 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, + selectSlowestOperators, + OPERATOR_NODE_HEIGHT, + OPERATOR_NODE_WIDTH, + type ProfileFlowNode, + type ProfileHotspot, +} from './profile-analysis.dag'; +import { ProfileDagFragmentNode, ProfileDagNode } from './ProfileDagNode'; +import { ProfileDagEdge } from './ProfileDagEdge'; +import './ProfileDag.scss'; + +export interface ProfileDagProps { + state: DagUiState; + dag: ProfileDagResponse | null; + error?: string | null; +} + +const nodeTypes = { + profileOperator: ProfileDagNode, + profileFragment: ProfileDagFragmentNode, +}; + +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…', + 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 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']>([]); + const [selectedNode, setSelectedNode] = useState(null); + const [layoutError, setLayoutError] = useState(null); + const canvasRef = useRef(null); + const hasFitVisibleCanvasRef = useRef(false); + const { fitView, getInternalNode, setCenter } = useReactFlow(); + const hotspots = useMemo(() => selectSlowestOperators(dag), [dag]); + + 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 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) { + return
{layoutError}
; + } + if (nodes.length === 0) { + return
; + } + + return ( +
+
+ + + {hotspots.length > 0 && } + + + +
+ {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 (prerequisite → dependent) + Longer execution + Longer wait +
+ Loading the execution graph…
}> + {() => } + + + ); +} 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/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/ProfileUploader.tsx b/src/components/profile-analysis/ProfileUploader.tsx index 9b3bb5fefb447..ebd56c0af209b 100644 --- a/src/components/profile-analysis/ProfileUploader.tsx +++ b/src/components/profile-analysis/ProfileUploader.tsx @@ -1,16 +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; disabled: boolean; - hcaptchaSiteKey: string; onFileChange: (file: File | null) => void; - onLanguageChange: (language: ResponseLanguage) => void; - onAnalyze: (hcaptchaToken: string, resetCaptcha: () => void) => void; } export function validateProfileFile(file: File): string | null { @@ -33,28 +27,10 @@ export function formatProfileFileSize(sizeInBytes: number): string { return `${(sizeInBytes / (1024 * 1024)).toFixed(1)} MiB`; } -export function ProfileUploader({ - file, - language, - disabled, - hcaptchaSiteKey, - onFileChange, - onLanguageChange, - 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) { @@ -94,7 +70,7 @@ export function ProfileUploader({ const handleDrop = (event: DragEvent) => { event.preventDefault(); - if (disabled || !consentAccepted) { + if (disabled) { return; } if (event.dataTransfer.files.length > 1) { @@ -107,39 +83,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 - - -
- @@ -171,120 +123,6 @@ export function ProfileUploader({ {formatProfileFileSize(file.size)}
)} - - {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 6ee818a1d9d76..a01dbf5c94f9a 100644 --- a/src/components/profile-analysis/profile-analysis.api.test.js +++ b/src/components/profile-analysis/profile-analysis.api.test.js @@ -100,18 +100,25 @@ 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, + }); + assert.deepEqual(await getAnalysisJob('', jobId), { + jobId, + status: 'RUNNING', + }); assert.equal((await getAnalysisJob('', jobId)).status, 'COMPLETED'); assert.equal((await getAnalysisJob('', jobId)).status, 'FAILED'); }); @@ -263,6 +270,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 => { diff --git a/src/components/profile-analysis/profile-analysis.api.ts b/src/components/profile-analysis/profile-analysis.api.ts index cbc3619f5916b..ccaf0eaf27948 100644 --- a/src/components/profile-analysis/profile-analysis.api.ts +++ b/src/components/profile-analysis/profile-analysis.api.ts @@ -75,7 +75,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 +94,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 +116,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 +127,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(); } @@ -229,7 +235,6 @@ export async function getAnalysisJob( if (!isRecord(body) || body.jobId !== jobId) { throw invalidResponse(); } - switch (body.status) { case 'QUEUED': if (!Number.isInteger(body.jobsAhead) || (body.jobsAhead as number) < 0) throw invalidResponse(); diff --git a/src/components/profile-analysis/profile-analysis.components.test.js b/src/components/profile-analysis/profile-analysis.components.test.js index 02b43f5cf34bb..0595855042105 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,108 +54,88 @@ 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', disabled: false, - hcaptchaSiteKey, onFileChange() {}, - onLanguageChange() {}, - 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 Analyze until a file exists and while analysis is running', () => { - const withoutFile = renderToStaticMarkup( - React.createElement(ProfileUploader, { +const renderAiForm = (props = {}) => + renderToStaticMarkup( + React.createElement(AiAnalysisForm, { file: null, language: 'en', disabled: false, hcaptchaSiteKey, - onFileChange() {}, onLanguageChange() {}, onAnalyze() {}, + ...props, }), ); - assert.match(withoutFile, /]*disabled=""[^>]*>Analyze Profile<\/button>/); - const analyzing = renderToStaticMarkup( - React.createElement(ProfileUploader, { - file: new File(['profile'], 'query.txt'), - language: 'zh-CN', - disabled: true, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - 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>/); }); -test('places an unchecked privacy consent after Analyze and displays provider, prohibited-content, and deletion notices', () => { - const markup = renderToStaticMarkup( - React.createElement(ProfileUploader, { - file: null, - language: 'en', - disabled: false, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - onAnalyze() {}, - }), - ); +test('keeps local file selection independent from unchecked AI consent and displays the required notice', () => { + 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/); 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(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, /&__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', - disabled: false, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - 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', - disabled: false, - hcaptchaSiteKey, - onFileChange() {}, - onLanguageChange() {}, - onAnalyze() {}, - }), - ); + const markup = renderAiForm(); assert.match(markup, /Response language<\/legend>/); assert.match(markup, /]*checked=""[^>]*value="en"/); @@ -235,3 +216,49 @@ test('the page composes the analyzer inside the Doris Layout without adding navi assert.match(pageSource, //); assert.match(pageSource, /
/); }); + +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*Visualize Execution\s*\s*AI-assisted analysis\s* { + 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 new file mode 100644 index 0000000000000..730db0359992d --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.dag.test.js @@ -0,0 +1,325 @@ +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 dagPath = path.join(__dirname, 'profile-analysis.dag.ts'); +const output = typescript.transpileModule(fs.readFileSync(dagPath, 'utf8'), { + compilerOptions: { + esModuleInterop: true, + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2020, + }, +}).outputText; +const dagModule = new Module(dagPath, module); +dagModule.filename = dagPath; +dagModule.paths = Module._nodeModulePaths(path.dirname(dagPath)); +dagModule._compile(output, dagPath); + +const { + buildElkGraph, + formatBytes, + formatCount, + formatDurationNs, + isDependencyEdge, + layoutProfileDag, + selectSlowestOperators, + OPERATOR_NODE_HEIGHT, + OPERATOR_NODE_WIDTH, +} = dagModule.exports; + +function fixture() { + const operator = (id, fragmentId, pipelineId, overrides = {}) => ({ + 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, + }, + }; +} + +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()); + + 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.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()); + + 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..41ae2caa88a85 --- /dev/null +++ b/src/components/profile-analysis/profile-analysis.dag.ts @@ -0,0 +1,362 @@ +import { MarkerType, type Edge, type 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 = '#667085'; +const DEPENDENCY_EDGE_COLOR = '#d98b00'; + +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; + elkPath: string | null; +} + +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[]; + container?: string; + sections?: ElkEdgeSection[]; +} + +interface ElkPoint { + x: number; + y: number; +} + +interface ElkEdgeSection { + startPoint: ElkPoint; + bendPoints?: ElkPoint[]; + endPoint: ElkPoint; +} + +export interface ElkGraph extends ElkNode { + children: ElkNode[]; + edges: ElkEdge[]; +} + +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'; +} + +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 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) { + 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 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 ?? []) { + 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); + 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: 'profileElk', + 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, + }, + 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, + }, + }; + }), + }; +} + +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.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.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..c70c04516d610 100644 --- a/src/components/profile-analysis/profile-analysis.types.ts +++ b/src/components/profile-analysis/profile-analysis.types.ts @@ -24,6 +24,8 @@ export type AnalysisState = export type AnalysisJobStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED'; +export type DagUiState = 'idle' | 'parsing' | 'ready' | 'unavailable' | 'failed'; + export interface CreateAnalysisJobResponse { jobId: string; status: AnalysisJobStatus; @@ -40,3 +42,145 @@ export type AnalysisJobSnapshot = | { jobId: string; status: 'RUNNING' } | { jobId: string; status: 'COMPLETED'; result: AgentMessage } | { 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 ProfileGraphIR { + 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 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 f4d6dfd991c87..90805df0c2dc6 100644 --- a/src/components/profile-analysis/use-profile-analysis.test.js +++ b/src/components/profile-analysis/use-profile-analysis.test.js @@ -175,6 +175,20 @@ test('keeps an uncertain analysis busy while its original identifiers are recove assert.equal(profileAnalysisReducer(recovering, { type: 'select', file: secondFile }), recovering); }); +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 completed = profileAnalysisReducer(queued, { + type: 'job_status', + job: { jobId: 'job-1', status: 'COMPLETED', result }, + }); + + 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', () => { 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..f2b9e33020a20 100644 --- a/src/components/profile-analysis/use-profile-analysis.ts +++ b/src/components/profile-analysis/use-profile-analysis.ts @@ -138,7 +138,7 @@ export function profileAnalysisReducer( jobId: action.jobId, jobsAhead: null, }; - case 'job_status': + case 'job_status': { if (action.job.status === 'QUEUED') { return { ...snapshot, @@ -148,9 +148,32 @@ export function profileAnalysisReducer( }; } if (action.job.status === 'RUNNING') { - return { ...snapshot, state: 'analyzing', jobId: action.job.jobId, jobsAhead: null }; + return { + ...snapshot, + state: 'analyzing', + jobId: action.job.jobId, + jobsAhead: null, + }; } - return snapshot; + if (action.job.status === 'COMPLETED') { + return { + ...snapshot, + state: 'completed', + jobId: action.job.jobId, + jobsAhead: null, + result: action.job.result, + error: null, + }; + } + return { + ...snapshot, + state: 'failed', + jobId: action.job.jobId, + jobsAhead: null, + result: null, + error: action.job.error.message, + }; + } case 'complete': return { ...snapshot, @@ -228,7 +251,8 @@ export function useProfileAnalysis(apiBaseUrl: string) { const pollJob = useCallback( async (jobId: string, pollIntervalMs: number, controller: AbortController): Promise => { - const terminal = await pollAnalysisJobWithRecovery({ + let settled = false; + await pollAnalysisJobWithRecovery({ get: () => getAnalysisJob(apiBaseUrl, jobId, controller.signal), wait: milliseconds => wait(milliseconds, controller.signal), onRecovering: () => { @@ -236,19 +260,15 @@ export function useProfileAnalysis(apiBaseUrl: string) { dispatch({ type: 'recovering' }); } }, - onProgress: job => { - if (mountedRef.current && abortControllerRef.current === controller) { - dispatch({ type: 'job_status', job }); - } + onProgress: () => {}, + onSnapshot: job => { + if (!mountedRef.current || abortControllerRef.current !== controller) return; + settled = job.status === 'COMPLETED' || job.status === 'FAILED'; + dispatch({ type: 'job_status', job }); }, + isComplete: () => settled, 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], ); 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"