sparkforensics-server 0.5.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +16 -7
- package/package.json +1 -1
- package/public/assets/AutoscalingChurn-DenWKTv-.js +1 -0
- package/public/assets/CacheUtilization-DLz7NdjV.js +1 -0
- package/public/assets/CachingOpportunity-zpQIqhzm.js +1 -0
- package/public/assets/ChartTheme-DAiARbLP.js +8 -0
- package/public/assets/ColdStart-C-9HjxWl.js +1 -0
- package/public/assets/ConfigAudit-D0V9tcrJ.js +1 -0
- package/public/assets/CoreUsageArea-yeerrljd.js +1 -0
- package/public/assets/Dashboard-DkUPrHKV.js +1 -0
- package/public/assets/DisclosureContext-DK1_iz6J.js +1 -0
- package/public/assets/DuplicatePlanSubtree-xDNDbCd3.js +1 -0
- package/public/assets/DurationHistogram-B6jsE0Ov.js +1 -0
- package/public/assets/EmptyState-C1uL40VU.js +1 -0
- package/public/assets/ExecutorUtilization-DXDZKmSc.js +1 -0
- package/public/assets/GcPressure-MRf7I4IG.js +1 -0
- package/public/assets/ImpactEstimate-Dm3xWbzV.js +1 -0
- package/public/assets/IncompleteRun-CaLOhMtf.js +1 -0
- package/public/assets/JobFailures-CzmzlvhO.js +1 -0
- package/public/assets/MemoryUtilization-AWUMUjCH.js +1 -0
- package/public/assets/NestedLoopJoin-C9sNr6Ss.js +1 -0
- package/public/assets/OverBroadcast-BOmRE6ca.js +1 -0
- package/public/assets/PartitionHint-BL2EmJfE.js +1 -0
- package/public/assets/PartitionSizing-BzBySvUq.js +1 -0
- package/public/assets/PlanExplorer-DdiQRcJU.js +1 -0
- package/public/assets/PlanGraphRoute-CIH9gBuR.js +1 -0
- package/public/assets/PlanView-B1hxzFRs.js +2 -0
- package/public/assets/PythonUdf-DvgCCueD.js +1 -0
- package/public/assets/RetryWaste-uXXu_CyM.js +1 -0
- package/public/assets/RowPagination-C-LzWkI5.js +1 -0
- package/public/assets/RowStatusCluster-Dbtmte7l.js +1 -0
- package/public/assets/ShuffleIO-nALwzC1_.js +1 -0
- package/public/assets/Skew-CHMEKgvk.js +1 -0
- package/public/assets/SlowHost-yyvjpHk6.js +1 -0
- package/public/assets/SmallFiles-y6odJ97n.js +1 -0
- package/public/assets/SpeculationWaste-C-SD2LOt.js +1 -0
- package/public/assets/Spill-DHTMmOup.js +1 -0
- package/public/assets/StageFailed-CQ3DxC9R.js +1 -0
- package/public/assets/StageFindingGroup-BgYLyj5q.js +1 -0
- package/public/assets/StageHeader-CIETkjVZ.js +1 -0
- package/public/assets/StagePill--P7PDd3s.js +1 -0
- package/public/assets/StageShape-BIv8vx_o.js +1 -0
- package/public/assets/StageSlowness-MMqzERxN.js +1 -0
- package/public/assets/Straggler-BgrY7AyW.js +1 -0
- package/public/assets/TaskFailures-BHHyKYTs.js +1 -0
- package/public/assets/TinyTask-DDScNhCl.js +1 -0
- package/public/assets/UnderBroadcast-CGIC04TI.js +1 -0
- package/public/assets/WidgetLeadSummary-CXFZP00C.js +1 -0
- package/public/assets/button-DbpTz2yk.js +1 -0
- package/public/assets/charts-vendor-CtTxwTeu.js +59 -0
- package/public/assets/composite-C9Nu2EI9.js +1 -0
- package/public/assets/d3-vendor-DNlD2-cz.js +1 -0
- package/public/assets/detector-registry-Bp8hrLua.js +2 -0
- package/public/assets/downsample-WmNM1qc9.js +1 -0
- package/public/assets/finding-anchor-Dr40LkS9.js +1 -0
- package/public/assets/findings-of-type-BL2u3RGZ.js +1 -0
- package/public/assets/impact-sort-mC5ISgX8.js +1 -0
- package/public/assets/inbox-KPU1Gppm.js +1 -0
- package/public/assets/index-CaRVPvAT.css +2 -0
- package/public/assets/index-fDiwfuoO.js +14 -0
- package/public/assets/inertValue-L9VxV0VE.js +1 -0
- package/public/assets/instrument-sans-latin-ext-wdth-normal-C5E2Gvlv.woff2 +0 -0
- package/public/assets/instrument-sans-latin-wdth-normal-BVScPF0l.woff2 +0 -0
- package/public/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/public/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/public/assets/layout.worker-CLcBHxVp.js +3 -0
- package/public/assets/{parser-worker-CA5RxqKc.js → parser-worker-CRIOPMOW.js} +8 -8
- package/public/assets/plan-graph-vendor-Ce6d4HU6.js +7 -0
- package/public/assets/react-vendor-C6KcUD36.js +9 -0
- package/public/assets/select-BHqOqun5.js +1 -0
- package/public/assets/toggle-DPeN6aNa.js +1 -0
- package/public/assets/toggle-group-Kl3uyHSr.js +1 -0
- package/public/assets/usePagedRows-BafD6bEN.js +1 -0
- package/public/assets/useRecentFiles-DzJZAoOT.js +3 -0
- package/public/assets/useRegisterFieldControl-pQv-ZDoU.js +1 -0
- package/public/assets/useScrollLock-BCxn_dMp.js +1 -0
- package/public/assets/useSortMode-DjrAN-z4.js +1 -0
- package/public/docs/404.html +3 -7
- package/public/docs/assets/{app.eYTPqTeq.js → app.1gvzd5Zb.js} +1 -1
- package/public/docs/assets/chunks/@localSearchIndexroot.DS2TQP5_.js +1 -0
- package/public/docs/assets/chunks/{VPLocalSearchBox.DsZrQKQR.js → VPLocalSearchBox.CNxT3s0I.js} +1 -1
- package/public/docs/assets/chunks/{theme.DsTNE2pE.js → theme.C5lLqUv7.js} +2 -2
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.lean.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.BYDo3ClR.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.ajUAVdbc.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract.md.CHHgywoP.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.wj6Et6UD.lean.js → contributor-guide_architecture_detector-contract.md.CHHgywoP.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.WH4tRHPL.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_detector-contract_threshold-tables.md.CKjWG56Y.lean.js → contributor-guide_architecture_detector-contract_threshold-tables.md.WH4tRHPL.lean.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_drill-down.md.CqZPrdo9.js → contributor-guide_architecture_drill-down.md.yB-Hz2XT.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_drill-down.md.CqZPrdo9.lean.js → contributor-guide_architecture_drill-down.md.yB-Hz2XT.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.BQZE04sw.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.lean.js → contributor-guide_architecture_drill-down_plan-graph.md.BQZE04sw.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.K71YB-Rr.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.lean.js → contributor-guide_architecture_impact-estimation.md.K71YB-Rr.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.DLCAffwn.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.lean.js → contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.DLCAffwn.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_overview.md.nxkpOhgf.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.U-9KMDFK.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.lean.js → contributor-guide_architecture_state-and-history_run-comparison-internals.md.U-9KMDFK.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.lean.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_widget-rendering_list-sort-mode.md.BuHKgH0C.js → contributor-guide_architecture_widget-rendering_list-sort-mode.md.37hcZJ7q.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_widget-rendering_row-expand-contract.md.ZT89u9f1.js → contributor-guide_architecture_widget-rendering_row-expand-contract.md.mK_VmAWk.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol.md.BAnhi2-d.js → contributor-guide_architecture_worker-protocol.md.Br6OE7RW.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.Br6OE7RW.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.IVyaXrGe.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_cli-and-mcp.md.DC1YqXBp.lean.js → contributor-guide_architecture_worker-protocol_cli-and-mcp.md.IVyaXrGe.lean.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DSVFPpWb.js → contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DVSXn-bb.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-availability.md.CjimIDBY.js → contributor-guide_architecture_worker-protocol_evidence-availability.md.D3S5cyOW.js} +2 -2
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-availability.md.CjimIDBY.lean.js → contributor-guide_architecture_worker-protocol_evidence-availability.md.D3S5cyOW.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.CtmCU57I.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.lean.js → contributor-guide_architecture_worker-protocol_evidence-report.md.CtmCU57I.lean.js} +1 -1
- package/public/docs/assets/contributor-guide_contributing.md.DBW4drtZ.js +1 -0
- package/public/docs/assets/{contributor-guide_development-setup.md.D9bu4j_c.js → contributor-guide_development-setup.md.B21ZTDhn.js} +3 -3
- package/public/docs/assets/contributor-guide_development-setup.md.B21ZTDhn.lean.js +1 -0
- package/public/docs/assets/contributor-guide_testing.md.DpkozNoG.js +4 -0
- package/public/docs/assets/{contributor-guide_testing.md.By6XGR9a.lean.js → contributor-guide_testing.md.DpkozNoG.lean.js} +1 -1
- package/public/docs/assets/{index.md.CHJVslga.js → index.md.lMm7SUMz.js} +1 -1
- package/public/docs/assets/{index.md.CHJVslga.lean.js → index.md.lMm7SUMz.lean.js} +1 -1
- package/public/docs/assets/instrument-sans-latin-ext-wdth-normal.C5E2Gvlv.woff2 +0 -0
- package/public/docs/assets/instrument-sans-latin-wdth-normal.BVScPF0l.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/public/docs/assets/style.Cfxqlj0y.css +1 -0
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.C0pcQoNc.js +29 -0
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.C0pcQoNc.lean.js +1 -0
- package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.CGaxPX_1.js +1 -0
- package/public/docs/assets/{user-guide_getting-started_tuning-thresholds.md.B_MyjyZm.js → user-guide_getting-started_tuning-thresholds.md.BGVE-wrc.js} +2 -2
- package/public/docs/assets/{user-guide_getting-started_tuning-thresholds.md.B_MyjyZm.lean.js → user-guide_getting-started_tuning-thresholds.md.BGVE-wrc.lean.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.js → user-guide_mcp-tools.md.49r3L2YE.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools_compare-runs.md.Cc_gLoix.js → user-guide_mcp-tools_compare-runs.md.BQ_kIptC.js} +11 -6
- package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.BQ_kIptC.lean.js +1 -0
- package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.azCZXkH8.js +44 -0
- package/public/docs/assets/{user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.lean.js → user-guide_mcp-tools_diagnose-run.md.azCZXkH8.lean.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.js → user-guide_mcp-tools_evaluate-budgets.md.dpQpRQ7z.js} +2 -2
- package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.lean.js → user-guide_mcp-tools_evaluate-budgets.md.dpQpRQ7z.lean.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools_get-finding-evidence.md.rKyu4wts.js → user-guide_mcp-tools_get-finding-evidence.md.CH6FkNLX.js} +2 -1
- package/public/docs/assets/{user-guide_mcp-tools_get-finding-evidence.md.rKyu4wts.lean.js → user-guide_mcp-tools_get-finding-evidence.md.CH6FkNLX.lean.js} +1 -1
- package/public/docs/assets/{user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.js → user-guide_mcp-tools_get-run-summary.md.EhaIsele.js} +1 -1
- package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.js +1 -0
- package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.lean.js +1 -0
- package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.js +1 -0
- package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.lean.js +1 -0
- package/public/docs/contributor-guide/architecture/board-widgets/caching-and-memory.html +6 -10
- package/public/docs/contributor-guide/architecture/board-widgets/plan-advisor.html +6 -10
- package/public/docs/contributor-guide/architecture/board-widgets/visual-system-and-templating.html +6 -10
- package/public/docs/contributor-guide/architecture/board-widgets.html +6 -10
- package/public/docs/contributor-guide/architecture/detector-contract/plan-attribution.html +6 -10
- package/public/docs/contributor-guide/architecture/detector-contract/threshold-tables.html +6 -10
- package/public/docs/contributor-guide/architecture/detector-contract.html +6 -10
- package/public/docs/contributor-guide/architecture/drill-down/plan-graph.html +6 -10
- package/public/docs/contributor-guide/architecture/drill-down.html +6 -10
- package/public/docs/contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.html +6 -10
- package/public/docs/contributor-guide/architecture/impact-estimation/stage-union-rollup.html +6 -10
- package/public/docs/contributor-guide/architecture/impact-estimation.html +6 -10
- package/public/docs/contributor-guide/architecture/index.html +5 -9
- package/public/docs/contributor-guide/architecture/overview.html +6 -10
- package/public/docs/contributor-guide/architecture/state-and-history/history-server-intake.html +5 -9
- package/public/docs/contributor-guide/architecture/state-and-history/run-comparison-internals.html +6 -10
- package/public/docs/contributor-guide/architecture/state-and-history.html +5 -9
- package/public/docs/contributor-guide/architecture/widget-rendering/findings-tab.html +6 -10
- package/public/docs/contributor-guide/architecture/widget-rendering/investigation-routing.html +5 -9
- package/public/docs/contributor-guide/architecture/widget-rendering/list-sort-mode.html +6 -10
- package/public/docs/contributor-guide/architecture/widget-rendering/reference-section.html +5 -9
- package/public/docs/contributor-guide/architecture/widget-rendering/row-expand-contract.html +6 -10
- package/public/docs/contributor-guide/architecture/widget-rendering.html +6 -10
- package/public/docs/contributor-guide/architecture/worker-protocol/cli-and-mcp.html +6 -10
- package/public/docs/contributor-guide/architecture/worker-protocol/core-and-event-validation.html +6 -10
- package/public/docs/contributor-guide/architecture/worker-protocol/evidence-availability.html +7 -11
- package/public/docs/contributor-guide/architecture/worker-protocol/evidence-report.html +6 -10
- package/public/docs/contributor-guide/architecture/worker-protocol.html +6 -10
- package/public/docs/contributor-guide/contributing.html +6 -10
- package/public/docs/contributor-guide/development-setup.html +8 -12
- package/public/docs/contributor-guide/testing.html +9 -10
- package/public/docs/favicon.svg +2 -2
- package/public/docs/hashmap.json +1 -1
- package/public/docs/index.html +6 -10
- package/public/docs/logo-dark.svg +1 -0
- package/public/docs/logo-light.svg +1 -0
- package/public/docs/tuning-reference/anti-patterns.html +5 -9
- package/public/docs/tuning-reference/aqe.html +5 -9
- package/public/docs/tuning-reference/bottleneck-broadcast-sizing.html +5 -9
- package/public/docs/tuning-reference/bottleneck-cold-start.html +5 -9
- package/public/docs/tuning-reference/bottleneck-duplicate-plan-subtree.html +5 -9
- package/public/docs/tuning-reference/bottleneck-failures.html +5 -9
- package/public/docs/tuning-reference/bottleneck-gc.html +5 -9
- package/public/docs/tuning-reference/bottleneck-job-failure-rate.html +5 -9
- package/public/docs/tuning-reference/bottleneck-memory-utilization.html +5 -9
- package/public/docs/tuning-reference/bottleneck-retry-waste.html +5 -9
- package/public/docs/tuning-reference/bottleneck-shuffle.html +5 -9
- package/public/docs/tuning-reference/bottleneck-skew.html +5 -9
- package/public/docs/tuning-reference/bottleneck-slow-host.html +5 -9
- package/public/docs/tuning-reference/bottleneck-small-files.html +5 -9
- package/public/docs/tuning-reference/bottleneck-spill.html +5 -9
- package/public/docs/tuning-reference/bottleneck-straggler.html +5 -9
- package/public/docs/tuning-reference/bottleneck-tiny-tasks.html +5 -9
- package/public/docs/tuning-reference/bottleneck-utilization.html +5 -9
- package/public/docs/tuning-reference/caching.html +5 -9
- package/public/docs/tuning-reference/cluster-config.html +5 -9
- package/public/docs/tuning-reference/config.html +5 -9
- package/public/docs/tuning-reference/data-formats.html +5 -9
- package/public/docs/tuning-reference/index.html +5 -9
- package/public/docs/tuning-reference/intro.html +5 -9
- package/public/docs/tuning-reference/joins.html +5 -9
- package/public/docs/tuning-reference/memory-model.html +5 -9
- package/public/docs/tuning-reference/metrics.html +5 -9
- package/public/docs/tuning-reference/partitioning.html +5 -9
- package/public/docs/tuning-reference/pyspark.html +5 -9
- package/public/docs/tuning-reference/shuffle.html +5 -9
- package/public/docs/tuning-reference/spark-architecture.html +5 -9
- package/public/docs/tuning-reference/table-formats.html +5 -9
- package/public/docs/user-guide/alternative-log-retrieval/managed-platforms.html +5 -9
- package/public/docs/user-guide/alternative-log-retrieval/ssh-bastion.html +5 -9
- package/public/docs/user-guide/alternative-log-retrieval.html +5 -9
- package/public/docs/user-guide/getting-started/ci-and-automation.html +17 -15
- package/public/docs/user-guide/getting-started/reading-the-dashboard.html +6 -10
- package/public/docs/user-guide/getting-started/regression-metric-keys.html +5 -9
- package/public/docs/user-guide/getting-started/tuning-thresholds.html +7 -11
- package/public/docs/user-guide/getting-started.html +5 -9
- package/public/docs/user-guide/mcp-tools/compare-runs.html +16 -15
- package/public/docs/user-guide/mcp-tools/diagnose-run.html +28 -11
- package/public/docs/user-guide/mcp-tools/evaluate-budgets.html +6 -10
- package/public/docs/user-guide/mcp-tools/get-finding-documentation.html +5 -9
- package/public/docs/user-guide/mcp-tools/get-finding-evidence.html +7 -10
- package/public/docs/user-guide/mcp-tools/get-reference-doc.html +5 -9
- package/public/docs/user-guide/mcp-tools/get-run-summary.html +6 -10
- package/public/docs/user-guide/mcp-tools/list-runs.html +5 -9
- package/public/docs/user-guide/mcp-tools.html +6 -10
- package/public/docs/user-guide/run-comparison/how-stages-are-matched.html +5 -9
- package/public/docs/user-guide/run-comparison.html +6 -10
- package/public/docs/user-guide/understanding-findings.html +6 -10
- package/public/export-template.html +46 -46
- package/public/favicon.svg +2 -2
- package/public/index.html +8 -10
- package/public/sample-runs/sample-run.ndjson.gz +0 -0
- package/vendor-core/allocation.js +126 -39
- package/vendor-core/analyzer.js +85 -58
- package/vendor-core/api-merge-writes.js +3 -0
- package/vendor-core/aqe-skew.js +347 -0
- package/vendor-core/cli/budgets.js +6 -4
- package/vendor-core/cli/collect-run-pair.js +89 -0
- package/vendor-core/cli/collect-run-worker.js +16 -0
- package/vendor-core/cli/collect-run.js +1 -2
- package/vendor-core/cli/native-zstd.js +12 -16
- package/vendor-core/comparability.js +234 -0
- package/vendor-core/comparison-output.js +15 -7
- package/vendor-core/comparison-verdict.js +105 -25
- package/vendor-core/core-source-hash.txt +1 -1
- package/vendor-core/core-time-series.js +0 -1
- package/vendor-core/core-usage-locality.js +1 -1
- package/vendor-core/delta-log-paths.js +10 -1
- package/vendor-core/detectors.js +1078 -416
- package/vendor-core/docs-config.js +0 -4
- package/vendor-core/docs-content/detection/cache.md +2 -1
- package/vendor-core/docs-content/detection/cfg.md +19 -11
- package/vendor-core/docs-content/detection/cold.md +2 -1
- package/vendor-core/docs-content/detection/gc.md +7 -3
- package/vendor-core/docs-content/detection/host.md +11 -1
- package/vendor-core/docs-content/detection/mem.md +17 -6
- package/vendor-core/docs-content/detection/part.md +3 -1
- package/vendor-core/docs-content/detection/plan.md +55 -13
- package/vendor-core/docs-content/detection/shfl.md +11 -5
- package/vendor-core/docs-content/detection/skew.md +48 -1
- package/vendor-core/docs-content/detection/slow.md +4 -2
- package/vendor-core/docs-content/detection/spec.md +6 -1
- package/vendor-core/docs-content/detection/spill.md +6 -3
- package/vendor-core/docs-content/detection/strag.md +19 -6
- package/vendor-core/docs-content/detection/tiny.md +9 -3
- package/vendor-core/docs-content/detection/util.md +6 -2
- package/vendor-core/effective-conf.js +7 -10
- package/vendor-core/efficiency-model.js +24 -16
- package/vendor-core/etl-phases.js +1 -2
- package/vendor-core/event-handlers.js +175 -57
- package/vendor-core/event-schemas.js +21 -16
- package/vendor-core/evidence-availability.js +9 -2
- package/vendor-core/evidence-report.js +118 -35
- package/vendor-core/executor-peaks.js +41 -0
- package/vendor-core/export-data.js +5 -5
- package/vendor-core/finding-identity.js +78 -0
- package/vendor-core/finding-presentation.js +129 -39
- package/vendor-core/finding-types.js +108 -30
- package/vendor-core/format-utils.js +14 -15
- package/vendor-core/html-export.js +1 -1
- package/vendor-core/impact-band.js +2 -2
- package/vendor-core/impact-format.js +47 -19
- package/vendor-core/impact-model.js +28 -17
- package/vendor-core/job-groups.js +4 -4
- package/vendor-core/mcp-server-factory.js +48 -12
- package/vendor-core/mcp-tools.js +179 -24
- package/vendor-core/median.js +2 -2
- package/vendor-core/nested-loop-join.js +73 -0
- package/vendor-core/occupancy.js +1 -2
- package/vendor-core/parser-worker.js +1 -1
- package/vendor-core/plan-graph-model.js +14 -22
- package/vendor-core/plan-node-detail.js +2 -3
- package/vendor-core/plan-summary.js +24 -19
- package/vendor-core/plan-tree-walk.js +6 -8
- package/vendor-core/python-stage.js +12 -1
- package/vendor-core/recommendation-rollup.js +13 -12
- package/vendor-core/redact.js +52 -46
- package/vendor-core/remediation.js +10 -5
- package/vendor-core/run-aggregates.js +6 -8
- package/vendor-core/run-comparison.js +226 -66
- package/vendor-core/run-identity.js +12 -0
- package/vendor-core/run-interpretation.js +21 -0
- package/vendor-core/run-metrics.js +2 -2
- package/vendor-core/run-output.js +44 -11
- package/vendor-core/run-totals.js +12 -0
- package/vendor-core/run-verdict.js +76 -3
- package/vendor-core/scaling-sim.js +3 -4
- package/vendor-core/snappy-block.js +2 -2
- package/vendor-core/spark-conf.js +168 -0
- package/vendor-core/sql-stages.js +2 -2
- package/vendor-core/stage-alignment.js +2 -12
- package/vendor-core/stage-identity.js +58 -3
- package/vendor-core/stage-label.js +14 -0
- package/vendor-core/stage-plan-nodes.js +17 -3
- package/vendor-core/stage-quantiles.js +141 -9
- package/vendor-core/stage-structure.js +17 -3
- package/vendor-core/threshold-overrides.js +9 -3
- package/vendor-core/types.js +70 -23
- package/vendor-core/wall-clock.js +8 -0
- package/vendor-core/wasted-core-hours.js +12 -12
- package/vendor-core/write-targets.js +146 -14
- package/vendor-core/zstd-worker-client.js +1 -1
- package/vendor-core/zstd-worker.js +1 -1
- package/public/assets/AutoscalingChurn-8t3U3isq.js +0 -1
- package/public/assets/CacheUtilization-CJs9tTum.js +0 -1
- package/public/assets/CachingOpportunity-D1NcHs0V.js +0 -1
- package/public/assets/ColdStart-CdRJ9gD8.js +0 -1
- package/public/assets/ConfigAudit-B5Gbe5js.js +0 -1
- package/public/assets/CoreUsageArea-a--NTRGY.js +0 -1
- package/public/assets/Dashboard-C1hNeX49.js +0 -2
- package/public/assets/DisclosureContext-ZpE6CkHx.js +0 -1
- package/public/assets/DuplicatePlanSubtree-dobXdz7E.js +0 -1
- package/public/assets/ExecutorUtilization-D9grwi7t.js +0 -1
- package/public/assets/GcPressure-8-G2ri10.js +0 -1
- package/public/assets/IncompleteRun-B91lky7a.js +0 -1
- package/public/assets/JobFailures-BsWvBMyJ.js +0 -1
- package/public/assets/MemoryUtilization-BM5schZI.js +0 -1
- package/public/assets/OverBroadcast-DdN8JIJH.js +0 -1
- package/public/assets/PartitionHint-CUvbnnxy.js +0 -1
- package/public/assets/PartitionSizing-CY5HDYtp.js +0 -1
- package/public/assets/PlanExplorer-DgHCDBD_.js +0 -1
- package/public/assets/PlanGraphRoute-DH3KJb8j.js +0 -1
- package/public/assets/RetryWaste-DOnE9vGF.js +0 -1
- package/public/assets/ShuffleIO-MMnDESkF.js +0 -1
- package/public/assets/Skew-BmZZJ5ms.js +0 -1
- package/public/assets/SlowHost-CsJzfEqo.js +0 -1
- package/public/assets/SmallFiles-BoK3R1Ex.js +0 -1
- package/public/assets/SpeculationWaste-CnVxB5AJ.js +0 -1
- package/public/assets/Spill-BhNicyBy.js +0 -1
- package/public/assets/StageFailed-mAhHU4l_.js +0 -1
- package/public/assets/StageFindingGroup-BEtPWcT0.js +0 -1
- package/public/assets/StageHeader-DJkYkrsU.js +0 -1
- package/public/assets/StageShape-BDxRknLp.js +0 -1
- package/public/assets/StageSlowness-BPRw-otN.js +0 -1
- package/public/assets/Straggler-CZCbnOWP.js +0 -1
- package/public/assets/TaskFailures-C57YcLFQ.js +0 -1
- package/public/assets/TinyTask-Bi84qU7n.js +0 -1
- package/public/assets/UnderBroadcast-DdQV7htE.js +0 -1
- package/public/assets/charts-vendor-0VMaLVLN.js +0 -59
- package/public/assets/finding-anchor-Cl6_5hfV.js +0 -1
- package/public/assets/impact-sort-CssZ6MT2.js +0 -1
- package/public/assets/index-2h_TgUAb.css +0 -2
- package/public/assets/index-B-03mG7K.js +0 -14
- package/public/assets/plan-graph-vendor-BYl-AzN5.js +0 -7
- package/public/assets/react-vendor-DEdfrRTJ.js +0 -9
- package/public/assets/select-CQjNhprm.js +0 -1
- package/public/assets/usePagedRows-Dyl6T8Ok.js +0 -1
- package/public/assets/useRecentFiles-XZEnLlal.js +0 -11
- package/public/assets/useSortMode-Dhvit8dt.js +0 -1
- package/public/docs/assets/chunks/@localSearchIndexroot.u_e5bQz4.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract.md.wj6Et6UD.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.CKjWG56Y.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_overview.md.C1sSvm9i.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BOZ1azXG.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BAnhi2-d.lean.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.DC1YqXBp.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.js +0 -1
- package/public/docs/assets/contributor-guide_contributing.md.CQMvGwFt.js +0 -1
- package/public/docs/assets/contributor-guide_development-setup.md.D9bu4j_c.lean.js +0 -1
- package/public/docs/assets/contributor-guide_testing.md.By6XGR9a.js +0 -1
- package/public/docs/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
- package/public/docs/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
- package/public/docs/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
- package/public/docs/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
- package/public/docs/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
- package/public/docs/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
- package/public/docs/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
- package/public/docs/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
- package/public/docs/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
- package/public/docs/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
- package/public/docs/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
- package/public/docs/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
- package/public/docs/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
- package/public/docs/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
- package/public/docs/assets/style.tmDhD3c8.css +0 -1
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.OUQqw3mI.js +0 -23
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.OUQqw3mI.lean.js +0 -1
- package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.js +0 -1
- package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.Cc_gLoix.lean.js +0 -1
- package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.js +0 -23
- package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.js +0 -1
- package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.lean.js +0 -1
- package/public/docs/assets/user-guide_understanding-findings.md.C9YRruMT.js +0 -1
- package/public/docs/assets/user-guide_understanding-findings.md.C9YRruMT.lean.js +0 -1
- /package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.lean.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.BYDo3ClR.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.lean.js → contributor-guide_architecture_board-widgets_plan-advisor.md.ajUAVdbc.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_overview.md.C1sSvm9i.lean.js → contributor-guide_architecture_overview.md.nxkpOhgf.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_widget-rendering.md.BOZ1azXG.lean.js → contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_widget-rendering_list-sort-mode.md.BuHKgH0C.lean.js → contributor-guide_architecture_widget-rendering_list-sort-mode.md.37hcZJ7q.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_widget-rendering_row-expand-contract.md.ZT89u9f1.lean.js → contributor-guide_architecture_widget-rendering_row-expand-contract.md.mK_VmAWk.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DSVFPpWb.lean.js → contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DVSXn-bb.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_contributing.md.CQMvGwFt.lean.js → contributor-guide_contributing.md.DBW4drtZ.lean.js} +0 -0
- /package/public/docs/assets/{user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.lean.js → user-guide_getting-started_reading-the-dashboard.md.CGaxPX_1.lean.js} +0 -0
- /package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.lean.js → user-guide_mcp-tools.md.49r3L2YE.lean.js} +0 -0
- /package/public/docs/assets/{user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.lean.js → user-guide_mcp-tools_get-run-summary.md.EhaIsele.lean.js} +0 -0
package/vendor-core/detectors.js
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
|
-
import { pathBasename, formatBytes, IMPACT_BAND_ORDER } from './format-utils.js';
|
|
1
|
+
import { pathBasename, formatBytes, formatDuration, IMPACT_BAND_ORDER, MS_PER_CORE_HOUR } from './format-utils.js';
|
|
2
2
|
import { medianOfSorted } from './median.js';
|
|
3
3
|
import { shareLabel } from './finding-presentation.js';
|
|
4
|
-
import { scanRelationId } from './plan-summary.js';
|
|
5
|
-
import {
|
|
4
|
+
import { scanRelationId, parseJoinType } from './plan-summary.js';
|
|
5
|
+
import { allocatedCoreMs, computeAllocation } from './allocation.js';
|
|
6
|
+
import { executorHeapPeaks } from './executor-peaks.js';
|
|
7
|
+
import { computePeakConcurrentCores } from './core-count.js';
|
|
6
8
|
import { walkPlanTree } from './plan-tree-walk.js';
|
|
9
|
+
import { diagnoseJoinSkew, isSkewJoinNode, planShowsSkewSplit } from './aqe-skew.js';
|
|
10
|
+
import { isBatchEvalPythonNode } from './python-stage.js';
|
|
7
11
|
import { computeCoreLocalityRatio } from './core-locality-ratio.js';
|
|
12
|
+
import { STRAGGLER_FACTOR } from './stage-quantiles.js';
|
|
8
13
|
import { tailRecoveryMs, tailRemovedWorkMs, stragglerFixLongestTaskMs, } from './occupancy.js';
|
|
9
14
|
import { IMPACT_FLOOR_PCT_WARN, IMPACT_FLOOR_PCT_CRIT, appDurationMs } from './impact-band.js';
|
|
10
15
|
import {
|
|
11
16
|
BROADCAST_BANDWIDTH_BPS, EXECUTOR_STARTUP_OVERHEAD_MS, FILE_OPEN_OVERHEAD_MS, IDEAL_BYTES_PER_PARTITION_TASK,
|
|
12
17
|
NETWORK_FETCH_PENALTY_MS, RE_READ_THROUGHPUT_BPS, SHUFFLE_THROUGHPUT_BPS, SPILL_IO_THROUGHPUT_BPS, TAIL_CLAIM,
|
|
13
18
|
TASK_SCHEDULING_OVERHEAD_MS, costOnly, fetchWaitWallClockMs, measuredTaskOverhead, multiStageImpact, noWasteModel,
|
|
14
|
-
retryWallClockMs, singleStageImpact, stageIoParallelism, stageMappableWasteOrCostOnly, tasksMostlyIdle,
|
|
19
|
+
ioModelAssumption, retryWallClockMs, singleStageImpact, stageIoParallelism, stageMappableWasteOrCostOnly, tasksMostlyIdle, withAssumptions,
|
|
15
20
|
|
|
16
21
|
} from './impact-model.js';
|
|
17
22
|
import { isExchangeNode, isBroadcastExchangeNode } from './plan-node-detail.js';
|
|
23
|
+
import { outputRowsOf, ownOutputRows, parseNestedLoopJoin } from './nested-loop-join.js';
|
|
18
24
|
import { totalExecutorCpuMs } from './run-totals.js';
|
|
19
25
|
import { DUPLICATE_SUBTREE_DIFFERING_NOTE, duplicateSubtreeDetail, SLOW_HOST_DIMENSION_LABEL } from './finding-generic-recommendation.js';
|
|
20
26
|
import { stageIdsForSqlExec } from './sql-stages.js';
|
|
21
27
|
import { cyrb53 } from './string-hash.js';
|
|
22
|
-
import { decreaseConf, increaseConf, setConf } from './remediation.js';
|
|
28
|
+
import { codeFix, decreaseConf, increaseConf, setConf } from './remediation.js';
|
|
29
|
+
import { effectiveSparkConf, executorOverheadSettings, overlayModifiedConfigs, parseSparkBytes } from './spark-conf.js';
|
|
23
30
|
import { MAX_FAILURE_GROUPS, describeTaskFailure, } from './task-failure.js';
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
|
|
32
|
+
|
|
26
33
|
|
|
27
34
|
const MB = 1024 * 1024;
|
|
28
35
|
const GB = 1024 * MB;
|
|
29
|
-
const TB = 1024 * GB;
|
|
30
36
|
|
|
31
37
|
// Labels a byte threshold in this file's binary units, so a 1 GiB default reads '1 GB'.
|
|
32
38
|
function binaryThresholdLabel(bytes ) {
|
|
@@ -44,6 +50,9 @@ function binaryThresholdLabel(bytes ) {
|
|
|
44
50
|
|
|
45
51
|
|
|
46
52
|
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
47
56
|
|
|
48
57
|
|
|
49
58
|
|
|
@@ -92,6 +101,7 @@ function binaryThresholdLabel(bytes ) {
|
|
|
92
101
|
|
|
93
102
|
|
|
94
103
|
|
|
104
|
+
|
|
95
105
|
|
|
96
106
|
|
|
97
107
|
|
|
@@ -105,6 +115,8 @@ function binaryThresholdLabel(bytes ) {
|
|
|
105
115
|
|
|
106
116
|
|
|
107
117
|
|
|
118
|
+
|
|
119
|
+
|
|
108
120
|
|
|
109
121
|
|
|
110
122
|
|
|
@@ -156,16 +168,33 @@ function binaryThresholdLabel(bytes ) {
|
|
|
156
168
|
|
|
157
169
|
|
|
158
170
|
|
|
159
|
-
|
|
171
|
+
|
|
160
172
|
|
|
161
173
|
|
|
162
174
|
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
163
178
|
|
|
164
179
|
|
|
165
180
|
// auditConfig(app) calls every config-scope detect({ app }) with whatever appModel.app is
|
|
166
181
|
// (SparkAppInfo | null), independent of DetectorCtx.
|
|
167
182
|
|
|
168
183
|
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
// True when any stage ran outside a SQL execution: RDD API work, which spark.serializer governs.
|
|
190
|
+
export function hasStageOutsideSql(stages ) {
|
|
191
|
+
if (stages == null) return false;
|
|
192
|
+
for (const stage of stages.values()) if (stage.sqlExecutionId == null) return true;
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
169
198
|
|
|
170
199
|
|
|
171
200
|
|
|
@@ -176,13 +205,13 @@ function binaryThresholdLabel(bytes ) {
|
|
|
176
205
|
|
|
177
206
|
|
|
178
207
|
|
|
179
|
-
//
|
|
208
|
+
// Spill magnitude: absolute-volume tiers first, then ratio-plus-floor skew tiers.
|
|
180
209
|
function computeSpillMagnitude(
|
|
181
210
|
stage ,
|
|
182
211
|
t ,
|
|
183
212
|
) {
|
|
184
213
|
const { taskCount, spillDiskMax = 0, spillMemMax = 0, spillDiskP50 = 0, spillMemP50 = 0 } = stage;
|
|
185
|
-
//
|
|
214
|
+
// Absolute volume, branching on task count.
|
|
186
215
|
if (taskCount === 1) {
|
|
187
216
|
if (spillDiskMax >= t.singleTaskDiskGiB * GB || spillMemMax >= t.singleTaskMemGiB * GB) return { magnitude: 'severe' };
|
|
188
217
|
} else {
|
|
@@ -190,7 +219,7 @@ function computeSpillMagnitude(
|
|
|
190
219
|
if (spillDiskMax >= t.highDiskGiB * GB || perTaskDisk >= t.highTaskDiskMB * MB || spillMemMax >= t.highMemGiB * GB) return { magnitude: 'high' };
|
|
191
220
|
if (spillDiskMax >= t.medDiskMB * MB || spillMemMax >= t.medMemGiB * GB) return { magnitude: 'medium' };
|
|
192
221
|
}
|
|
193
|
-
//
|
|
222
|
+
// Ratio plus floor skew (requires enough tasks).
|
|
194
223
|
if (taskCount >= t.skewMinTasks) {
|
|
195
224
|
if (spillDiskP50 > 0 && spillDiskMax / spillDiskP50 > t.skewRatio && spillDiskMax >= t.skewDiskFloorMB * MB) return { magnitude: 'high' };
|
|
196
225
|
if (spillMemP50 > 0 && spillMemMax / spillMemP50 > t.skewRatio && spillMemMax >= t.skewMemFloorMB * MB) return { magnitude: 'medium' };
|
|
@@ -214,7 +243,7 @@ export function unionStageIds(nodes , fallback ) {
|
|
|
214
243
|
|
|
215
244
|
// Bottom-up shape computation for duplicate-subtree detection and the
|
|
216
245
|
// cachingOpportunity composite detector. `size` is the subtree node count; the default
|
|
217
|
-
// fingerprint encodes operator name + sorted metric NAMES (never values
|
|
246
|
+
// fingerprint encodes operator name + sorted metric NAMES (never values) + a digest
|
|
218
247
|
// of each child's fingerprint, so two subtrees with the same shape but different values still
|
|
219
248
|
// collide. Digesting children keeps each fingerprint O(own size): embedding the full child
|
|
220
249
|
// strings made every node carry its whole subtree, O(n x depth) text on deep real plans.
|
|
@@ -224,6 +253,13 @@ export function unionStageIds(nodes , fallback ) {
|
|
|
224
253
|
// without descendant scan detail entering the comparison.
|
|
225
254
|
|
|
226
255
|
|
|
256
|
+
// The metric names a node's fingerprint is built from. Executor-side values are left out: whether
|
|
257
|
+
// one resolves depends on which tasks reported it, and a fingerprint (and the duplicatePlanSubtree
|
|
258
|
+
// and cachingOpportunity ids derived from it) must not shift when a run reports more of them.
|
|
259
|
+
function fingerprintMetricNames(metrics ) {
|
|
260
|
+
return (metrics ?? []).filter((m) => !m.executorSide).map((m) => m.name).sort().join(',');
|
|
261
|
+
}
|
|
262
|
+
|
|
227
263
|
export function computePlanShapes(
|
|
228
264
|
root ,
|
|
229
265
|
opts = {},
|
|
@@ -248,7 +284,7 @@ export function computePlanShapes(
|
|
|
248
284
|
const realMetrics = writeHalf ? writeHalf.metrics : node.metrics;
|
|
249
285
|
const childShapes = realChildren.map((c) => visit(c, false));
|
|
250
286
|
const size = 1 + childShapes.reduce((sum, c) => sum + c.size, 0);
|
|
251
|
-
const metricNames = (realMetrics
|
|
287
|
+
const metricNames = fingerprintMetricNames(realMetrics);
|
|
252
288
|
const childFingerprints = childShapes.map((c) => cyrb53(c.fingerprint)).join(',');
|
|
253
289
|
const fingerprint = isRoot && includeDetail
|
|
254
290
|
? `${node.name}[${metricNames}]<${normalize(node.detail ?? '')}>{${childFingerprints}}`
|
|
@@ -273,8 +309,7 @@ export function normalizeDetail(detail ) {
|
|
|
273
309
|
.replace(/\bBuild(Left|Right)\b/g, 'BuildSide');
|
|
274
310
|
// The lookbehind only skips starts inside an identifier, which can't match unless the
|
|
275
311
|
// identifier's own start already did: same output, without re-scanning each identifier from
|
|
276
|
-
// every one of its letters (O(length^2) on
|
|
277
|
-
// log; 64ms -> 40ms per analyze()).
|
|
312
|
+
// every one of its letters (O(length^2) on long join details).
|
|
278
313
|
s = s.replace(/(?<![A-Za-z_])([A-Za-z_][\w.]*)\s*=\s*([A-Za-z_][\w.]*)/g, (_m, l, r) => {
|
|
279
314
|
const [a, b] = [l, r].sort();
|
|
280
315
|
return `${a} = ${b}`;
|
|
@@ -330,7 +365,7 @@ export function findCompositeCandidates(root ) {
|
|
|
330
365
|
const childResults = (node.children ?? []).map(visit);
|
|
331
366
|
path.pop();
|
|
332
367
|
|
|
333
|
-
const metricNames = (node.metrics
|
|
368
|
+
const metricNames = fingerprintMetricNames(node.metrics);
|
|
334
369
|
// Child digests, as in computePlanShapes: deterministic, so still comparable across executions.
|
|
335
370
|
const childFingerprints = childResults.map((r) => cyrb53(r.fingerprint)).join(',');
|
|
336
371
|
const fingerprint = `${node.name}[${metricNames}]{${childFingerprints}}`;
|
|
@@ -397,11 +432,8 @@ export function findDuplicateSubtrees(
|
|
|
397
432
|
{ minSubtreeSize, minOccurrences } ,
|
|
398
433
|
) {
|
|
399
434
|
const { shapeOf, allNodes } = computePlanShapes(root);
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// write-half node is ever pushed into allNodes in the first place, this
|
|
403
|
-
// filter can structurally never exclude anything. Kept in case that
|
|
404
|
-
// invariant ever changes upstream.
|
|
435
|
+
// Guard only: computePlanShapes steps through a read node to its write half's children, so no
|
|
436
|
+
// write-half node reaches allNodes.
|
|
405
437
|
const eligible = allNodes.filter(n => shapeOf.get(n) .size >= minSubtreeSize && n.exchangeRole !== 'write');
|
|
406
438
|
|
|
407
439
|
const groups = new Map ();
|
|
@@ -445,7 +477,7 @@ export function findDuplicateSubtrees(
|
|
|
445
477
|
// scanned table, literals). The fingerprint ignores detail, so occurrences can be same-shaped
|
|
446
478
|
// branches over different data; only identical ones are repeated work that computing once would
|
|
447
479
|
// save. AQE query-stage numbers (`ShuffleQueryStage 718` vs `720`) name the same computation's
|
|
448
|
-
// runtime stage and are ignored too.
|
|
480
|
+
// runtime stage and are ignored too. Calibration: threshold-tables.md.
|
|
449
481
|
function occurrencesHaveIdenticalDetails(occurrences ) {
|
|
450
482
|
const detailsOf = (root ) => {
|
|
451
483
|
const out = [];
|
|
@@ -469,11 +501,10 @@ function occurrencesHaveIdenticalDetails(occurrences ) {
|
|
|
469
501
|
|
|
470
502
|
// Stages that run nothing but the duplicated occurrences' operators: every operator attributed to
|
|
471
503
|
// the stage is inside `nodes`. A stage shared with operators outside them (the join consuming the
|
|
472
|
-
// subtree, the other join side) does other work too, so its time isn't the subtree's to claim
|
|
473
|
-
// counting it
|
|
504
|
+
// subtree, the other join side) does other work too, so its time isn't the subtree's to claim, and
|
|
505
|
+
// counting it would let sibling groups claim the same stage twice. A WholeStageCodegen wrapper and
|
|
474
506
|
// an Exchange's write half aren't other work (the fused pipeline around an operator, the shuffle
|
|
475
|
-
// write of its output), so they're left out of both counts
|
|
476
|
-
// only outside node on 426 stages.
|
|
507
|
+
// write of its output), so they're left out of both counts.
|
|
477
508
|
function countsTowardStage(node ) {
|
|
478
509
|
return node.exchangeRole !== 'write' && !node.name.startsWith('WholeStageCodegen');
|
|
479
510
|
}
|
|
@@ -516,40 +547,119 @@ const FILES_READ_BYTES = 'size of files read';
|
|
|
516
547
|
const FILES_WRITTEN_COUNT = 'number of written files';
|
|
517
548
|
const FILES_WRITTEN_BYTES = 'written output';
|
|
518
549
|
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
550
|
+
// A join side's shuffle: the Exchange node whose "data size" is that side's runtime size. AQE
|
|
551
|
+
// decides broadcast from the same metric (ShuffleExchangeExec.runtimeStatistics, apache/spark
|
|
552
|
+
// v3.5.0). Only operators that pass rows through unchanged are looked through, so a side that is
|
|
553
|
+
// another join's output, an aggregate, or a reused exchange has no size of its own and yields null.
|
|
554
|
+
const SHUFFLE_PASS_THROUGH = /^(Sort|InputAdapter|WholeStageCodegen|ShuffleQueryStage|AQEShuffleRead|CustomShuffleReader)\b/;
|
|
555
|
+
|
|
556
|
+
function joinSideShuffle(node ) {
|
|
557
|
+
if (node.name === 'ReusedExchange') return null;
|
|
558
|
+
const dataSize = (node.metrics ?? []).some((m) => m.name === 'data size');
|
|
559
|
+
if (isExchangeNode(node) && !isBroadcastExchangeNode(node.name) && dataSize) return node;
|
|
560
|
+
const children = node.children ?? [];
|
|
561
|
+
if (children.length !== 1 || !(isExchangeNode(node) || SHUFFLE_PASS_THROUGH.test(node.name))) return null;
|
|
562
|
+
return joinSideShuffle(children[0]);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function shuffleBytes(exchange ) {
|
|
566
|
+
return (exchange.metrics ?? []).find((m) => m.name === 'data size')?.value ?? 0;
|
|
528
567
|
}
|
|
529
568
|
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
569
|
+
// The join types a side can be the broadcast build side of, per canBuildBroadcastLeft/Right in
|
|
570
|
+
// JoinSelectionHelper (apache/spark v3.5.0, v4.0.0 adds LeftSingle on the right). A full outer
|
|
571
|
+
// join builds from neither side, and an unrecognized type is not guessed at.
|
|
572
|
+
const BROADCAST_BUILD_LEFT = new Set(['Inner', 'Cross', 'RightOuter']);
|
|
573
|
+
const BROADCAST_BUILD_RIGHT = new Set(['Inner', 'Cross', 'LeftOuter', 'LeftSingle', 'LeftSemi', 'LeftAnti', 'ExistenceJoin']);
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
// The smaller of the sides this join's type can broadcast. Both sides need a measurable size of
|
|
583
|
+
// their own, so the candidate is never compared with an unknown partner.
|
|
584
|
+
function broadcastCandidate(join ) {
|
|
585
|
+
const joinType = parseJoinType(join.detail ?? '');
|
|
586
|
+
if (joinType == null || (join.children ?? []).length !== 2) return null;
|
|
587
|
+
const left = joinSideShuffle(join.children[0]);
|
|
588
|
+
const right = joinSideShuffle(join.children[1]);
|
|
589
|
+
if (!left || !right) return null;
|
|
590
|
+
const options = [];
|
|
591
|
+
if (BROADCAST_BUILD_LEFT.has(joinType)) options.push({ joinType, buildSide: 'left', build: left, stream: right });
|
|
592
|
+
if (BROADCAST_BUILD_RIGHT.has(joinType)) options.push({ joinType, buildSide: 'right', build: right, stream: left });
|
|
593
|
+
return options.reduce ((best, o) => (best == null || shuffleBytes(o.build) < shuffleBytes(best.build) ? o : best), null);
|
|
538
594
|
}
|
|
539
595
|
|
|
540
596
|
// Max/median deviation ratio over a list of {key, value}; returns the
|
|
541
|
-
// exceeding entry's key + ratio, or null when < 3 samples or median 0.
|
|
597
|
+
// exceeding entry's key + ratio, or null when < 3 samples or median 0. `qualifies` narrows the
|
|
598
|
+
// entries the maximum is taken from (the median still covers all of them).
|
|
542
599
|
function maxMedianRatio(
|
|
543
600
|
samples ,
|
|
601
|
+
qualifies = () => true,
|
|
544
602
|
) {
|
|
545
603
|
if (samples.length < 3) return null;
|
|
546
604
|
const vals = samples.map(s => s.value).sort((a, b) => a - b);
|
|
547
605
|
const median = medianOfSorted(vals);
|
|
548
606
|
if (median <= 0) return null;
|
|
549
|
-
const
|
|
607
|
+
const pool = samples.filter(qualifies);
|
|
608
|
+
if (pool.length === 0) return null;
|
|
609
|
+
const top = pool.reduce((a, b) => (b.value > a.value ? b : a));
|
|
550
610
|
return { key: top.key, ratio: top.value / median, value: top.value };
|
|
551
611
|
}
|
|
552
612
|
|
|
613
|
+
// z a lone executor's mean must clear to stand out from `executors` chance draws at a family-wise
|
|
614
|
+
// false-flag rate of `alpha`. A normal tail obeys P(Z > z) <= exp(-z^2 / 2) / 2, so a
|
|
615
|
+
// Bonferroni share of alpha / executors gives z = sqrt(2 ln(executors / (2 alpha))): the cutoff
|
|
616
|
+
// grows with the number of executors the largest mean was picked from.
|
|
617
|
+
function chanceOutlierZ(executors , alpha ) {
|
|
618
|
+
return Math.sqrt(2 * Math.log(executors / (2 * alpha)));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// How far each executor's task durations sit above the stage's median executor, in standard errors,
|
|
622
|
+
// on the log scale: (executor mean - median of executor means) / (stage task sd / sqrt(executor
|
|
623
|
+
// tasks)). The stage sd is rebuilt from the per-executor count, log sum and M2: within-executor
|
|
624
|
+
// squared deviations plus each executor mean's distance from the grand mean. null when the stage
|
|
625
|
+
// has under two tasks, no spread, or any executor lacks finite log stats, so an unknown spread
|
|
626
|
+
// never flags an executor.
|
|
627
|
+
function executorDurationZ(execs ) {
|
|
628
|
+
const active = execs.filter(e => e.taskCount > 0);
|
|
629
|
+
let tasks = 0, logTotal = 0;
|
|
630
|
+
for (const e of active) {
|
|
631
|
+
if (!Number.isFinite(e.logDurationSum) || !Number.isFinite(e.logDurationM2)) return null;
|
|
632
|
+
tasks += e.taskCount;
|
|
633
|
+
logTotal += e.logDurationSum ;
|
|
634
|
+
}
|
|
635
|
+
if (tasks < 2) return null;
|
|
636
|
+
const grandMean = logTotal / tasks;
|
|
637
|
+
let squares = 0;
|
|
638
|
+
const means = active.map(e => (e.logDurationSum ) / e.taskCount);
|
|
639
|
+
active.forEach((e, i) => { squares += (e.logDurationM2 ) + e.taskCount * (means[i] - grandMean) ** 2; });
|
|
640
|
+
const sd = Math.sqrt(squares / (tasks - 1));
|
|
641
|
+
if (!(sd > 0) || !Number.isFinite(sd)) return null;
|
|
642
|
+
const median = medianOfSorted([...means].sort((a, b) => a - b));
|
|
643
|
+
return new Map(active.map((e, i) => [e.executorId, (means[i] - median) / (sd / Math.sqrt(e.taskCount))]));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// slowHost's taskTime dimension picks the largest of N per-executor mean task times: the more
|
|
647
|
+
// executors, the likelier the largest reaches a given ratio to the median by chance alone, whatever
|
|
648
|
+
// the hardware. The band comes from the modeled saving, so the finding states how weak the signal is.
|
|
649
|
+
// No `host` here: it is one of slowHost's id discriminators, so setting it would change the id.
|
|
650
|
+
function executorTaskTimeCaveat(
|
|
651
|
+
executorId , ratio , executorCount , taskCount ,
|
|
652
|
+
) {
|
|
653
|
+
return {
|
|
654
|
+
confidence: 'low',
|
|
655
|
+
validationRequired:
|
|
656
|
+
`One of ${executorCount} executors: the largest of ${executorCount} per-executor mean task times reaches ${Math.round(ratio * 10) / 10}x the median by chance ` +
|
|
657
|
+
`when task durations vary widely or each executor ran few tasks (${taskCount} tasks over ${executorCount} executors here). ` +
|
|
658
|
+
`Compare per-task times on executor ${executorId} in the Spark UI before acting.`,
|
|
659
|
+
executorCount, taskCount,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
553
663
|
|
|
554
664
|
|
|
555
665
|
|
|
@@ -594,7 +704,6 @@ export function computeSkewRatio(
|
|
|
594
704
|
// ratio on a few ms is noise in an hours-long run but real in a seconds-long one; a fixed-ms
|
|
595
705
|
// floor can't scale. Used by skew/straggler, gated on the same occupancy-clipped tail claim their
|
|
596
706
|
// estimate() displays as savings (tailClaimImpact).
|
|
597
|
-
// NOT SOURCED: floor percentages are our own noise floor, unvalidated.
|
|
598
707
|
// Unknown app timing never suppresses a finding; it just skips the floor gate.
|
|
599
708
|
function meetsRuntimeFloor(wasteMs , runMs , floorPct ) {
|
|
600
709
|
return runMs == null || wasteMs >= runMs * floorPct;
|
|
@@ -656,26 +765,90 @@ function tailClaimFloorMs(claim , stageId , ctx )
|
|
|
656
765
|
return tailClaimImpact(claim, stageId, ctx.impact).wallClock?.high ?? claim.wasteMs;
|
|
657
766
|
}
|
|
658
767
|
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
function
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
if (
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
if (key === 'spark.sql.adaptive.skewJoin.enabled' || key === 'spark.sql.adaptive.coalescePartitions.enabled') {
|
|
671
|
-
return major >= 3 ? 'true' : undefined;
|
|
672
|
-
}
|
|
673
|
-
return undefined;
|
|
768
|
+
// skew's duration gate: the task-time ratio over `ratioWarn` whose occupancy-clipped tail claim
|
|
769
|
+
// (the claim estimate() reports as savings, so the gate agrees with it) is at least `floorPctWarn`
|
|
770
|
+
// of the run. Null when it does not hold. straggler runs it too, to report the tails skew's gate
|
|
771
|
+
// admits whose cause is not data.
|
|
772
|
+
function skewGate(
|
|
773
|
+
stage , ctx , ratioWarn , minTasksForP95 , floorPctWarn ,
|
|
774
|
+
) {
|
|
775
|
+
const result = computeSkewRatio(stage, minTasksForP95);
|
|
776
|
+
if (result === null || result.ratio <= ratioWarn) return null;
|
|
777
|
+
const floorWasteMs = tailClaimFloorMs(skewTailClaim(stage, result.metric === 'P95/median'), stage.id, ctx);
|
|
778
|
+
return meetsRuntimeFloor(floorWasteMs, appDurationMs(ctx.app), floorPctWarn) ? result : null;
|
|
674
779
|
}
|
|
675
780
|
|
|
676
|
-
//
|
|
781
|
+
// Why a stage's slow tail is slow, from attributeTail (stage-quantiles.ts). 'data' when data volume
|
|
782
|
+
// accounts for at least `dataShareMin` of the tail's extra time, else the largest of GC, fetch wait,
|
|
783
|
+
// one host and 'unexplained'. 'unattributed' (no data volume to compare, or no tail attribution)
|
|
784
|
+
// means only the duration is known, so straggler reports it and skew does not.
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
function tailVerdict(stage , dataShareMin ) {
|
|
793
|
+
const a = stage.tailAttribution ?? null;
|
|
794
|
+
if (a === null || !(a.excessMs > 0)) return { cause: 'unattributed', sharePct: 0, attribution: a };
|
|
795
|
+
const pct = (ms ) => Math.round((ms / a.excessMs) * 100);
|
|
796
|
+
if (a.dataMs / a.excessMs >= dataShareMin) return { cause: 'data', sharePct: pct(a.dataMs), attribution: a };
|
|
797
|
+
const rest = Math.max(0, a.excessMs - a.dataMs - a.gcMs - a.fetchWaitMs - a.hostMs);
|
|
798
|
+
const causes = [['gc', a.gcMs], ['fetchWait', a.fetchWaitMs], ['host', a.hostMs],
|
|
799
|
+
[a.dataRatio === null ? 'unattributed' : 'unexplained', rest]];
|
|
800
|
+
const [cause, ms] = causes.reduce((best, c) => (c[1] > best[1] ? c : best));
|
|
801
|
+
return { cause, sharePct: cause === 'unattributed' ? 0 : pct(ms), attribution: a };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// The run's effective value of a property: the logged one, else Spark's default for the run's
|
|
805
|
+
// version (spark-conf.ts). A detector scoped to one SQL execution passes that execution's app (see
|
|
806
|
+
// queryApp) so the settings it ran with count.
|
|
677
807
|
function effectiveConf(app , key ) {
|
|
678
|
-
return (app?.
|
|
808
|
+
return effectiveSparkConf({ sparkVersion: app?.sparkVersion, properties: app?.config }, key)?.value;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// The factor and minimum Spark applies to size the executor overhead when spark.executor.memoryOverhead
|
|
812
|
+
// is unset (spark-conf.ts's executorOverheadSettings); `fallback` is the detector's default pair.
|
|
813
|
+
function defaultOverheadSettings(app , fallback ) {
|
|
814
|
+
return executorOverheadSettings({ sparkVersion: app?.sparkVersion, properties: app?.config }, fallback);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const queryAppCache = new WeakMap ();
|
|
818
|
+
|
|
819
|
+
// The app as one SQL execution saw it: its modifiedConfigs applied over the logged properties, so
|
|
820
|
+
// effectiveConf reads what `spark.conf.set` changed. The app itself when the execution modified
|
|
821
|
+
// nothing.
|
|
822
|
+
function queryApp(app , modified ) {
|
|
823
|
+
if (modified == null) return app;
|
|
824
|
+
const cached = queryAppCache.get(modified);
|
|
825
|
+
if (cached?.base === app) return cached.derived;
|
|
826
|
+
const config = overlayModifiedConfigs(app?.config, modified);
|
|
827
|
+
if (config === app?.config) return app;
|
|
828
|
+
const derived = { ...app, config };
|
|
829
|
+
queryAppCache.set(modified, { base: app, derived });
|
|
830
|
+
return derived;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// The app as the stage's SQL execution saw it; the run's app for a stage outside any SQL execution.
|
|
834
|
+
function stageApp(ctx , stage ) {
|
|
835
|
+
return queryApp(ctx.app, stage.sqlExecutionId != null ? ctx.sql.get(stage.sqlExecutionId)?.modifiedConfigs : undefined);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// When Spark relaunches a slow task under the run's effective speculation settings: once the
|
|
839
|
+
// quantile of the stage's tasks has finished, a task running over the multiplier times the median
|
|
840
|
+
// of the finished ones gets a copy (TaskSetManager.checkSpeculatableTasks). Null when either
|
|
841
|
+
// setting is neither logged nor a default the run's Spark version fixes.
|
|
842
|
+
function speculationRule(app ) {
|
|
843
|
+
const multiplier = Number.parseFloat(effectiveConf(app, 'spark.speculation.multiplier') ?? '');
|
|
844
|
+
const quantile = Number.parseFloat(effectiveConf(app, 'spark.speculation.quantile') ?? '');
|
|
845
|
+
if (!Number.isFinite(multiplier) || !Number.isFinite(quantile)) return null;
|
|
846
|
+
return `a task running over ${multiplier}x the median is relaunched once ${Math.round(quantile * 100)}% of the stage's tasks have finished`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function speculationRuleNote(app ) {
|
|
850
|
+
const rule = speculationRule(app);
|
|
851
|
+
return rule == null ? '' : ` (with this run's settings, ${rule})`;
|
|
679
852
|
}
|
|
680
853
|
|
|
681
854
|
// A setting is only a fix when the run's effective conf doesn't already have it: a run that set it,
|
|
@@ -698,6 +871,34 @@ function switchFix(on , key , suggested , recomm
|
|
|
698
871
|
return on ? { text: alreadyOn, remediation: [] } : { text: recommend, remediation: [setConf(key, suggested)] };
|
|
699
872
|
}
|
|
700
873
|
|
|
874
|
+
// Whether memoryUtilization can judge the executor heap: a measured peak and a known executor size.
|
|
875
|
+
// Without both, the stage-level low-GC note is the only memory-sizing signal there is.
|
|
876
|
+
function heapJudgementAvailable(ctx ) {
|
|
877
|
+
return executorHeapPeaks(ctx).size > 0 && (ctx.app?.resources?.executor?.memoryMB ?? 0) > 0;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const LOG_STAGE_EXECUTOR_METRICS_KEY = 'spark.eventLog.logStageExecutorMetrics';
|
|
881
|
+
const PYTHON_UDF_ARROW_KEY = 'spark.sql.execution.pythonUDF.arrow.enabled';
|
|
882
|
+
// The executor-side metrics of the Python evaluator operators. Spark's PythonSQLMetrics trait adds
|
|
883
|
+
// them in 3.4.0, the release that also adds the Arrow-optimized UDF property, so an older log never
|
|
884
|
+
// reports them and the detector skips it.
|
|
885
|
+
const PYTHON_DATA_SENT = 'data sent to Python workers';
|
|
886
|
+
const PYTHON_DATA_RETURNED = 'data returned from Python workers';
|
|
887
|
+
|
|
888
|
+
// How to cut the cost of a row-at-a-time Python UDF: the Arrow-optimized UDF property while the
|
|
889
|
+
// session lacks it, else what is left once it is on (a UDF that opted out with useArrow=False, takes no
|
|
890
|
+
// arguments, or was created before the property was set: all of them still run as BatchEvalPython).
|
|
891
|
+
function pythonUdfArrowFix(app ) {
|
|
892
|
+
return switchFix(
|
|
893
|
+
loggedAs(app, PYTHON_UDF_ARROW_KEY, true), PYTHON_UDF_ARROW_KEY, true,
|
|
894
|
+
`set ${PYTHON_UDF_ARROW_KEY}=true to ship rows in Arrow batches, or rewrite the UDF as a pandas UDF.`,
|
|
895
|
+
`Arrow-optimized Python UDFs are already on for the session, so this UDF may have opted out with useArrow=False, take no arguments, or have been created before the property was set: check which, or rewrite it as a pandas UDF.`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// A tail reading at least this many times the median task's data is not described as reading no more.
|
|
900
|
+
const MORE_DATA_RATIO = 2;
|
|
901
|
+
|
|
701
902
|
const SKEW_KEY_REMEDY = 'salt the key or repartition on a better key';
|
|
702
903
|
|
|
703
904
|
// What a stage reads, by the dominant side: a stage that reads a shuffle and mostly input files is
|
|
@@ -716,24 +917,22 @@ function predatesAqeSkewJoin(app ) {
|
|
|
716
917
|
// What a skewed stage reads, which decides whether AQE skew-join handling can act on it. It splits
|
|
717
918
|
// skewed partitions on the shuffle-read side of a sort-merge or shuffled-hash join, so only a stage
|
|
718
919
|
// that reads a shuffle, in a SQL execution whose plan has such a join, is 'shuffleJoin'. A plan does
|
|
719
|
-
// not say which stage runs which join
|
|
720
|
-
// matched per execution. 'inputScan' reads mostly files (its tasks differ in input size);
|
|
920
|
+
// not say which stage runs which join, so the join is matched per execution. 'inputScan' reads mostly files (its tasks differ in input size);
|
|
721
921
|
// 'other' is any stage the plan cannot tie to a join (an aggregation's shuffle, no plan, no input),
|
|
722
922
|
// and any stage of a run on Spark before 3.0 (skewFix).
|
|
723
923
|
function skewOrigin(stage , ctx , shuffleEvidence ) {
|
|
724
924
|
if (shuffleEvidence || (stage.shuffleReadBytes > 0 && stage.shuffleReadBytes >= stage.inputBytes)) {
|
|
725
925
|
let hasJoin = false;
|
|
726
926
|
const plan = stage.sqlExecutionId != null ? ctx.sql.get(stage.sqlExecutionId)?.planTree : null;
|
|
727
|
-
walkPlanTree(plan, (node) => { if (
|
|
927
|
+
walkPlanTree(plan, (node) => { if (isSkewJoinNode(node.name)) hasJoin = true; });
|
|
728
928
|
return hasJoin ? 'shuffleJoin' : 'other';
|
|
729
929
|
}
|
|
730
930
|
return stage.inputBytes > 0 ? 'inputScan' : 'other';
|
|
731
931
|
}
|
|
732
|
-
const SKEW_JOIN_NODES = new Set(['SortMergeJoin', 'ShuffledHashJoin']);
|
|
733
932
|
|
|
734
933
|
// The skew finding's fix for the stage's origin. Join-driven skew gets AQE skew-join handling,
|
|
735
934
|
// unless the run's effective conf already has it; uneven input gets the file-size remedy.
|
|
736
|
-
function skewFix(stage , ctx , shuffleEvidence = false)
|
|
935
|
+
function skewFix(stage , ctx , shuffleEvidence = false, tail ) {
|
|
737
936
|
const origin = skewOrigin(stage, ctx, shuffleEvidence);
|
|
738
937
|
if (origin === 'inputScan') {
|
|
739
938
|
return {
|
|
@@ -742,10 +941,25 @@ function skewFix(stage , ctx , shuffleEvidence = false
|
|
|
742
941
|
remediation: [decreaseConf('spark.sql.files.maxPartitionBytes')],
|
|
743
942
|
};
|
|
744
943
|
}
|
|
745
|
-
if (origin === 'other') return { origin, text: SKEW_KEY_REMEDY, remediation: [] };
|
|
944
|
+
if (origin === 'other') return { origin, text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
|
|
746
945
|
// Before Spark 3.0 there is no AQE skew-join handling, so the stage gets the generic advice.
|
|
747
|
-
if (predatesAqeSkewJoin(ctx.app)) return { origin: 'other', text: SKEW_KEY_REMEDY, remediation: [] };
|
|
748
|
-
|
|
946
|
+
if (predatesAqeSkewJoin(ctx.app)) return { origin: 'other', text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
|
|
947
|
+
const app = stageApp(ctx, stage);
|
|
948
|
+
const fix = skewJoinFix(app);
|
|
949
|
+
// A tail the data volume explains (input plus shuffle read, in bytes or records) is data the
|
|
950
|
+
// diagnosis must not call even or cured from shuffle-read bytes alone.
|
|
951
|
+
const tailData = tail?.cause === 'data' ? { ratio: tail.attribution?.dataRatio ?? null } : undefined;
|
|
952
|
+
// With skew-join handling on, the final plan and conf say why it did or did not act on this stage.
|
|
953
|
+
const handlingOn = !loggedAs(app, 'spark.sql.adaptive.enabled', false) && loggedAs(app, 'spark.sql.adaptive.skewJoin.enabled', true);
|
|
954
|
+
const diagnosis = handlingOn && stage.sqlExecutionId != null
|
|
955
|
+
? diagnoseJoinSkew({
|
|
956
|
+
plan: ctx.sql.get(stage.sqlExecutionId)?.planTree, stageId: stage.id,
|
|
957
|
+
readMax: stage.shuffleReadMax, readP50: stage.shuffleReadP50, stageShuffleReadBytes: stage.shuffleReadBytes,
|
|
958
|
+
...(tailData ? { tailData } : {}),
|
|
959
|
+
conf: (key) => effectiveConf(app, key), sparkVersion: app?.sparkVersion, keyRemedy: SKEW_KEY_REMEDY,
|
|
960
|
+
})
|
|
961
|
+
: null;
|
|
962
|
+
return diagnosis == null ? { origin, ...fix } : { origin, aqeSkew: diagnosis.case, text: diagnosis.text, remediation: diagnosis.remediation };
|
|
749
963
|
}
|
|
750
964
|
|
|
751
965
|
function skewJoinFix(app ) {
|
|
@@ -756,9 +970,11 @@ function skewJoinFix(app )
|
|
|
756
970
|
remediation: [setConf('spark.sql.adaptive.enabled', true), ...setConfUnlessLogged(app, key, true)],
|
|
757
971
|
};
|
|
758
972
|
}
|
|
759
|
-
|
|
973
|
+
const fix = switchFix(loggedAs(app, key, true), key, true,
|
|
760
974
|
`for join-driven skew, enable AQE skew-join handling (${key}); otherwise ${SKEW_KEY_REMEDY}`,
|
|
761
975
|
`AQE skew-join handling is already on, so ${SKEW_KEY_REMEDY}`);
|
|
976
|
+
// With the switch already on, only the key is left to fix: a change to the job, not a property.
|
|
977
|
+
return fix.remediation.length === 0 ? { ...fix, remediation: [codeFix(SKEW_KEY_REMEDY)] } : fix;
|
|
762
978
|
}
|
|
763
979
|
|
|
764
980
|
// The resources flag is read from the same property as the logged conf.
|
|
@@ -767,6 +983,23 @@ function dynamicAllocationFix(app , recommend , alreadyOn
|
|
|
767
983
|
return switchFix(app.resources?.dynamicAllocationEnabled === true || loggedAs(app, key, true), key, true, recommend, alreadyOn);
|
|
768
984
|
}
|
|
769
985
|
|
|
986
|
+
// Idle capacity is cured by a smaller cluster, which the run's dynamic allocation decides how to
|
|
987
|
+
// size. With it on: the cap on how many executors it scales to, and the floor it holds when that
|
|
988
|
+
// is logged above 0 (both lowered together). With it off or unset: the alternatives of a fixed
|
|
989
|
+
// executor count or switching it on, which dynamicAllocationFix already names, so the
|
|
990
|
+
// recommendation reads "either ... or" and a consumer applies one entry. Not executorIdleTimeout:
|
|
991
|
+
// autoscalingChurn recommends raising it, so lowering it here would contradict that finding.
|
|
992
|
+
function idleCapacityFix(app , recommend , alreadyOnLead ) {
|
|
993
|
+
const fix = dynamicAllocationFix(app, recommend, alreadyOnLead);
|
|
994
|
+
// dynamicAllocationFix's remediation is empty exactly when dynamic allocation is already on.
|
|
995
|
+
if (fix.remediation.length > 0) return { ...fix, remediation: [...fix.remediation, decreaseConf('spark.executor.instances')] };
|
|
996
|
+
const holdsFloor = Number.parseInt(effectiveConf(app, 'spark.dynamicAllocation.minExecutors') ?? '', 10) > 0;
|
|
997
|
+
return holdsFloor
|
|
998
|
+
? { text: `${alreadyOnLead} by lowering spark.dynamicAllocation.maxExecutors and spark.dynamicAllocation.minExecutors`,
|
|
999
|
+
remediation: [decreaseConf('spark.dynamicAllocation.maxExecutors'), decreaseConf('spark.dynamicAllocation.minExecutors')] }
|
|
1000
|
+
: { text: `${alreadyOnLead} by lowering spark.dynamicAllocation.maxExecutors`, remediation: [decreaseConf('spark.dynamicAllocation.maxExecutors')] };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
770
1003
|
// The run's effective spark.sql.shuffle.partitions (logged, else Spark's 200) as a count, or null
|
|
771
1004
|
// when the logged value is not a count.
|
|
772
1005
|
function effectiveShufflePartitions(app ) {
|
|
@@ -774,31 +1007,129 @@ function effectiveShufflePartitions(app ) {
|
|
|
774
1007
|
return value != null && /^\d+$/.test(value) ? Number(value) : null;
|
|
775
1008
|
}
|
|
776
1009
|
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1010
|
+
// The partition count and property that sized a shuffle-reading stage's tasks. AQE only rewrites the
|
|
1011
|
+
// count of a stage inside a SQL execution. With coalescing on, `initialPartitionNum` replaces
|
|
1012
|
+
// spark.sql.shuffle.partitions as the starting count when it is set
|
|
1013
|
+
// (CoalesceShufflePartitions.coalesceShufflePartitions at v3.5.0), so it is the lever then.
|
|
1014
|
+
const INITIAL_PARTITION_NUM_KEY = 'spark.sql.adaptive.coalescePartitions.initialPartitionNum';
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
function partitionBasis(stage , ctx ) {
|
|
1032
|
+
const app = stageApp(ctx, stage);
|
|
1033
|
+
const aqe = stage.sqlExecutionId != null && loggedAs(app, 'spark.sql.adaptive.enabled', true);
|
|
1034
|
+
const coalesce = aqe && loggedAs(app, 'spark.sql.adaptive.coalescePartitions.enabled', true);
|
|
1035
|
+
const initial = coalesce ? effectiveConf(app, INITIAL_PARTITION_NUM_KEY) : undefined;
|
|
1036
|
+
const plan = stage.sqlExecutionId != null ? ctx.sql.get(stage.sqlExecutionId)?.planTree : null;
|
|
1037
|
+
const skewSplit = aqe ? planShowsSkewSplit(plan) : null;
|
|
1038
|
+
const explicitRepartition = planHasExplicitRepartition(plan);
|
|
1039
|
+
if (initial != null && /^\d+$/.test(initial)) return { count: Number(initial), key: INITIAL_PARTITION_NUM_KEY, aqe, coalesce, skewSplit, explicitRepartition };
|
|
1040
|
+
return { count: effectiveShufflePartitions(app), key: 'spark.sql.shuffle.partitions', aqe, coalesce, skewSplit, explicitRepartition };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Whether a plan holds an exchange the job asked for (`repartition(n)`, `repartition(col)` or a
|
|
1044
|
+
// rebalance hint), which is what makes a task count different from the configured one the stage's own.
|
|
1045
|
+
function planHasExplicitRepartition(plan ) {
|
|
1046
|
+
let found = false;
|
|
1047
|
+
walkPlanTree(plan, (node) => { if (/\b(REPARTITION_BY_NUM|REPARTITION_BY_COL|REBALANCE_PARTITIONS_BY_NONE|REBALANCE_PARTITIONS_BY_COL)\b/.test(node.detail ?? '')) found = true; });
|
|
1048
|
+
return found;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// How the stage's task count relates to the configured one. 'matches': the configured count sized it
|
|
1052
|
+
// (or the count is unknown). 'coalesced': AQE merged the partitions into fewer tasks. 'adjusted': more
|
|
1053
|
+
// tasks than configured under AQE, which a skew-join split can produce and several shuffle reads in
|
|
1054
|
+
// one execution can obscure: the stage's own repartition(n) is blamed only when the plan holds one.
|
|
1055
|
+
// 'own': a different count with no AQE to rewrite it, or with a repartition(n) in the plan, which is
|
|
1056
|
+
// a repartition(n) or an RDD operation that fixed its own count.
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
function countRelation(stage , basis ) {
|
|
1060
|
+
if (basis.count == null || stage.taskCount === basis.count) return 'matches';
|
|
1061
|
+
if (basis.coalesce && stage.taskCount < basis.count) return 'coalesced';
|
|
1062
|
+
if (basis.aqe && stage.taskCount > basis.count && (basis.skewSplit !== false || !basis.explicitRepartition)) return 'adjusted';
|
|
1063
|
+
return 'own';
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Whether partition-count advice fits a shuffle-reading stage. The count that brings each shuffle
|
|
1067
|
+
// partition to the ideal size is per stage; the property is job-wide. 'sufficient': the tasks are
|
|
1068
|
+
// already at or under the ideal size. 'aqeCoalesced' and 'ownPartitioning' follow countRelation
|
|
1069
|
+
// ('ownPartitioning' also when the property already gives at least the count needed). Otherwise
|
|
1070
|
+
// the property limits the stage: 'raise'.
|
|
1071
|
+
function shufflePartitionCase(stage , ctx ) {
|
|
1072
|
+
const basis = partitionBasis(stage, ctx);
|
|
1073
|
+
const { count, key } = basis;
|
|
786
1074
|
const needed = Math.ceil(stage.shuffleReadBytes / IDEAL_BYTES_PER_PARTITION_TASK);
|
|
787
1075
|
const tasks = Math.max(1, stage.taskCount);
|
|
1076
|
+
const relation = countRelation(stage, basis);
|
|
788
1077
|
let partitions = 'raise';
|
|
789
1078
|
if (stage.shuffleReadBytes / tasks <= IDEAL_BYTES_PER_PARTITION_TASK) partitions = 'sufficient';
|
|
790
|
-
else if (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return { partitions, count, needed };
|
|
1079
|
+
else if (relation === 'coalesced') partitions = 'aqeCoalesced';
|
|
1080
|
+
else if (count != null && count >= needed && relation !== 'adjusted') partitions = 'ownPartitioning';
|
|
1081
|
+
return { partitions, count, key, needed };
|
|
794
1082
|
}
|
|
795
1083
|
|
|
796
1084
|
const ADVISORY_PARTITION_SIZE_KEY = 'spark.sql.adaptive.advisoryPartitionSizeInBytes';
|
|
1085
|
+
const PARALLELISM_FIRST_KEY = 'spark.sql.adaptive.coalescePartitions.parallelismFirst';
|
|
1086
|
+
|
|
1087
|
+
// What sized a shuffle-reading stage's tasks, for advice that moves the partition count: the
|
|
1088
|
+
// countRelation outcomes as levers ('matches' is 'property'; 'adjusted' is 'aqeAdjusted', where the
|
|
1089
|
+
// advice stays on the property). Under 'aqeCoalesced' the property does not set the count: AQE
|
|
1090
|
+
// sizes the merged tasks from the advisory size and, with parallelismFirst (Spark's default), from
|
|
1091
|
+
// defaultParallelism too (ShufflePartitionsUtil.coalescePartitions and CoalesceShufflePartitions
|
|
1092
|
+
// at v3.5.0).
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
function partitionLever(stage , ctx ) {
|
|
1096
|
+
const basis = partitionBasis(stage, ctx);
|
|
1097
|
+
const relation = countRelation(stage, basis);
|
|
1098
|
+
const lever = relation === 'matches' ? 'property'
|
|
1099
|
+
: relation === 'coalesced' ? 'aqeCoalesced'
|
|
1100
|
+
: relation === 'adjusted' ? 'aqeAdjusted' : 'ownPartitioning';
|
|
1101
|
+
return { lever, count: basis.count, key: basis.key, explicitRepartition: basis.explicitRepartition };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// What follows advice on the property when the stage ran more tasks than configured under AQE.
|
|
1105
|
+
function aqeAdjustedNote(stage , count , explicitRepartition ) {
|
|
1106
|
+
return explicitRepartition
|
|
1107
|
+
? ` (this stage ran ${stage.taskCount} tasks against ${count} configured, and AQE skew-join splits add tasks, so that is not necessarily its own repartition(n))`
|
|
1108
|
+
: ` (this stage ran ${stage.taskCount} tasks against ${count} configured, and the plan shows no repartition behind the difference)`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Whether parallelismFirst is on for the run: logged, else Spark's default from 3.2.0, where it
|
|
1112
|
+
// exists. A log that records no Spark version is read as current.
|
|
1113
|
+
function parallelismFirstOn(app ) {
|
|
1114
|
+
const effective = effectiveConf(app, PARALLELISM_FIRST_KEY);
|
|
1115
|
+
if (effective != null) return effective.toLowerCase() === 'true';
|
|
1116
|
+
const version = /^(\d+)\.(\d+)/.exec(app?.sparkVersion ?? '');
|
|
1117
|
+
return version == null || Number(version[1]) > 3 || Number(version[2]) >= 2;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// The conf that makes AQE's merged tasks larger. With parallelismFirst on, AQE targets
|
|
1121
|
+
// min(total / defaultParallelism, advisory size), so the advisory size alone merges nothing for
|
|
1122
|
+
// small tasks: turn parallelismFirst off first. With it off, the advisory size is the lever.
|
|
1123
|
+
function aqeCoalesceLargerFix(app ) {
|
|
1124
|
+
return parallelismFirstOn(app)
|
|
1125
|
+
? { keys: `setting ${PARALLELISM_FIRST_KEY}=false (so AQE targets the advisory size, not defaultParallelism tasks) and then raising ${ADVISORY_PARTITION_SIZE_KEY}`, remediation: [setConf(PARALLELISM_FIRST_KEY, false), increaseConf(ADVISORY_PARTITION_SIZE_KEY)] }
|
|
1126
|
+
: { keys: `raising ${ADVISORY_PARTITION_SIZE_KEY}`, remediation: [increaseConf(ADVISORY_PARTITION_SIZE_KEY)] };
|
|
1127
|
+
}
|
|
797
1128
|
|
|
798
1129
|
// lowShuffleParallelism's fix. Unlike the shuffle finding it fires on stages whose tasks are larger
|
|
799
1130
|
// than the ideal size, so its cases are 'raise', 'aqeCoalesced' and 'ownPartitioning'.
|
|
800
|
-
function lowShuffleParallelismFix(stage ,
|
|
801
|
-
const { partitions, count, needed } = shufflePartitionCase(stage,
|
|
1131
|
+
function lowShuffleParallelismFix(stage , ctx ) {
|
|
1132
|
+
const { partitions, count, key, needed } = shufflePartitionCase(stage, ctx);
|
|
802
1133
|
if (partitions === 'aqeCoalesced') {
|
|
803
1134
|
return {
|
|
804
1135
|
partitions,
|
|
@@ -809,36 +1140,104 @@ function lowShuffleParallelismFix(stage , app )
|
|
|
809
1140
|
if (partitions === 'ownPartitioning') {
|
|
810
1141
|
return {
|
|
811
1142
|
partitions,
|
|
812
|
-
text:
|
|
1143
|
+
text: `${key} is already ${count}, so raise this stage's own partition count (its repartition(n) or RDD parallelism) so each partition is smaller`,
|
|
813
1144
|
remediation: [],
|
|
814
1145
|
};
|
|
815
1146
|
}
|
|
816
1147
|
return {
|
|
817
1148
|
partitions,
|
|
818
|
-
text:
|
|
819
|
-
remediation: [increaseConf(
|
|
1149
|
+
text: `raise ${key} so each partition is smaller`,
|
|
1150
|
+
remediation: [increaseConf(key, count == null ? null : needed)],
|
|
820
1151
|
};
|
|
821
1152
|
}
|
|
822
1153
|
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1154
|
+
// tinyTask's fix on a stage that reads a shuffle: fewer, larger tasks, through whatever sized this
|
|
1155
|
+
// stage. Lowering spark.sql.shuffle.partitions merges nothing AQE kept for parallelism, and does
|
|
1156
|
+
// not touch a repartition(n).
|
|
1157
|
+
function tinyShuffleFix(stage , ctx , coalesceTo ) {
|
|
1158
|
+
const { lever, count, key, explicitRepartition } = partitionLever(stage, ctx);
|
|
1159
|
+
if (lever === 'aqeCoalesced') {
|
|
1160
|
+
const larger = aqeCoalesceLargerFix(stageApp(ctx, stage));
|
|
1161
|
+
return {
|
|
1162
|
+
text: `${larger.keys}, or using .coalesce(${coalesceTo}): AQE already coalesced the ${count} configured shuffle partitions into ${stage.taskCount} tasks, so lowering the shuffle partition count will not merge them`,
|
|
1163
|
+
remediation: larger.remediation,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
if (lever === 'ownPartitioning') {
|
|
1167
|
+
const hint = `lowering the repartition(n) or RDD partition count that sized this stage, or using .coalesce(${coalesceTo})`;
|
|
1168
|
+
return { text: `${hint} (the configured shuffle partition count is ${count} but this stage ran ${stage.taskCount} tasks)`, remediation: [codeFix(hint)] };
|
|
1169
|
+
}
|
|
1170
|
+
const note = lever === 'aqeAdjusted' ? aqeAdjustedNote(stage, count, explicitRepartition) : '';
|
|
1171
|
+
return { text: `lowering ${key} or using .coalesce(${coalesceTo})${note}`, remediation: [decreaseConf(key)] };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// spill's fix on a stage that reads a shuffle and spills for volume: smaller partitions or more
|
|
1175
|
+
// memory. Raising spark.sql.shuffle.partitions only helps while the property sized the stage.
|
|
1176
|
+
function spillShuffleFix(stage , ctx ) {
|
|
1177
|
+
const memory = increaseConf('spark.executor.memory');
|
|
1178
|
+
const { lever, count, key, explicitRepartition } = partitionLever(stage, ctx);
|
|
1179
|
+
if (lever === 'aqeCoalesced') {
|
|
1180
|
+
return {
|
|
1181
|
+
text: `AQE coalesced the ${count} configured shuffle partitions into ${stage.taskCount} tasks: lower ${ADVISORY_PARTITION_SIZE_KEY} so each partition is smaller, or increase executor memory`,
|
|
1182
|
+
remediation: [decreaseConf(ADVISORY_PARTITION_SIZE_KEY), memory],
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (lever === 'ownPartitioning') {
|
|
1186
|
+
const hint = "raise this stage's own partition count (its repartition(n) or RDD parallelism) so each partition is smaller";
|
|
1187
|
+
return { text: `the configured shuffle partition count is ${count} but this stage ran ${stage.taskCount} tasks, so ${hint}, or increase executor memory`, remediation: [codeFix(hint), memory] };
|
|
1188
|
+
}
|
|
1189
|
+
const note = lever === 'aqeAdjusted' ? aqeAdjustedNote(stage, count, explicitRepartition) : '';
|
|
1190
|
+
return { text: `raise ${key} or increase executor memory${note}`, remediation: [increaseConf(key), memory] };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// stageSlowness's fix on a stage that reads a shuffle: more tasks. spark.default.parallelism sizes
|
|
1194
|
+
// RDD shuffles only (the DataFrame shuffle reads spark.sql.shuffle.partitions), so it is not offered.
|
|
1195
|
+
function slowShuffleFix(stage , ctx ) {
|
|
1196
|
+
const { lever, count, key, explicitRepartition } = partitionLever(stage, ctx);
|
|
1197
|
+
if (lever === 'aqeCoalesced') {
|
|
1198
|
+
return {
|
|
1199
|
+
text: `AQE coalesced the ${count} configured shuffle partitions into ${stage.taskCount} tasks, so lower ${ADVISORY_PARTITION_SIZE_KEY} to get more of them`,
|
|
1200
|
+
remediation: [decreaseConf(ADVISORY_PARTITION_SIZE_KEY)],
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
if (lever === 'ownPartitioning') {
|
|
1204
|
+
const hint = "raise this stage's own partition count (its repartition(n) or RDD parallelism)";
|
|
1205
|
+
return { text: `the configured shuffle partition count is ${count} but this stage ran ${stage.taskCount} tasks, so ${hint}`, remediation: [codeFix(hint)] };
|
|
1206
|
+
}
|
|
1207
|
+
const note = lever === 'aqeAdjusted' ? aqeAdjustedNote(stage, count, explicitRepartition) : '';
|
|
1208
|
+
return { text: `raise ${key}${note}`, remediation: [increaseConf(key)] };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Spark's default spark.dynamicAllocation.maxExecutors: no upper bound.
|
|
1212
|
+
const UNBOUNDED_EXECUTORS = 2147483647;
|
|
1213
|
+
|
|
1214
|
+
const BROADCAST_THRESHOLD_KEY = 'spark.sql.autoBroadcastJoinThreshold';
|
|
1215
|
+
const ADAPTIVE_BROADCAST_THRESHOLD_KEY = 'spark.sql.adaptive.autoBroadcastJoinThreshold';
|
|
1216
|
+
// AQE keeps a join side out of a broadcast when too few of its shuffle partitions are non-empty
|
|
1217
|
+
// (DynamicJoinSelection at v3.5.0).
|
|
1218
|
+
const NON_EMPTY_PARTITION_RATIO_KEY = 'spark.sql.adaptive.nonEmptyPartitionRatioForBroadcastJoin';
|
|
1219
|
+
|
|
1220
|
+
// The property that bounds a broadcast: with AQE, runtime sizes are judged against the adaptive
|
|
1221
|
+
// threshold when it is set (JoinSelectionHelper.canBroadcastBySize, apache/spark v3.5.0), and
|
|
1222
|
+
// against spark.sql.autoBroadcastJoinThreshold otherwise.
|
|
1223
|
+
function broadcastThresholdKey(app , aqe ) {
|
|
1224
|
+
return aqe && effectiveConf(app, ADAPTIVE_BROADCAST_THRESHOLD_KEY) !== undefined ? ADAPTIVE_BROADCAST_THRESHOLD_KEY : BROADCAST_THRESHOLD_KEY;
|
|
829
1225
|
}
|
|
830
1226
|
|
|
831
|
-
// The run's effective
|
|
832
|
-
// negative when auto-broadcast is disabled, null when the
|
|
833
|
-
|
|
834
|
-
|
|
1227
|
+
// The run's effective value of a broadcast threshold property in bytes (logged, else Spark's 10 MiB
|
|
1228
|
+
// default for the static one); negative when auto-broadcast is disabled, null when the value is
|
|
1229
|
+
// not a size or the property has none.
|
|
1230
|
+
function effectiveBroadcastThreshold(app , key ) {
|
|
1231
|
+
const raw = effectiveConf(app, key);
|
|
835
1232
|
if (raw != null && /^-\d+$/.test(raw)) return -1;
|
|
836
1233
|
return parseSparkBytes(raw);
|
|
837
1234
|
}
|
|
838
1235
|
|
|
839
|
-
// No dynamic-allocation property has an effect on a run
|
|
1236
|
+
// No dynamic-allocation property has an effect on a run that has it off: Spark's default is off, so
|
|
1237
|
+
// an unset property counts as off, as it does for idleCapacityFix.
|
|
840
1238
|
function dynamicAllocationOff(app ) {
|
|
841
|
-
|
|
1239
|
+
if (app?.resources?.dynamicAllocationEnabled != null) return !app.resources.dynamicAllocationEnabled;
|
|
1240
|
+
return !loggedAs(app, 'spark.dynamicAllocation.enabled', true);
|
|
842
1241
|
}
|
|
843
1242
|
|
|
844
1243
|
// Shared by cacheUtilization's two variants, worded per storage source: neither is a runtime
|
|
@@ -1090,8 +1489,7 @@ function gcConfidence(
|
|
|
1090
1489
|
// warnFloor gates the finding, so a value just past it is the weakest evidence this detector can
|
|
1091
1490
|
// produce. highFloor is critPct: straggler's own second (speculative-share) tier, 2x warnPct
|
|
1092
1491
|
// (0.10 -> 0.20); reused as the high-confidence bar for the stragglerShare path too since that
|
|
1093
|
-
// metric has no
|
|
1094
|
-
// on the straggler detector) but is the same 0-1 task-share magnitude.
|
|
1492
|
+
// metric has no critical tier of its own but is the same 0-1 task-share magnitude.
|
|
1095
1493
|
function stragglerConfidence(shareValue , warnFloor , highFloor ) {
|
|
1096
1494
|
if (shareValue < warnFloor * 1.5) return 'low';
|
|
1097
1495
|
if (shareValue >= highFloor) return 'high';
|
|
@@ -1162,29 +1560,156 @@ function gcValidation(minRunTimeMs ) {
|
|
|
1162
1560
|
|
|
1163
1561
|
const INCOMPLETE_RUN_RECOMMENDATION = 'This event log never recorded an ApplicationEnd event: the capture stopped before the run finished (a job still running, a rotated log, or a cut-short capture), so every figure on this board covers only what was captured.';
|
|
1164
1562
|
|
|
1563
|
+
// straggler's own gate: tasks over 4x P50 are more than `shareWarn` of the stage (`shareWarnAtFloor`
|
|
1564
|
+
// when the occupancy-clipped tail clears the runtime floor), or a speculative task ran. Null when it
|
|
1565
|
+
// does not hold, else what the finding reports.
|
|
1566
|
+
function stragglerGate(stage , ctx , thresholds
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
) {
|
|
1570
|
+
if (stage.taskCount < thresholds.minTasks) return null;
|
|
1571
|
+
const runMs = appDurationMs(ctx.app);
|
|
1572
|
+
if (stageBelowRuntimeFloor(stage, ctx, thresholds.floorPctWarn)) return null;
|
|
1573
|
+
const stragglerShare = (stage.stragglerCount ?? 0) / stage.taskCount;
|
|
1574
|
+
const useSpeculative = (stage.speculativeTasks ?? 0) > 0;
|
|
1575
|
+
if (!useSpeculative && stragglerShare <= thresholds.shareWarnAtFloor) return null;
|
|
1576
|
+
const speculativeShare = useSpeculative ? stage.speculativeTasks / stage.taskCount : 0;
|
|
1577
|
+
// The claim estimate() reports as savings: a high straggler/speculative share on a stage
|
|
1578
|
+
// whose tasks barely vary models near-zero savings, so it must not outrank 'info'.
|
|
1579
|
+
const floorWasteMs = tailClaimFloorMs(stragglerTailClaim(stage), stage.id, ctx);
|
|
1580
|
+
const meetsWarnFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctWarn);
|
|
1581
|
+
const meetsCritFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctCrit);
|
|
1582
|
+
// The lower gate needs positive evidence the tail matters: meetsRuntimeFloor passes by
|
|
1583
|
+
// default when the app's duration is unknown (an incomplete run), which isn't that.
|
|
1584
|
+
const stragglerShareFires = stragglerShare > thresholds.shareWarn
|
|
1585
|
+
|| (stragglerShare > thresholds.shareWarnAtFloor && runMs != null && meetsWarnFloor);
|
|
1586
|
+
if (!useSpeculative && !stragglerShareFires) return null;
|
|
1587
|
+
const speculativeTier = speculativeShare >= thresholds.critPct && meetsCritFloor ? 'critical'
|
|
1588
|
+
: speculativeShare >= thresholds.warnPct && meetsWarnFloor ? 'warning' : 'info';
|
|
1589
|
+
// A straggler share has only a warning tier (threshold-tables.md, Straggler row).
|
|
1590
|
+
const stragglerTier = stragglerShareFires && meetsWarnFloor ? 'warning' : 'info';
|
|
1591
|
+
// Report whichever signal actually drove the finding, not just whether speculation was on:
|
|
1592
|
+
// a high stragglerShare with few speculative retries must not be reported as a low-value
|
|
1593
|
+
// speculativeTasks count. Ties keep the speculative-driven default.
|
|
1594
|
+
const useSpeculativeMetric = useSpeculative && !(IMPACT_BAND_ORDER[stragglerTier] < IMPACT_BAND_ORDER[speculativeTier]);
|
|
1595
|
+
const value = useSpeculativeMetric ? stage.speculativeTasks : Math.round(stragglerShare * 100);
|
|
1596
|
+
return {
|
|
1597
|
+
useSpeculativeMetric, value,
|
|
1598
|
+
detail: useSpeculativeMetric
|
|
1599
|
+
? `${value} speculative attempt${value === 1 ? '' : 's'} discarded${speculationRuleNote(ctx.app)}`
|
|
1600
|
+
: `${value}% of tasks ran over ${STRAGGLER_FACTOR}× the median`,
|
|
1601
|
+
confidence: useSpeculativeMetric
|
|
1602
|
+
? stragglerConfidence(speculativeShare, thresholds.warnPct, thresholds.critPct)
|
|
1603
|
+
: stragglerConfidence(stragglerShare, thresholds.shareWarn, thresholds.critPct),
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// The straggler finding's evidence, recommendation and fix for the tail's cause. Without a measured
|
|
1608
|
+
// cause there is nothing to fix yet, only the causes to check.
|
|
1609
|
+
function stragglerAdvice(stage , ctx , tail , detail )
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
{
|
|
1614
|
+
const a = tail.attribution;
|
|
1615
|
+
const cpuPct = a?.cpuPct != null ? Math.round(a.cpuPct) : undefined;
|
|
1616
|
+
const evidence = { cause: tail.cause, ...(tail.cause === 'unattributed' ? {} : { causeSharePct: tail.sharePct }), ...(cpuPct !== undefined ? { cpuPct } : {}) };
|
|
1617
|
+
const share = `${tail.sharePct}% of the slow tasks' extra time`;
|
|
1618
|
+
switch (tail.cause) {
|
|
1619
|
+
case 'data': {
|
|
1620
|
+
// skew reports a tail its gate admits; this is one only the straggler gate does.
|
|
1621
|
+
const fix = skewFix(stage, ctx, false, tail);
|
|
1622
|
+
const ratio = a?.dataRatio != null ? Math.round(a.dataRatio * 10) / 10 : null;
|
|
1623
|
+
return {
|
|
1624
|
+
evidence: { ...evidence, origin: fix.origin, aqeSkew: fix.aqeSkew }, remediation: fix.remediation,
|
|
1625
|
+
recommendation: `${detail}: data volume accounts for ${share} (${ratio !== null ? `the slow tasks read a median ${ratio}× the data of the median task` : 'the median task read almost nothing'}): ${fix.text}.`,
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
case 'gc':
|
|
1629
|
+
return {
|
|
1630
|
+
evidence, remediation: [increaseConf('spark.executor.memory')],
|
|
1631
|
+
recommendation: `${detail}: GC accounts for ${share}: reduce object creation, use primitive types, avoid UDFs, increase executor memory.`,
|
|
1632
|
+
};
|
|
1633
|
+
case 'fetchWait':
|
|
1634
|
+
return {
|
|
1635
|
+
evidence, remediation: undefined,
|
|
1636
|
+
recommendation: `${detail}: waiting on shuffle fetches accounts for ${share}: look for a slow or overloaded node serving shuffle blocks, executors lost mid-stage, or reducers fetching many small blocks.`,
|
|
1637
|
+
};
|
|
1638
|
+
case 'host': {
|
|
1639
|
+
const speculation = switchFix(loggedAs(ctx.app, 'spark.speculation', true), 'spark.speculation', true,
|
|
1640
|
+
'check what it was running, and consider enabling spark.speculation to relaunch a lagging task automatically',
|
|
1641
|
+
'check what it was running; speculation is already on, so a lagging task there is already relaunched');
|
|
1642
|
+
return {
|
|
1643
|
+
evidence: { ...evidence, host: a .host , hostTasks: a .hostTasks }, remediation: speculation.remediation,
|
|
1644
|
+
recommendation: `${detail}: ${a .hostTasks} of the ${a .tasks} slow tasks ran on ${a .host}, which accounts for ${share}: ${speculation.text}.`,
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
case 'unexplained': {
|
|
1648
|
+
const cpu = cpuPct === undefined ? '' : cpuPct < 50
|
|
1649
|
+
? ` They used ${cpuPct}% of their run time on CPU, so they mostly waited (on storage, a remote service or a lock).`
|
|
1650
|
+
: ` They used ${cpuPct}% of their run time on CPU, so the work itself is slow.`;
|
|
1651
|
+
// A tail that read clearly more data is not "no more data" even when run time did not scale
|
|
1652
|
+
// with it; where data is the largest share anyway, the data advice stays.
|
|
1653
|
+
const dataRatio = a?.dataRatio ?? 0;
|
|
1654
|
+
if (a != null && dataRatio >= MORE_DATA_RATIO) {
|
|
1655
|
+
const rounded = Math.round(dataRatio * 10) / 10;
|
|
1656
|
+
const dataShare = Math.round((a.dataMs / a.excessMs) * 100);
|
|
1657
|
+
const dataLargest = a.dataMs >= a.excessMs - a.dataMs - a.gcMs - a.fetchWaitMs - a.hostMs;
|
|
1658
|
+
const lead = `${detail}: the slow tasks read a median ${rounded}× the data of the median task, and data volume accounts for ${dataShare}% of their extra time`;
|
|
1659
|
+
if (dataLargest) {
|
|
1660
|
+
const fix = skewFix(stage, ctx, false, tail);
|
|
1661
|
+
return {
|
|
1662
|
+
evidence: { ...evidence, origin: fix.origin, aqeSkew: fix.aqeSkew }, remediation: fix.remediation,
|
|
1663
|
+
recommendation: `${lead}, more than anything else the log attributes (not a majority): ${fix.text}.`,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
return {
|
|
1667
|
+
evidence, remediation: undefined,
|
|
1668
|
+
recommendation: `${lead}, so run time did not scale with it, and GC, shuffle fetch wait and one slow host do not account for the rest.${cpu} Look at per-record cost (UDFs, regular expressions, a call out per row) as well as the data they read.`,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
return {
|
|
1672
|
+
evidence, remediation: undefined,
|
|
1673
|
+
recommendation: `${detail}: the slow tasks read no more data than the median task, and GC, shuffle fetch wait and one slow host do not account for their time.${cpu} Look at per-record cost (UDFs, regular expressions, a call out per row).`,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
default:
|
|
1677
|
+
return {
|
|
1678
|
+
evidence, remediation: undefined,
|
|
1679
|
+
recommendation: `${detail}: nothing in the log attributes the slow tasks to data volume, GC, shuffle fetch wait or one host, so check their input sizes, GC time and hosts before choosing a fix.`,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1165
1684
|
export const DETECTORS = [
|
|
1166
1685
|
defineStageDetector({
|
|
1167
1686
|
type: 'skew', order: 30, fixEffort: 'code', version: 1,
|
|
1168
1687
|
emits: ['skew'],
|
|
1169
1688
|
docAnchor: '#bottleneck-skew',
|
|
1170
|
-
|
|
1689
|
+
// dataShareMin: the share of the slow tasks' extra time that run time proportional to their data
|
|
1690
|
+
// volume must account for before the tail is data skew; straggler judges a tail with it too.
|
|
1691
|
+
thresholds: { ratioWarn: 3, minTasksForP95: 20, dataShareMin: 0.5, floorPctWarn: IMPACT_FLOOR_PCT_WARN },
|
|
1171
1692
|
detect(stage, ctx, thresholds) {
|
|
1172
|
-
const
|
|
1173
|
-
if (
|
|
1174
|
-
const { ratio, metric } =
|
|
1175
|
-
|
|
1176
|
-
//
|
|
1177
|
-
const
|
|
1178
|
-
if (
|
|
1693
|
+
const gate = skewGate(stage, ctx, thresholds.ratioWarn, thresholds.minTasksForP95, thresholds.floorPctWarn);
|
|
1694
|
+
if (gate === null) return null;
|
|
1695
|
+
const { ratio, metric } = gate;
|
|
1696
|
+
// A tail data does not explain, or one with no measured cause, is not skew: straggler reports
|
|
1697
|
+
// it, with the cause (it re-runs this same gate, see skewGate).
|
|
1698
|
+
const tail = tailVerdict(stage, thresholds.dataShareMin);
|
|
1699
|
+
if (tail.cause !== 'data') return null;
|
|
1179
1700
|
const value = Math.round(ratio * 10) / 10;
|
|
1180
|
-
const fix = skewFix(stage, ctx);
|
|
1701
|
+
const fix = skewFix(stage, ctx, false, tail);
|
|
1702
|
+
const dataRatio = tail.attribution?.dataRatio != null
|
|
1703
|
+
? Math.round(tail.attribution.dataRatio * 10) / 10 : undefined;
|
|
1181
1704
|
return {
|
|
1182
|
-
type: 'skew', stageId: stage.id, origin: fix.origin,
|
|
1705
|
+
type: 'skew', stageId: stage.id, origin: fix.origin, aqeSkew: fix.aqeSkew,
|
|
1706
|
+
cause: tail.cause,
|
|
1707
|
+
...(dataRatio !== undefined ? { dataRatio } : {}),
|
|
1183
1708
|
impactBand: 'warning',
|
|
1184
1709
|
metric, value,
|
|
1185
1710
|
confidence: skewConfidence(ratio, thresholds.ratioWarn),
|
|
1186
1711
|
validationRequired: `Flagged only when it costs at least ${shareLabel(thresholds.floorPctWarn)} of run time.`,
|
|
1187
|
-
recommendation: `Task duration ratio (${metric}) is ${value}
|
|
1712
|
+
recommendation: `Task duration ratio (${metric}) is ${value}×${dataRatio !== undefined ? `, and the slow tasks read a median ${dataRatio}× the data of the median task` : ', and the median task read almost nothing'}: ${fix.text}.`,
|
|
1188
1713
|
remediation: fix.remediation,
|
|
1189
1714
|
};
|
|
1190
1715
|
},
|
|
@@ -1201,8 +1726,7 @@ export const DETECTORS = [
|
|
|
1201
1726
|
emits: ['stageShape'],
|
|
1202
1727
|
docAnchor: '#bottleneck-stage-shape',
|
|
1203
1728
|
// lowParallelismFloorPct: the same 0.5% runtime floor the tiered detectors use. Parallelizing a
|
|
1204
|
-
// stage can't save more than the stage's own duration, so a shorter stage can't clear it
|
|
1205
|
-
// the 14 real logs that was 2839 of 3005 lowParallelism findings (2168 on sub-second stages).
|
|
1729
|
+
// stage can't save more than the stage's own duration, so a shorter stage can't clear it.
|
|
1206
1730
|
// App-wide idle capacity stays covered by utilization.
|
|
1207
1731
|
// taskStageSkew: stageShareMin is the share of the stage's wall-clock the longest task must
|
|
1208
1732
|
// span, and skewWarn how far past the median task it must run (skew's own max/median 3×).
|
|
@@ -1284,10 +1808,13 @@ export const DETECTORS = [
|
|
|
1284
1808
|
// Cores idle during the straggler's tail, at achieved concurrency (not full cluster
|
|
1285
1809
|
// capacity, which is lowParallelism's territory): resourceOnly, since skew and straggler
|
|
1286
1810
|
// already claim that tail's wall-clock time.
|
|
1287
|
-
const
|
|
1811
|
+
const tailIdleCoreMs =
|
|
1288
1812
|
Math.max(0, Math.min(totalCores, taskCount) - 1) *
|
|
1289
1813
|
Math.max(0, (stage.taskDurationMax ?? 0) - (stage.taskDurationP50 ?? 0));
|
|
1290
|
-
|
|
1814
|
+
// An upper bound that assumes every executor stays allocated through the tail, so it is
|
|
1815
|
+
// modeled; one stage cannot idle more than the whole run did, so it is clipped to that.
|
|
1816
|
+
const idleCoreMs = ctx.runIdleCoreMs == null ? tailIdleCoreMs : Math.min(tailIdleCoreMs, ctx.runIdleCoreMs);
|
|
1817
|
+
return costOnly('modeled', { value: idleCoreMs, unit: 'coreMs', idle: true });
|
|
1291
1818
|
}
|
|
1292
1819
|
return null;
|
|
1293
1820
|
},
|
|
@@ -1297,22 +1824,21 @@ export const DETECTORS = [
|
|
|
1297
1824
|
emits: ['shuffle'],
|
|
1298
1825
|
docAnchor: '#bottleneck-shuffle',
|
|
1299
1826
|
// stageFloorPct: the 0.5% runtime floor. The shuffle claim is clipped to the stage, so on a
|
|
1300
|
-
// shorter stage it
|
|
1301
|
-
// is still there on those stages; the floor is why they're dropped.
|
|
1827
|
+
// shorter stage it would grade info; the shuffle is still there, the floor drops the finding.
|
|
1302
1828
|
thresholds: { minBytes: 50 * MB, stageFloorPct: 0.005 },
|
|
1303
1829
|
detect(stage, ctx, thresholds) {
|
|
1304
1830
|
const bytes = stage.shuffleReadBytes;
|
|
1305
1831
|
if (bytes <= thresholds.minBytes) return null;
|
|
1306
1832
|
if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
|
|
1307
|
-
const { partitions, count } = shufflePartitionCase(stage, ctx
|
|
1833
|
+
const { partitions, count, key } = shufflePartitionCase(stage, ctx);
|
|
1308
1834
|
const perTask = formatBytes(bytes / Math.max(1, stage.taskCount));
|
|
1309
1835
|
const fix = partitions === 'sufficient'
|
|
1310
1836
|
? { text: `its ${stage.taskCount} tasks already read about ${perTask} each, so more partitions will not help: consider a broadcast join for the smaller side`, remediation: [] }
|
|
1311
1837
|
: partitions === 'aqeCoalesced'
|
|
1312
1838
|
? { text: `AQE coalesced it into ${stage.taskCount} tasks (from ${count} configured partitions): consider lowering ${ADVISORY_PARTITION_SIZE_KEY} or adding a broadcast join`, remediation: [decreaseConf(ADVISORY_PARTITION_SIZE_KEY)] }
|
|
1313
1839
|
: partitions === 'ownPartitioning'
|
|
1314
|
-
? { text:
|
|
1315
|
-
: { text:
|
|
1840
|
+
? { text: `${key} is already ${count}, so consider raising this stage's own partition count (its repartition(n) or RDD parallelism) or adding a broadcast join`, remediation: [] }
|
|
1841
|
+
: { text: `consider increasing ${key} or adding a broadcast join`, remediation: [increaseConf(key)] };
|
|
1316
1842
|
return {
|
|
1317
1843
|
type: 'shuffle', stageId: stage.id,
|
|
1318
1844
|
impactBand: 'info', partitions,
|
|
@@ -1332,8 +1858,11 @@ export const DETECTORS = [
|
|
|
1332
1858
|
const measuredMs = fetchWaitWallClockMs(stage);
|
|
1333
1859
|
const wasteMs = measuredMs == null ? modeledMs : Math.min(modeledMs, measuredMs);
|
|
1334
1860
|
// rawWaste: the measured byte volume behind the modeled figure.
|
|
1335
|
-
|
|
1336
|
-
|
|
1861
|
+
const capped = measuredMs != null && measuredMs < modeledMs;
|
|
1862
|
+
return withAssumptions(
|
|
1863
|
+
singleStageImpact(wasteMs, finding.stageId, ctx, capped ? 'measured' : 'modeled', { value: shuffleReadBytes, unit: 'bytes' }),
|
|
1864
|
+
`${ioModelAssumption(SHUFFLE_THROUGHPUT_BPS, stage)}; counts network transfer only${capped ? ', capped at the fetch wait the tasks measured' : ''}`,
|
|
1865
|
+
);
|
|
1337
1866
|
},
|
|
1338
1867
|
}),
|
|
1339
1868
|
defineStageDetector({
|
|
@@ -1353,13 +1882,13 @@ export const DETECTORS = [
|
|
|
1353
1882
|
const fix = skewFix(stage, ctx, true);
|
|
1354
1883
|
out.push({
|
|
1355
1884
|
type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
|
|
1356
|
-
rule: 'shufflePartitionSkew', origin: fix.origin, metric: 'shuffleReadMax', value: max,
|
|
1885
|
+
rule: 'shufflePartitionSkew', origin: fix.origin, aqeSkew: fix.aqeSkew, metric: 'shuffleReadMax', value: max,
|
|
1357
1886
|
recommendation: `The largest shuffle partition (${formatBytes(max)}) is ${ratioText}: ${fix.text}.`,
|
|
1358
1887
|
remediation: fix.remediation,
|
|
1359
1888
|
});
|
|
1360
1889
|
}
|
|
1361
1890
|
if (total >= thresholds.lowParTotalBytes && taskCount <= thresholds.lowParMaxTasks) {
|
|
1362
|
-
const fix = lowShuffleParallelismFix(stage, ctx
|
|
1891
|
+
const fix = lowShuffleParallelismFix(stage, ctx);
|
|
1363
1892
|
out.push({
|
|
1364
1893
|
type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
|
|
1365
1894
|
rule: 'lowShuffleParallelism', partitions: fix.partitions, metric: 'taskCount', value: taskCount,
|
|
@@ -1385,6 +1914,7 @@ export const DETECTORS = [
|
|
|
1385
1914
|
const stage = ctx.stages.get(finding.stageId);
|
|
1386
1915
|
if (!stage) return null;
|
|
1387
1916
|
let wasteMs = 0;
|
|
1917
|
+
let longestTaskAfterFixMs = 0;
|
|
1388
1918
|
if (finding.rule === 'maxPartitionTooBig') {
|
|
1389
1919
|
wasteMs = ((stage.shuffleReadMax ?? 0) / SHUFFLE_THROUGHPUT_BPS) * 1000;
|
|
1390
1920
|
} else if (finding.rule === 'shufflePartitionSkew') {
|
|
@@ -1400,19 +1930,28 @@ export const DETECTORS = [
|
|
|
1400
1930
|
// work, not the scheduling cost of tasks you'd add (adding tasks incurs overhead, recovers
|
|
1401
1931
|
// nothing). Model the achievable duration at target parallelism by scaling down proportionally.
|
|
1402
1932
|
wasteMs = stageDurationMs * (1 - taskCount / targetTaskCount);
|
|
1933
|
+
// The fix splits the long tasks, so the longest task is the quantity it shortens: the
|
|
1934
|
+
// occupancy clip must not floor the claim at it (TAIL_CLAIM). An even split leaves the
|
|
1935
|
+
// longest task at taskCount / targetTaskCount of today's: a modeled figure.
|
|
1936
|
+
const longestTaskMs = stage.taskDurationMax ?? 0;
|
|
1937
|
+
longestTaskAfterFixMs = longestTaskMs * taskCount / targetTaskCount;
|
|
1938
|
+
// Splitting only shortens tasks: the stage still takes the time outside its longest task
|
|
1939
|
+
// plus the longest task the split leaves, so the claim is at most the longest task's own
|
|
1940
|
+
// reduction. Without a task-duration max (older snapshots) the stage-wide scaling stands.
|
|
1941
|
+
if (longestTaskMs > 0) wasteMs = Math.min(wasteMs, longestTaskMs - longestTaskAfterFixMs);
|
|
1403
1942
|
}
|
|
1404
1943
|
} else {
|
|
1405
1944
|
return null;
|
|
1406
1945
|
}
|
|
1407
|
-
return singleStageImpact(wasteMs, finding.stageId, ctx, 'modeled', { value: wasteMs, unit: 'ms' }
|
|
1946
|
+
return singleStageImpact(wasteMs, finding.stageId, ctx, 'modeled', { value: wasteMs, unit: 'ms' },
|
|
1947
|
+
finding.rule === 'lowShuffleParallelism' ? { ...TAIL_CLAIM, longestTaskAfterFixMs } : undefined);
|
|
1408
1948
|
},
|
|
1409
1949
|
}),
|
|
1410
1950
|
defineStageDetector({
|
|
1411
1951
|
type: 'spill', order: 10, fixEffort: 'code', version: 1,
|
|
1412
1952
|
emits: ['spill'],
|
|
1413
1953
|
docAnchor: '#bottleneck-spill',
|
|
1414
|
-
// stageFloorPct: the 0.5% runtime floor, as for shuffle
|
|
1415
|
-
// logs, all info). The spill is still there on those stages; the floor is why they're dropped.
|
|
1954
|
+
// stageFloorPct: the 0.5% runtime floor, as for shuffle.
|
|
1416
1955
|
thresholds: { singleTaskDiskGiB: 1, singleTaskMemGiB: 4, highDiskGiB: 1, highTaskDiskMB: 512, highMemGiB: 4, medDiskMB: 256, medMemGiB: 1, skewRatio: 5, skewDiskFloorMB: 128, skewMemFloorMB: 256, skewMinTasks: 10, stageFloorPct: 0.005 },
|
|
1417
1956
|
detect(stage, ctx, thresholds) {
|
|
1418
1957
|
if (stage.memoryBytesSpilled === 0) return null;
|
|
@@ -1423,7 +1962,7 @@ export const DETECTORS = [
|
|
|
1423
1962
|
const impactBand = 'warning';
|
|
1424
1963
|
// shuffle.partitions sizes a shuffle's reduce side: a stage that reads no shuffle gets memory only.
|
|
1425
1964
|
const reads = stageReads(stage);
|
|
1426
|
-
const
|
|
1965
|
+
const shuffleFix = reads === 'shuffle' ? spillShuffleFix(stage, ctx) : null;
|
|
1427
1966
|
return {
|
|
1428
1967
|
type: 'spill', stageId: stage.id, impactBand, reads,
|
|
1429
1968
|
spillMagnitude: mag?.magnitude,
|
|
@@ -1434,10 +1973,10 @@ export const DETECTORS = [
|
|
|
1434
1973
|
: 'Spill cause could not be classified: inspect per-task spill metrics in the Spark UI before acting.',
|
|
1435
1974
|
recommendation: cls === 'skew'
|
|
1436
1975
|
? `${formatBytes(stage.memoryBytesSpilled)} spilled, skew-driven: fix task skew first; adding memory will not help.`
|
|
1437
|
-
:
|
|
1438
|
-
? `${formatBytes(stage.memoryBytesSpilled)} spilled:
|
|
1976
|
+
: shuffleFix
|
|
1977
|
+
? `${formatBytes(stage.memoryBytesSpilled)} spilled: ${shuffleFix.text}.`
|
|
1439
1978
|
: `${formatBytes(stage.memoryBytesSpilled)} spilled: this stage reads no shuffle, so increase executor memory or process less data per task.`,
|
|
1440
|
-
remediation: cls === 'skew' ? [] :
|
|
1979
|
+
remediation: cls === 'skew' ? [] : shuffleFix?.remediation ?? [increaseConf('spark.executor.memory')],
|
|
1441
1980
|
};
|
|
1442
1981
|
},
|
|
1443
1982
|
estimate(finding, ctx) {
|
|
@@ -1447,7 +1986,10 @@ export const DETECTORS = [
|
|
|
1447
1986
|
const diskBytesSpilled = stage.diskBytesSpilled ?? 0;
|
|
1448
1987
|
const wasteMs = (diskBytesSpilled / (SPILL_IO_THROUGHPUT_BPS * stageIoParallelism(stage))) * 1000;
|
|
1449
1988
|
// Surfaces the number the formula uses: the displayed metric is memoryBytesSpilled, but disk spill costs the I/O time.
|
|
1450
|
-
return
|
|
1989
|
+
return withAssumptions(
|
|
1990
|
+
singleStageImpact(wasteMs, finding.stageId, ctx, 'modeled', { value: diskBytesSpilled, unit: 'bytes' }),
|
|
1991
|
+
`${ioModelAssumption(SPILL_IO_THROUGHPUT_BPS, stage)}; counts disk spill only, not memory spill, serialization or GC`,
|
|
1992
|
+
);
|
|
1451
1993
|
},
|
|
1452
1994
|
}),
|
|
1453
1995
|
defineStageDetector({
|
|
@@ -1456,14 +1998,13 @@ export const DETECTORS = [
|
|
|
1456
1998
|
docAnchor: '#bottleneck-gc',
|
|
1457
1999
|
thresholds: {
|
|
1458
2000
|
warnPct100: 10,
|
|
1459
|
-
//
|
|
2001
|
+
// A GC share below this reads as over-provisioned memory (the low-GC note).
|
|
1460
2002
|
lowInfoPct100: 5,
|
|
1461
|
-
//
|
|
2003
|
+
// Noise floor so a stage that barely ran doesn't flag either direction.
|
|
1462
2004
|
minRunTimeMs: 10000,
|
|
1463
2005
|
// lowInfoFloorPct: the 0.5% runtime floor stageShape's lowParallelism uses. The low-GC note is
|
|
1464
2006
|
// an app-level memory-sizing signal; on a stage shorter than this share of the run it adds
|
|
1465
|
-
// nothing to that call.
|
|
1466
|
-
// findings); the low-GC pattern is still true on those stages, the floor is why they're dropped.
|
|
2007
|
+
// nothing to that call.
|
|
1467
2008
|
lowInfoFloorPct: 0.005,
|
|
1468
2009
|
},
|
|
1469
2010
|
detect(stage, ctx, thresholds) {
|
|
@@ -1480,8 +2021,14 @@ export const DETECTORS = [
|
|
|
1480
2021
|
remediation: [increaseConf('spark.executor.memory')],
|
|
1481
2022
|
};
|
|
1482
2023
|
}
|
|
1483
|
-
// Low-GC (cost) branch: only for stages that ran long enough to be meaningful
|
|
2024
|
+
// Low-GC (cost) branch: only for stages that ran long enough to be meaningful, and only as
|
|
2025
|
+
// the fallback sizing signal: a log with measured executor heap peaks and a known executor
|
|
2026
|
+
// memory gets memoryUtilization's heapOverProvisioned judgement instead (low GC time just
|
|
2027
|
+
// means a low allocation rate).
|
|
1484
2028
|
if ((stage.executorRunTime ?? 0) >= thresholds.minRunTimeMs
|
|
2029
|
+
&& !heapJudgementAvailable(ctx)
|
|
2030
|
+
// A stage that failed, or lost tasks, ran too little work to call its GC low.
|
|
2031
|
+
&& stage.stageFailureReason == null && stage.failedTasks === 0
|
|
1485
2032
|
&& pct < thresholds.lowInfoPct100
|
|
1486
2033
|
&& !stageBelowRuntimeFloor(stage, ctx, thresholds.lowInfoFloorPct)) {
|
|
1487
2034
|
const value = Math.round(pct * 10) / 10;
|
|
@@ -1520,7 +2067,7 @@ export const DETECTORS = [
|
|
|
1520
2067
|
},
|
|
1521
2068
|
}),
|
|
1522
2069
|
defineStageDetector({
|
|
1523
|
-
type: 'slowHost', order: 60, fixEffort: 'config', version:
|
|
2070
|
+
type: 'slowHost', order: 60, fixEffort: 'config', version: 2,
|
|
1524
2071
|
emits: ['slowHost'],
|
|
1525
2072
|
docAnchor: '#bottleneck-slow-host',
|
|
1526
2073
|
thresholds: {
|
|
@@ -1531,11 +2078,15 @@ export const DETECTORS = [
|
|
|
1531
2078
|
floorMs: 1000, floorBytes: 64 * MB,
|
|
1532
2079
|
// stageFloorPct: the tiered detectors' 0.5% runtime floor. On a stage shorter than this share
|
|
1533
2080
|
// of the run a slow host can't cost that much: duration findings are clipped to the stage
|
|
1534
|
-
// and
|
|
1535
|
-
// its band)
|
|
1536
|
-
// 324 of 452 slowHost findings, all info. The imbalance is still there on those stages; the
|
|
1537
|
-
// floor is why they're dropped.
|
|
2081
|
+
// and would grade info, and a byte-dimension imbalance (no time estimate, so its ratio tier
|
|
2082
|
+
// is its band) is graded info there too. So the stage is skipped.
|
|
1538
2083
|
stageFloorPct: 0.005,
|
|
2084
|
+
// outlierAlpha: the nominal chance a stage of alike tasks gets any executor flagged on task
|
|
2085
|
+
// time. The slowest of many executors always tops the median, so a task-time executor is
|
|
2086
|
+
// flagged only when its mean sits further above the median than the stage's own task spread
|
|
2087
|
+
// and its task count allow (chanceOutlierZ), more strictly the more executors there are.
|
|
2088
|
+
// The normal-tail bound behind that cutoff is loose: simulated alike stages flag well under it.
|
|
2089
|
+
outlierAlpha: 0.005,
|
|
1539
2090
|
},
|
|
1540
2091
|
detect(stage, ctx, thresholds) {
|
|
1541
2092
|
const hosts = stage.hostStats ?? [];
|
|
@@ -1543,9 +2094,10 @@ export const DETECTORS = [
|
|
|
1543
2094
|
if ((hosts.length < thresholds.minHosts && execs0.length < thresholds.minHosts) || stage.taskCount < thresholds.minTasks) return null;
|
|
1544
2095
|
if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
|
|
1545
2096
|
const out = [];
|
|
2097
|
+
const rule = speculationRule(ctx.app);
|
|
1546
2098
|
const speculation = switchFix(loggedAs(ctx.app, 'spark.speculation', true), 'spark.speculation', true,
|
|
1547
|
-
|
|
1548
|
-
|
|
2099
|
+
`check what it was running, and consider enabling spark.speculation to relaunch a lagging task automatically${rule ? ` (with this run's settings, ${rule})` : ''}`,
|
|
2100
|
+
`check what it was running; speculation is already on, so a lagging task there is already relaunched${rule ? ` (with this run's settings, ${rule})` : ''}`);
|
|
1549
2101
|
if (hosts.length >= thresholds.minHosts) {
|
|
1550
2102
|
const means = hosts.map(h => ({ host: h.host, taskCount: h.taskCount, mean: h.totalDuration / h.taskCount }));
|
|
1551
2103
|
const sorted = [...means].map(h => h.mean).sort((a, b) => a - b);
|
|
@@ -1591,8 +2143,15 @@ export const DETECTORS = [
|
|
|
1591
2143
|
const impactBandFor = (r ) =>
|
|
1592
2144
|
r >= tiers[3] ? 'critical' : (r >= tiers[1] ? 'warning' : (r >= tiers[0] ? 'info' : null));
|
|
1593
2145
|
const floorMs = thresholds.floorMs, floorBytes = thresholds.floorBytes;
|
|
1594
|
-
const
|
|
1595
|
-
|
|
2146
|
+
const taskTimeSamples = execs.filter(e => e.taskCount > 0).map(e => ({ key: e.executorId, value: e.totalDuration / e.taskCount }));
|
|
2147
|
+
const taskTimeZs = executorDurationZ(execs);
|
|
2148
|
+
const taskTimeZ = chanceOutlierZ(taskTimeSamples.length, thresholds.outlierAlpha);
|
|
2149
|
+
const dims = [
|
|
2150
|
+
{
|
|
2151
|
+
dimension: 'taskTime', floor: floorMs, samples: taskTimeSamples,
|
|
2152
|
+
// Only an executor the stage's own spread can't explain: with no known spread, none.
|
|
2153
|
+
qualifies: sample => (taskTimeZs?.get(sample.key) ?? -Infinity) >= taskTimeZ,
|
|
2154
|
+
},
|
|
1596
2155
|
{ dimension: 'inputBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: e.inputBytes ?? 0 })) },
|
|
1597
2156
|
{ dimension: 'shuffleBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: (e.shuffleReadBytes ?? 0) + (e.shuffleWriteBytes ?? 0) })) },
|
|
1598
2157
|
];
|
|
@@ -1602,14 +2161,13 @@ export const DETECTORS = [
|
|
|
1602
2161
|
dims.push({ dimension: 'storageMemory', floor: floorBytes, samples: [...em.entries()].map(([id, m]) => ({ key: id, value: (m.onHeapStorageMemory ?? 0) + (m.offHeapStorageMemory ?? 0) })) });
|
|
1603
2162
|
}
|
|
1604
2163
|
for (const d of dims) {
|
|
1605
|
-
const r = maxMedianRatio(d.samples);
|
|
2164
|
+
const r = maxMedianRatio(d.samples, d.qualifies);
|
|
1606
2165
|
if (!r || r.value < d.floor) continue; // absolute-magnitude floor: same ratio+floor shape as computeSpillMagnitude
|
|
1607
2166
|
const tier = impactBandFor(r.ratio);
|
|
1608
2167
|
if (!tier) continue;
|
|
1609
|
-
// taskTime is the only wallClock-bearing dimension
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
// get a wallClock estimate, so they keep the dynamic tier unchanged.
|
|
2168
|
+
// taskTime is the only wallClock-bearing dimension: it takes the fixed fallback band (see
|
|
2169
|
+
// threshold-tables.md). The other three never get a wallClock estimate, so they keep the
|
|
2170
|
+
// ratio tier.
|
|
1613
2171
|
const impactBand = d.dimension === 'taskTime' ? 'info' : tier;
|
|
1614
2172
|
out.push({
|
|
1615
2173
|
type: 'slowHost', stageId: stage.id, impactBand,
|
|
@@ -1619,6 +2177,7 @@ export const DETECTORS = [
|
|
|
1619
2177
|
// in this dimension's own unit (ms for taskTime, bytes for the rest).
|
|
1620
2178
|
execMaxValue: r.value,
|
|
1621
2179
|
executorId: r.key,
|
|
2180
|
+
...(d.dimension === 'taskTime' ? executorTaskTimeCaveat(r.key, r.ratio, d.samples.length, stage.taskCount) : {}),
|
|
1622
2181
|
recommendation: `Executor ${r.key}'s ${SLOW_HOST_DIMENSION_LABEL[d.dimension]} is ${Math.round(r.ratio * 10) / 10}× the median: investigate uneven partition assignment or a degraded executor.`,
|
|
1623
2182
|
});
|
|
1624
2183
|
}
|
|
@@ -1652,7 +2211,7 @@ export const DETECTORS = [
|
|
|
1652
2211
|
thresholds: { infoMin: 15 },
|
|
1653
2212
|
// A stage slowHost already explains needs no generic "this stage is slow" finding on top.
|
|
1654
2213
|
suppressedBy: 'slowHost',
|
|
1655
|
-
detect(stage,
|
|
2214
|
+
detect(stage, ctx, thresholds) {
|
|
1656
2215
|
// Basis is real wall-clock stage duration, not per-executor average; the impact-estimator
|
|
1657
2216
|
// formula reuses this exact stageDurationMs computation.
|
|
1658
2217
|
const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
|
|
@@ -1662,11 +2221,12 @@ export const DETECTORS = [
|
|
|
1662
2221
|
const impactBand = durationMinutes >= t.infoMin ? 'info' : null;
|
|
1663
2222
|
if (!impactBand) return null;
|
|
1664
2223
|
const value = Math.round(durationMinutes * 10) / 10;
|
|
1665
|
-
// shuffle
|
|
1666
|
-
//
|
|
2224
|
+
// A shuffle's reduce-side partition count is the remedy on a stage that reads a shuffle; a stage
|
|
2225
|
+
// that reads input files gets the input-partitioning remedy instead.
|
|
1667
2226
|
const reads = stageReads(stage);
|
|
1668
|
-
const
|
|
1669
|
-
|
|
2227
|
+
const shuffleFix = reads === 'shuffle' ? slowShuffleFix(stage, ctx) : null;
|
|
2228
|
+
const recommendation = shuffleFix
|
|
2229
|
+
? `This stage ran ${value} minutes with no more specific cause flagged: often a partition-count problem, ${shuffleFix.text}, or check for a large per-task data volume driving heavy shuffle and spill.`
|
|
1670
2230
|
: reads === 'input'
|
|
1671
2231
|
? `This stage ran ${value} minutes with no more specific cause flagged and reads mostly input files: often too few or too uneven input partitions, so check input file sizes and lower spark.sql.files.maxPartitionBytes, or look for a large per-task data volume driving heavy spill.`
|
|
1672
2232
|
: `This stage ran ${value} minutes with no more specific cause flagged and reads neither shuffle nor input files: check what it computes and for a large per-task data volume driving heavy spill.`;
|
|
@@ -1674,8 +2234,8 @@ export const DETECTORS = [
|
|
|
1674
2234
|
type: 'stageSlowness', stageId: stage.id, impactBand,
|
|
1675
2235
|
metric: 'stageDurationMinutes', value, reads,
|
|
1676
2236
|
recommendation,
|
|
1677
|
-
remediation:
|
|
1678
|
-
?
|
|
2237
|
+
remediation: shuffleFix
|
|
2238
|
+
? shuffleFix.remediation
|
|
1679
2239
|
: reads === 'input' ? [decreaseConf('spark.sql.files.maxPartitionBytes')] : [],
|
|
1680
2240
|
};
|
|
1681
2241
|
},
|
|
@@ -1690,8 +2250,7 @@ export const DETECTORS = [
|
|
|
1690
2250
|
// too, hence TAIL_CLAIM's post-fix floor. Unknown cluster size: no defensible figure.
|
|
1691
2251
|
if (ctx.totalCores <= 0) return costOnly('modeled');
|
|
1692
2252
|
// A stage that read no input and no shuffle, its tasks idle waiting on an external system,
|
|
1693
|
-
// gains nothing from more partitions
|
|
1694
|
-
// was claimed 99% recoverable): claim 0. Stages that read bytes keep their claim.
|
|
2253
|
+
// gains nothing from more partitions: claim 0. Stages that read bytes keep their claim.
|
|
1695
2254
|
const readBytes = (stage.inputBytes ?? 0) + (stage.shuffleReadBytes ?? 0);
|
|
1696
2255
|
const activeMs = typeof stage.taskActiveMs === 'number'
|
|
1697
2256
|
? stage.taskActiveMs
|
|
@@ -1762,63 +2321,39 @@ export const DETECTORS = [
|
|
|
1762
2321
|
// floorPctWarn/floorPctCrit default to impact-band.ts's run-wide noise floor, so a tail this
|
|
1763
2322
|
// gate admits at its warn floor grades at least warning there too.
|
|
1764
2323
|
// shareWarnAtFloor: in a large stage, the few stragglers that gate it for tens of seconds can be
|
|
1765
|
-
// only 2.5-5% of its tasks
|
|
1766
|
-
// (recoverable = replay with each task over 4x P50 capped at P50), admitting 2.5-5% shares
|
|
1767
|
-
// whose clipped tail already clears floorPctWarn found 3 such stages (10-48s) for 1 borderline
|
|
1768
|
-
// miss; admitting every 2.5% share instead added 86 findings below the floor.
|
|
2324
|
+
// only 2.5-5% of its tasks, so a 2.5% share counts once its clipped tail clears floorPctWarn.
|
|
1769
2325
|
// A stage shorter than floorPctWarn of the run is skipped outright: its tail can't cost more
|
|
1770
|
-
// than the stage's own duration, so every finding there
|
|
1771
|
-
//
|
|
1772
|
-
//
|
|
2326
|
+
// than the stage's own duration, so every finding there would grade info.
|
|
2327
|
+
// A tail skew's gate admits and data explains is skew's finding; one it admits whose cause is not
|
|
2328
|
+
// data is this one's. Both use skew's thresholds (ctx.skewThresholds), so tuning skew moves both.
|
|
1773
2329
|
thresholds: { minTasks: 10, shareWarn: 0.05, shareWarnAtFloor: 0.025, warnPct: 0.10, critPct: 0.20, floorPctWarn: IMPACT_FLOOR_PCT_WARN, floorPctCrit: IMPACT_FLOOR_PCT_CRIT },
|
|
1774
2330
|
detect(stage, ctx, thresholds) {
|
|
1775
|
-
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
const
|
|
1782
|
-
|
|
1783
|
-
//
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
const stragglerShareFires = stragglerShare > thresholds.shareWarn
|
|
1790
|
-
|| (stragglerShare > thresholds.shareWarnAtFloor && runMs != null && meetsWarnFloor);
|
|
1791
|
-
if (!useSpeculative && !stragglerShareFires) return null;
|
|
1792
|
-
const speculativeTier = speculativeShare >= thresholds.critPct && meetsCritFloor ? 'critical'
|
|
1793
|
-
: speculativeShare >= thresholds.warnPct && meetsWarnFloor ? 'warning' : 'info';
|
|
1794
|
-
// Straggler share has no dedicated critical tier per detector-contract.md; only warning.
|
|
1795
|
-
const stragglerTier = stragglerShareFires && meetsWarnFloor ? 'warning' : 'info';
|
|
1796
|
-
// Fixed fallback: overwritten by deriveImpactBand when this finding gets a real wallClock
|
|
1797
|
-
// estimate (the common case). Only surfaces on the rare occupancy-sweep miss.
|
|
1798
|
-
const impactBand = 'info';
|
|
1799
|
-
// Report whichever signal actually drove the finding, not just whether speculation was on:
|
|
1800
|
-
// a high stragglerShare with few speculative retries must not be reported as a low-value
|
|
1801
|
-
// speculativeTasks count. Ties keep the speculative-driven default.
|
|
1802
|
-
const useSpeculativeMetric = useSpeculative && !(IMPACT_BAND_ORDER[stragglerTier] < IMPACT_BAND_ORDER[speculativeTier]);
|
|
1803
|
-
const value = useSpeculativeMetric ? stage.speculativeTasks : Math.round(stragglerShare * 100);
|
|
1804
|
-
const detail = useSpeculativeMetric
|
|
1805
|
-
? `${value} speculative attempt${value === 1 ? '' : 's'} discarded`
|
|
1806
|
-
: `${value}% of tasks straggled`;
|
|
1807
|
-
// The skew advice fits only a stage that reads a shuffle feeding a join (skewFix).
|
|
1808
|
-
const fix = skewFix(stage, ctx);
|
|
2331
|
+
const skew = ctx.skewThresholds;
|
|
2332
|
+
const tail = tailVerdict(stage, skew.dataShareMin);
|
|
2333
|
+
const skewTail = skewGate(stage, ctx, skew.ratioWarn, skew.minTasksForP95, skew.floorPctWarn);
|
|
2334
|
+
if (tail.cause === 'data' && skewTail !== null) return null; // skew reports it
|
|
2335
|
+
// A tail skew's duration gate admits but data does not explain (or nothing attributes) is this finding's.
|
|
2336
|
+
const fromSkewGate = skewTail !== null && tail.cause !== 'data';
|
|
2337
|
+
const own = stragglerGate(stage, ctx, thresholds);
|
|
2338
|
+
if (own === null && !fromSkewGate) return null;
|
|
2339
|
+
// Through skew's gate alone the tail can be a share too small to state, so skew's ratio describes it.
|
|
2340
|
+
const reported = own !== null
|
|
2341
|
+
? { metric: own.useSpeculativeMetric ? 'speculativeTasks' : 'stragglerShare', value: own.value, unit: own.useSpeculativeMetric ? 'count' : 'pct' , detail: own.detail }
|
|
2342
|
+
: { metric: skewTail .metric, value: Math.round(skewTail .ratio * 10) / 10, unit: 'ratio' ,
|
|
2343
|
+
detail: `Task duration ratio (${skewTail .metric}) is ${Math.round(skewTail .ratio * 10) / 10}×` };
|
|
2344
|
+
const advice = stragglerAdvice(stage, ctx, tail, reported.detail);
|
|
1809
2345
|
return {
|
|
1810
|
-
type: 'straggler', stageId: stage.id,
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
2346
|
+
type: 'straggler', stageId: stage.id,
|
|
2347
|
+
impactBand: 'info', ...advice.evidence,
|
|
2348
|
+
metric: reported.metric,
|
|
2349
|
+
value: reported.value,
|
|
2350
|
+
unit: reported.unit,
|
|
1814
2351
|
speculativeTasks: stage.speculativeTasks ?? 0,
|
|
1815
2352
|
stragglerCount: stage.stragglerCount ?? 0,
|
|
1816
|
-
confidence:
|
|
1817
|
-
? stragglerConfidence(speculativeShare, thresholds.warnPct, thresholds.critPct)
|
|
1818
|
-
: stragglerConfidence(stragglerShare, thresholds.shareWarn, thresholds.critPct),
|
|
2353
|
+
confidence: own?.confidence ?? skewConfidence(skewTail .ratio, skew.ratioWarn),
|
|
1819
2354
|
validationRequired: `Warning needs at least ${shareLabel(thresholds.floorPctWarn)} of run time at stake, critical ${shareLabel(thresholds.floorPctCrit)}.`,
|
|
1820
|
-
recommendation:
|
|
1821
|
-
remediation:
|
|
2355
|
+
recommendation: advice.recommendation,
|
|
2356
|
+
remediation: advice.remediation,
|
|
1822
2357
|
};
|
|
1823
2358
|
},
|
|
1824
2359
|
estimate(finding, ctx) {
|
|
@@ -1833,7 +2368,7 @@ export const DETECTORS = [
|
|
|
1833
2368
|
emits: ['speculationWaste'],
|
|
1834
2369
|
docAnchor: '#bottleneck-speculation-waste',
|
|
1835
2370
|
thresholds: { minWasted: 5, minWasteMs: 60000 },
|
|
1836
|
-
detect(stage,
|
|
2371
|
+
detect(stage, ctx, thresholds) {
|
|
1837
2372
|
const wasted = stage.speculationWastedAttempts ?? 0;
|
|
1838
2373
|
const wastedMs = stage.speculationWasteMs ?? 0;
|
|
1839
2374
|
if (wasted < thresholds.minWasted || wastedMs < thresholds.minWasteMs) return null;
|
|
@@ -1842,7 +2377,7 @@ export const DETECTORS = [
|
|
|
1842
2377
|
impactBand: 'warning',
|
|
1843
2378
|
metric: 'speculationWasteMs', value: wastedMs,
|
|
1844
2379
|
confidence: speculationWasteConfidence(wastedMs, thresholds.minWasteMs),
|
|
1845
|
-
recommendation: `Speculative execution discarded ${Math.round(wastedMs / 1000)}s of executor time in this stage: if task durations are naturally variable rather than genuine stragglers, consider tuning spark.speculation.multiplier/quantile.`,
|
|
2380
|
+
recommendation: `Speculative execution discarded ${Math.round(wastedMs / 1000)}s of executor time in this stage: if task durations are naturally variable rather than genuine stragglers, consider tuning spark.speculation.multiplier/quantile${speculationRuleNote(ctx.app)}.`,
|
|
1846
2381
|
remediation: [increaseConf('spark.speculation.multiplier'), increaseConf('spark.speculation.quantile')],
|
|
1847
2382
|
};
|
|
1848
2383
|
},
|
|
@@ -1890,8 +2425,7 @@ export const DETECTORS = [
|
|
|
1890
2425
|
emits: ['tinyTask'],
|
|
1891
2426
|
docAnchor: '#bottleneck-tiny-tasks',
|
|
1892
2427
|
// stageFloorPct: the tiered detectors' 0.5% runtime floor. Coalescing can't save more than the
|
|
1893
|
-
// stage's own duration, so on a shorter stage every finding
|
|
1894
|
-
// 132 of 151 tinyTask findings. The tasks are still tiny there; the floor is why they're dropped.
|
|
2428
|
+
// stage's own duration, so on a shorter stage every finding would grade info.
|
|
1895
2429
|
thresholds: { minTasks: 100, maxP50: 500, maxP95: 1000, stageFloorPct: 0.005 },
|
|
1896
2430
|
detect(stage, ctx, thresholds) {
|
|
1897
2431
|
if (stage.taskCount < thresholds.minTasks) return null;
|
|
@@ -1899,14 +2433,12 @@ export const DETECTORS = [
|
|
|
1899
2433
|
if (stage.taskDurationP50 > thresholds.maxP50 || stage.taskDurationP95 > thresholds.maxP95) return null;
|
|
1900
2434
|
const coalesceTo = Math.max(1, Math.round(stage.taskCount / 10));
|
|
1901
2435
|
const reads = stageReads(stage);
|
|
1902
|
-
const fix = reads === 'shuffle'
|
|
1903
|
-
? `lower spark.sql.shuffle.partitions or .coalesce(${coalesceTo})`
|
|
1904
|
-
: `.coalesce(${coalesceTo})`;
|
|
2436
|
+
const fix = reads === 'shuffle' ? tinyShuffleFix(stage, ctx, coalesceTo) : { text: `.coalesce(${coalesceTo})`, remediation: [] };
|
|
1905
2437
|
return {
|
|
1906
2438
|
type: 'tinyTask', stageId: stage.id, impactBand: 'info', reads,
|
|
1907
2439
|
metric: 'taskDurationP50', value: Math.round(stage.taskDurationP50),
|
|
1908
|
-
recommendation: `Many small tasks (${stage.taskCount}, P50 ${Math.round(stage.taskDurationP50)}ms): scheduler overhead may dominate. Try ${fix}.`,
|
|
1909
|
-
remediation:
|
|
2440
|
+
recommendation: `Many small tasks (${stage.taskCount}, P50 ${Math.round(stage.taskDurationP50)}ms): scheduler overhead may dominate. Try ${fix.text}.`,
|
|
2441
|
+
remediation: fix.remediation,
|
|
1910
2442
|
};
|
|
1911
2443
|
},
|
|
1912
2444
|
estimate(finding, ctx) {
|
|
@@ -1959,10 +2491,7 @@ export const DETECTORS = [
|
|
|
1959
2491
|
// No stage ever recorded a submission timestamp: no basis to measure a startup gap against.
|
|
1960
2492
|
if (!Number.isFinite(firstStageSubmitted)) return null;
|
|
1961
2493
|
// The wait is from the first runnable stage to the first executor, not from app start: the
|
|
1962
|
-
// driver's own startup before its first job
|
|
1963
|
-
// executors did) isn't something executors could shorten. On the 9 real logs that fired,
|
|
1964
|
-
// the first executor arrived 146-192s after the first stage on two whose old gap read ~40s,
|
|
1965
|
-
// and 2s after it on one the old gap flagged critical at 42s.
|
|
2494
|
+
// driver's own startup before its first job isn't something executors could shorten.
|
|
1966
2495
|
// An executor added before the first stage only counts if it was still alive at submission:
|
|
1967
2496
|
// with dynamic allocation scaling to zero, early executors can idle out before any job runs.
|
|
1968
2497
|
const removedAt = new Map ();
|
|
@@ -2005,35 +2534,33 @@ export const DETECTORS = [
|
|
|
2005
2534
|
thresholds: { minUtil: 0.60 },
|
|
2006
2535
|
detect(ctx, thresholds) {
|
|
2007
2536
|
const { app, executorsAdded, executorsRemoved, runAggregates } = ctx;
|
|
2008
|
-
// Nullish (not falsy) check: a literal startTime:0 must not be treated as "missing".
|
|
2009
2537
|
if (!app || executorsAdded.length === 0 || app.startTime == null || app.endTime == null) return null;
|
|
2010
2538
|
const appDuration = app.endTime - app.startTime;
|
|
2011
2539
|
if (appDuration <= 0) return null;
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
2014
|
-
//
|
|
2540
|
+
// Capacity is what the run was allocated (cores x time alive, the figure behind
|
|
2541
|
+
// metrics.allocation.coreHours), not peak concurrent cores x the whole run: under dynamic
|
|
2542
|
+
// allocation or late-joining executors that is more than was ever held, and the idle figure
|
|
2543
|
+
// would exceed the allocation. Null without executor cores.
|
|
2544
|
+
const capacityCoreMs = allocatedCoreMs({ app, stages: ctx.stages, executors: { added: executorsAdded, removed: executorsRemoved } });
|
|
2545
|
+
if (capacityCoreMs == null) return null;
|
|
2546
|
+
// Peak concurrent cores (not computeTotalCores, a cumulative sum that double-counts a
|
|
2547
|
+
// churned-through executor against its replacement): the cluster size the finding reports.
|
|
2015
2548
|
const totalCores = computePeakConcurrentCores(app, executorsAdded, executorsRemoved);
|
|
2016
|
-
if (totalCores <= 0) return null;
|
|
2017
|
-
const capacityCoreMs = totalCores * appDuration;
|
|
2018
2549
|
// Busy core-time (from the whole-run core-time-series, same signal memoryUtilization's
|
|
2019
2550
|
// idleCores variant already uses), not executor lifetime: an executor that exists for the
|
|
2020
2551
|
// whole run but sits fully idle must not score as 100% used. Missing runAggregates (older
|
|
2021
|
-
// callers, synthetic fixtures) reads as 0 busy time
|
|
2022
|
-
// lifetime-based measure this replaces.
|
|
2552
|
+
// callers, synthetic fixtures) reads as 0 busy time.
|
|
2023
2553
|
const busyCoreMs = runAggregates?.busyCoreMs ?? 0;
|
|
2024
2554
|
const utilization = busyCoreMs / capacityCoreMs;
|
|
2025
2555
|
if (utilization >= thresholds.minUtil) return null;
|
|
2026
2556
|
|
|
2027
|
-
// CPU-time-based utilization
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
const cpuMs = totalExecutorCpuMs(ctx.stages.values());
|
|
2032
|
-
cpuUtilizationPct = cpuMs == null ? null : Math.round((cpuMs / (appDuration * totalCores)) * 100);
|
|
2033
|
-
}
|
|
2557
|
+
// CPU-time-based utilization: metric only, no threshold.
|
|
2558
|
+
// Null when no stage recorded CPU time (older Spark), the same rule as the CLI metrics block.
|
|
2559
|
+
const cpuMs = totalExecutorCpuMs(ctx.stages.values());
|
|
2560
|
+
const cpuUtilizationPct = cpuMs == null ? null : Math.round((cpuMs / capacityCoreMs) * 100);
|
|
2034
2561
|
|
|
2035
2562
|
const value = Math.round(utilization * 100);
|
|
2036
|
-
const fix =
|
|
2563
|
+
const fix = idleCapacityFix(app, 'consider either reducing cluster size (spark.executor.instances) or enabling dynamic allocation',
|
|
2037
2564
|
'dynamic allocation is already on, so consider reducing cluster size');
|
|
2038
2565
|
return {
|
|
2039
2566
|
type: 'utilization', stageId: null, impactBand: 'info',
|
|
@@ -2041,6 +2568,7 @@ export const DETECTORS = [
|
|
|
2041
2568
|
utilizationFraction: utilization,
|
|
2042
2569
|
appDurationMs: appDuration,
|
|
2043
2570
|
totalCores,
|
|
2571
|
+
allocatedCoreMs: capacityCoreMs,
|
|
2044
2572
|
cpuUtilizationPct,
|
|
2045
2573
|
recommendation: `Average executor utilization was only ${value}%: ${fix.text}.`,
|
|
2046
2574
|
remediation: fix.remediation,
|
|
@@ -2048,13 +2576,17 @@ export const DETECTORS = [
|
|
|
2048
2576
|
},
|
|
2049
2577
|
estimate(finding) {
|
|
2050
2578
|
const fraction = finding.utilizationFraction ;
|
|
2051
|
-
const
|
|
2052
|
-
|
|
2053
|
-
if (fraction == null || appDurationMs == null || totalCores == null) {
|
|
2579
|
+
const allocatedMs = finding.allocatedCoreMs ;
|
|
2580
|
+
if (fraction == null || allocatedMs == null) {
|
|
2054
2581
|
return costOnly('measured');
|
|
2055
2582
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2583
|
+
// The same allocated-minus-busy figure as `rawWaste`, in core-milliseconds, next to (never in)
|
|
2584
|
+
// coreTimeMs: that field is busy task time a fix removes, this is capacity no task used.
|
|
2585
|
+
const idleMs = (1 - fraction) * allocatedMs;
|
|
2586
|
+
return {
|
|
2587
|
+
...costOnly('measured', { value: idleMs / MS_PER_CORE_HOUR, unit: 'coreHours', idle: true }),
|
|
2588
|
+
idleCoreTimeMs: { low: idleMs, high: idleMs },
|
|
2589
|
+
};
|
|
2058
2590
|
},
|
|
2059
2591
|
}),
|
|
2060
2592
|
defineAppDetector({
|
|
@@ -2062,105 +2594,100 @@ export const DETECTORS = [
|
|
|
2062
2594
|
emits: ['memoryUtilization'],
|
|
2063
2595
|
docAnchor: '#bottleneck-memory-utilization',
|
|
2064
2596
|
thresholds: {
|
|
2065
|
-
idleCoreWarn: 0.50, //
|
|
2066
|
-
bandTooSmall: 0.95, // MemoryAlertsReducer: used/allocated
|
|
2597
|
+
idleCoreWarn: 0.50, // idle share of allocated core time above which idleCores fires
|
|
2067
2598
|
bandTooHigh: 0.70, // below this => over-provisioned (cost signal)
|
|
2068
|
-
wasteBufferMultiplier: 1.5, //
|
|
2599
|
+
wasteBufferMultiplier: 1.5, // wasted memory-time must exceed used by this multiple
|
|
2069
2600
|
},
|
|
2070
2601
|
detect(ctx, thresholds) {
|
|
2071
2602
|
const { app, executorsAdded, executorsRemoved, runAggregates, stages } = ctx;
|
|
2072
2603
|
const out = [];
|
|
2073
|
-
// Nullish (not falsy) check: a literal startTime:0 must not be treated as "missing".
|
|
2074
2604
|
if (app?.startTime == null || app?.endTime == null) return out;
|
|
2075
2605
|
const appDurationMs = app.endTime - app.startTime;
|
|
2076
2606
|
if (appDurationMs <= 0) return out;
|
|
2077
2607
|
|
|
2078
|
-
// Peak concurrent executors/cores (not executorsAdded.length/computeTotalCores): a
|
|
2079
|
-
// cumulative sum or count double-counts a churned-through executor against its replacement's
|
|
2080
|
-
// (spot preemption, dynamicAllocation replacement), inflating idle-rate and waste-model figures.
|
|
2081
|
-
const peakExecutors = computePeakConcurrentExecutorCount(executorsAdded, executorsRemoved);
|
|
2082
|
-
const totalCores = computePeakConcurrentCores(app, executorsAdded, executorsRemoved);
|
|
2083
|
-
// Hoisted above 1a (also 1b/1c's input) so the idle-cores finding carries the allocated
|
|
2084
|
-
// memory its MB-seconds estimate needs.
|
|
2085
2608
|
const allocatedMB = app.resources?.executor?.memoryMB ?? null;
|
|
2086
2609
|
|
|
2087
|
-
//
|
|
2610
|
+
// Idle-cores rate.
|
|
2088
2611
|
const busyCoreMs = runAggregates?.busyCoreMs;
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2612
|
+
// Allocated core-time, as for `utilization`, so the two findings of one idle condition agree.
|
|
2613
|
+
const allocationInput = { app, stages, executors: { added: executorsAdded, removed: executorsRemoved } };
|
|
2614
|
+
const capacityCoreMs = allocatedCoreMs(allocationInput);
|
|
2615
|
+
if (busyCoreMs != null && capacityCoreMs != null) {
|
|
2616
|
+
const idleRate = 1 - (busyCoreMs / capacityCoreMs);
|
|
2092
2617
|
if (idleRate > thresholds.idleCoreWarn) {
|
|
2093
2618
|
const value = Math.round(idleRate * 100);
|
|
2094
|
-
const
|
|
2619
|
+
const { memoryGbHours } = computeAllocation(allocationInput);
|
|
2620
|
+
const fix = idleCapacityFix(app, 'either reduce cluster size (spark.executor.instances) or enable dynamic allocation',
|
|
2095
2621
|
'dynamic allocation is already on, so reduce cluster size');
|
|
2096
2622
|
out.push({
|
|
2097
2623
|
type: 'memoryUtilization', variant: 'idleCores', stageId: null,
|
|
2098
2624
|
impactBand: 'warning', metric: 'idleCoreRate', value,
|
|
2099
|
-
// Raw (unrounded) rate plus
|
|
2100
|
-
idleRateFraction: idleRate,
|
|
2625
|
+
// Raw (unrounded) rate plus the allocated memory-time for the impact estimator: `value` is rounded pct.
|
|
2626
|
+
idleRateFraction: idleRate,
|
|
2627
|
+
allocatedMBSeconds: memoryGbHours != null ? memoryGbHours * 1024 * 3600 : null,
|
|
2101
2628
|
recommendation: `${value}% of available core-time ran no task: ${fix.text}.`,
|
|
2102
2629
|
remediation: fix.remediation,
|
|
2103
2630
|
});
|
|
2104
2631
|
}
|
|
2105
2632
|
}
|
|
2106
2633
|
|
|
2107
|
-
//
|
|
2108
|
-
//
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
if (!(em instanceof Map)) continue;
|
|
2113
|
-
for (const [execId, m] of em) {
|
|
2114
|
-
const heap = m?.jvmHeapMemory ?? 0;
|
|
2115
|
-
if (heap > (peakHeapByExec.get(execId) ?? 0)) peakHeapByExec.set(execId, heap);
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2634
|
+
// Memory band (executor only: there is no driver heap metric).
|
|
2635
|
+
// spark.executor.memory is one setting for every executor, so the judgement is made once, on
|
|
2636
|
+
// the executor with the highest sampled heap peak. A near-capacity peak is not reported:
|
|
2637
|
+
// JVMHeapMemory counts uncollected garbage, so a peak near -Xmx is normal JVM behaviour.
|
|
2638
|
+
const peakHeapByExec = executorHeapPeaks(ctx);
|
|
2118
2639
|
if (peakHeapByExec.size === 0) {
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2640
|
+
// Spark 3.0+ writes the executor's metric peaks on every TaskEnd, but it samples them at the
|
|
2641
|
+
// executor heartbeat (spark.executor.metrics.pollingInterval defaults to 0), so a task that
|
|
2642
|
+
// finishes between two heartbeats reports zeros, as does every task in local mode.
|
|
2643
|
+
// spark.eventLog.logStageExecutorMetrics logs the per-stage peaks the driver gathers.
|
|
2644
|
+
const stageMetrics = switchFix(loggedAs(app, LOG_STAGE_EXECUTOR_METRICS_KEY, true), LOG_STAGE_EXECUTOR_METRICS_KEY, true,
|
|
2645
|
+
` To measure it, set ${LOG_STAGE_EXECUTOR_METRICS_KEY}=true to record the peaks per stage.`,
|
|
2646
|
+
` ${LOG_STAGE_EXECUTOR_METRICS_KEY} is already on, so no executor ran long enough to be sampled.`);
|
|
2123
2647
|
out.push({
|
|
2124
2648
|
type: 'memoryUtilization', variant: 'memoryBand', stageId: null,
|
|
2125
2649
|
impactBand: 'info', metric: 'memoryBand', dataUnavailable: true,
|
|
2126
|
-
recommendation:
|
|
2127
|
-
remediation:
|
|
2650
|
+
recommendation: `Executor heap peaks are missing from this log: Spark samples them at the executor heartbeat, so tasks shorter than the heartbeat interval report zeros (and local mode reports zeros throughout), and executor memory sizing was not measured.${stageMetrics.text}`,
|
|
2651
|
+
remediation: stageMetrics.remediation,
|
|
2128
2652
|
});
|
|
2129
2653
|
} else if (allocatedMB != null && allocatedMB > 0) {
|
|
2130
2654
|
const allocatedBytes = allocatedMB * 1024 * 1024;
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
remediation: [decreaseConf('spark.executor.memory')],
|
|
2153
|
-
});
|
|
2154
|
-
}
|
|
2655
|
+
let execId = '';
|
|
2656
|
+
let heap = 0;
|
|
2657
|
+
for (const [id, peak] of peakHeapByExec) {
|
|
2658
|
+
if (peak > heap) { heap = peak; execId = id; }
|
|
2659
|
+
}
|
|
2660
|
+
const ratio = heap / allocatedBytes;
|
|
2661
|
+
if (ratio < thresholds.bandTooHigh) {
|
|
2662
|
+
const pct = Math.round(ratio * 100);
|
|
2663
|
+
const executorCount = peakHeapByExec.size;
|
|
2664
|
+
out.push({
|
|
2665
|
+
type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapOverProvisioned',
|
|
2666
|
+
stageId: null, executorId: execId,
|
|
2667
|
+
impactBand: 'info', metric: 'heapUsedRatio', value: pct,
|
|
2668
|
+
// Absolute figures behind the rounded ratio, for the estimator's unused-memory model:
|
|
2669
|
+
// the unused heap is held by every executor for as long as it is alive.
|
|
2670
|
+
allocatedBytes, heap, executorCount,
|
|
2671
|
+
executorSeconds: computeAllocation({ app, stages, executors: { added: executorsAdded, removed: executorsRemoved } }).executorSeconds
|
|
2672
|
+
?? executorCount * (appDurationMs / 1000),
|
|
2673
|
+
recommendation: `The busiest of ${executorCount} sampled executors peaked at ${pct}% of allocated heap (a lower bound: Spark samples at executor heartbeat): memory may be over-provisioned; consider reducing spark.executor.memory for cost savings.`,
|
|
2674
|
+
remediation: [decreaseConf('spark.executor.memory')],
|
|
2675
|
+
});
|
|
2155
2676
|
}
|
|
2156
2677
|
}
|
|
2157
2678
|
|
|
2158
|
-
//
|
|
2159
|
-
|
|
2160
|
-
|
|
2679
|
+
// Spark Memory Limit waste model.
|
|
2680
|
+
// executorRunTime sums task time across cores, so dividing by the cores per executor gives
|
|
2681
|
+
// executor-seconds: the same unit as the allocated executor-seconds it is compared with.
|
|
2682
|
+
// The allocated side is the executors' alive time (computeAllocation), not the peak executor
|
|
2683
|
+
// count held for the whole run: under dynamic allocation the two differ by peak over mean.
|
|
2684
|
+
const allocation = computeAllocation(allocationInput);
|
|
2685
|
+
const executorCores = app.resources?.executor?.cores ?? allocation.executorCores;
|
|
2686
|
+
if (allocatedMB != null && allocation.executorSeconds != null && allocation.executorSeconds > 0 && executorCores != null && executorCores > 0) {
|
|
2687
|
+
const allocatedMBSeconds = allocatedMB * allocation.executorSeconds;
|
|
2161
2688
|
let usedRunTimeMs = 0;
|
|
2162
2689
|
for (const s of stages.values()) usedRunTimeMs += s.executorRunTime ?? 0;
|
|
2163
|
-
const usedMBSeconds = allocatedMB * (usedRunTimeMs / 1000);
|
|
2690
|
+
const usedMBSeconds = allocatedMB * (usedRunTimeMs / 1000 / executorCores);
|
|
2164
2691
|
const wastedMBSeconds = allocatedMBSeconds - usedMBSeconds;
|
|
2165
2692
|
if (wastedMBSeconds > thresholds.wasteBufferMultiplier * usedMBSeconds) {
|
|
2166
2693
|
const value = Math.round(wastedMBSeconds);
|
|
@@ -2183,26 +2710,24 @@ export const DETECTORS = [
|
|
|
2183
2710
|
return costOnly('measured', { value: finding.value, unit: 'mbSeconds' });
|
|
2184
2711
|
}
|
|
2185
2712
|
if (finding.variant === 'idleCores') {
|
|
2186
|
-
// Idle core-time priced as memory held but unused: the
|
|
2713
|
+
// Idle core-time priced as memory held but unused: the allocated memory-seconds times the idle rate.
|
|
2187
2714
|
const idleRateFraction = finding.idleRateFraction ;
|
|
2188
|
-
const
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
if (idleRateFraction != null && allocatedMB != null && peakExecutors != null && appDurationMs != null) {
|
|
2192
|
-
const wastedMBSeconds = idleRateFraction * allocatedMB * peakExecutors * (appDurationMs / 1000);
|
|
2715
|
+
const allocatedMBSeconds = finding.allocatedMBSeconds ;
|
|
2716
|
+
if (idleRateFraction != null && allocatedMBSeconds != null) {
|
|
2717
|
+
const wastedMBSeconds = idleRateFraction * allocatedMBSeconds;
|
|
2193
2718
|
return costOnly('modeled', { value: wastedMBSeconds, unit: 'mbSeconds' });
|
|
2194
2719
|
}
|
|
2195
2720
|
return costOnly('modeled');
|
|
2196
2721
|
}
|
|
2197
|
-
// Only the over-provisioned band is a waste; the
|
|
2198
|
-
//
|
|
2722
|
+
// Only the over-provisioned band is a waste; the dataUnavailable shape has no inputs and
|
|
2723
|
+
// stays informational.
|
|
2199
2724
|
if (finding.variant === 'memoryBand' && finding.rule === 'heapOverProvisioned') {
|
|
2200
2725
|
const allocatedBytes = finding.allocatedBytes ;
|
|
2201
2726
|
const heap = finding.heap ;
|
|
2202
|
-
const
|
|
2203
|
-
if (allocatedBytes != null && heap != null &&
|
|
2727
|
+
const executorSeconds = finding.executorSeconds ;
|
|
2728
|
+
if (allocatedBytes != null && heap != null && executorSeconds != null) {
|
|
2204
2729
|
const unusedMB = (allocatedBytes - heap) / (1024 * 1024);
|
|
2205
|
-
const wastedMBSeconds = unusedMB *
|
|
2730
|
+
const wastedMBSeconds = unusedMB * executorSeconds;
|
|
2206
2731
|
return costOnly('modeled', { value: wastedMBSeconds, unit: 'mbSeconds' });
|
|
2207
2732
|
}
|
|
2208
2733
|
}
|
|
@@ -2229,7 +2754,7 @@ export const DETECTORS = [
|
|
|
2229
2754
|
const out = [];
|
|
2230
2755
|
let persistedRddCount = 0;
|
|
2231
2756
|
// With block-update logging on, zero rdd_* updates means nothing was ever cached, not a gap.
|
|
2232
|
-
const blockUpdatesLogged =
|
|
2757
|
+
const blockUpdatesLogged = loggedAs(ctx.app, 'spark.eventLog.logBlockUpdates.enabled', true);
|
|
2233
2758
|
// Spark before 2.3 has no block-update logging and writes RDD Info's cache figures only on
|
|
2234
2759
|
// StageCompleted, which isn't read: the caveat's advice doesn't apply there. A log with no
|
|
2235
2760
|
// version is pre-1.3 (no SparkListenerLogStart); every 2.3+ log records one.
|
|
@@ -2276,7 +2801,10 @@ export const DETECTORS = [
|
|
|
2276
2801
|
const uncachedBytes = numCachedPartitions > 0 ? (cachedBytes / numCachedPartitions) * numUncachedPartitions : 0;
|
|
2277
2802
|
const uncachedOrSpilledBytes = uncachedBytes + diskSize;
|
|
2278
2803
|
const wasteMs = (uncachedOrSpilledBytes / RE_READ_THROUGHPUT_BPS) * 1000;
|
|
2279
|
-
return
|
|
2804
|
+
return withAssumptions(
|
|
2805
|
+
costOnly('modeled', { value: wasteMs, unit: 'ms' }),
|
|
2806
|
+
`assumes ${Math.round(RE_READ_THROUGHPUT_BPS / 1e6)} MB/s per reading task, summed as task time and not divided over executors`,
|
|
2807
|
+
);
|
|
2280
2808
|
},
|
|
2281
2809
|
}),
|
|
2282
2810
|
defineAppDetector({
|
|
@@ -2546,8 +3074,13 @@ export const DETECTORS = [
|
|
|
2546
3074
|
},
|
|
2547
3075
|
estimate(finding) {
|
|
2548
3076
|
const totalReadBytes = (finding.totalReadBytes ) ?? 0;
|
|
2549
|
-
|
|
2550
|
-
|
|
3077
|
+
// The measured scan volume and the query count, not a time: the bytes are cumulative task
|
|
3078
|
+
// input across queries, and a re-read cost depends on how much of it a cache would serve.
|
|
3079
|
+
const queries = (finding.executionIds )?.length ?? 0;
|
|
3080
|
+
return withAssumptions(
|
|
3081
|
+
costOnly('measured', { value: totalReadBytes, unit: 'bytes', reread: true }),
|
|
3082
|
+
`counts the bytes ${queries} ${queries === 1 ? 'query' : 'queries'} scanned from the same relation, matched by plan; a cache would still read it once`,
|
|
3083
|
+
);
|
|
2551
3084
|
},
|
|
2552
3085
|
}),
|
|
2553
3086
|
defineAppDetector({
|
|
@@ -2590,43 +3123,18 @@ export const DETECTORS = [
|
|
|
2590
3123
|
return costOnly('modeled', { value: coreHoursIsh, unit: 'coreHours' });
|
|
2591
3124
|
},
|
|
2592
3125
|
}),
|
|
2593
|
-
// ── Config-sanity entries (scope:'config', inScorecard:false) ────────────────
|
|
2594
|
-
defineConfigDetector({
|
|
2595
|
-
type: 'configAudit', order: 120, fixEffort: 'config', version: 1, inScorecard: false,
|
|
2596
|
-
emits: ['configAudit'],
|
|
2597
|
-
docAnchor: '#config-shuffle-service', thresholds: {}, property: 'spark.shuffle.service.enabled',
|
|
2598
|
-
detect(target) {
|
|
2599
|
-
const res = target.app?.resources ?? null;
|
|
2600
|
-
if (res?.dynamicAllocationEnabled === true && res?.shuffleServiceEnabled === false) {
|
|
2601
|
-
return {
|
|
2602
|
-
type: 'configAudit', property: 'spark.shuffle.service.enabled',
|
|
2603
|
-
impactBand: 'warning', metric: 'config', valueText: 'false',
|
|
2604
|
-
recommendation: 'Dynamic allocation is on but the external shuffle service is off: set spark.shuffle.service.enabled=true so shuffle data survives executor removal.',
|
|
2605
|
-
remediation: setConfUnlessLogged(target.app, 'spark.shuffle.service.enabled', true),
|
|
2606
|
-
};
|
|
2607
|
-
}
|
|
2608
|
-
return null;
|
|
2609
|
-
},
|
|
2610
|
-
estimate: noWasteModel,
|
|
2611
|
-
}),
|
|
2612
3126
|
defineConfigDetector({
|
|
2613
3127
|
type: 'configAudit', order: 121, fixEffort: 'config', version: 1, inScorecard: false,
|
|
2614
3128
|
emits: ['configAudit'],
|
|
2615
3129
|
docAnchor: '#config-autoscale-bounds', thresholds: {}, property: 'spark.dynamicAllocation.maxExecutors',
|
|
2616
3130
|
detect(target) {
|
|
2617
|
-
const app = target.app; const
|
|
3131
|
+
const app = target.app; const res = app?.resources ?? null;
|
|
2618
3132
|
if (res?.dynamicAllocationEnabled !== true) return null;
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
impactBand: 'critical', metric: 'config', valueText: `${minN} > ${maxN}`,
|
|
2625
|
-
recommendation: `spark.dynamicAllocation.minExecutors (${minN}) exceeds maxExecutors (${maxN}): set min ≤ max.`,
|
|
2626
|
-
remediation: [decreaseConf('spark.dynamicAllocation.minExecutors', maxN)],
|
|
2627
|
-
};
|
|
2628
|
-
}
|
|
2629
|
-
if (maxN == null) {
|
|
3133
|
+
// minExecutors > maxExecutors is not audited: ExecutorAllocationManager.validateSettings throws on it
|
|
3134
|
+
// at startup (v3.5.0 lines 196-199), so no run that wrote an event log has inverted bounds.
|
|
3135
|
+
// Unbounded is the effective maxExecutors being Spark's default (Int.MaxValue) or that value set.
|
|
3136
|
+
const maxN = Number.parseInt(effectiveConf(app, 'spark.dynamicAllocation.maxExecutors') ?? '', 10);
|
|
3137
|
+
if (!Number.isFinite(maxN) || maxN >= UNBOUNDED_EXECUTORS) {
|
|
2630
3138
|
return {
|
|
2631
3139
|
type: 'configAudit', property: 'spark.dynamicAllocation.maxExecutors',
|
|
2632
3140
|
impactBand: 'info', metric: 'config', valueText: '(unset)',
|
|
@@ -2645,7 +3153,13 @@ export const DETECTORS = [
|
|
|
2645
3153
|
detect(target) {
|
|
2646
3154
|
const app = target.app; const config = app?.config ?? {}; const res = app?.resources ?? null;
|
|
2647
3155
|
if (Object.keys(config).length === 0) return null;
|
|
2648
|
-
|
|
3156
|
+
// spark.serializer does not serialize SQL rows (shuffles and caches use UnsafeRow and the columnar
|
|
3157
|
+
// cache serializer), so the note only applies to a run that ran stages outside any SQL execution.
|
|
3158
|
+
// Without the stages there is no evidence of RDD work and no claim.
|
|
3159
|
+
if (!hasStageOutsideSql(target.stages)) return null;
|
|
3160
|
+
// The serializer the run set; Spark's own default (JavaSerializer) is reported as the default.
|
|
3161
|
+
const effective = effectiveSparkConf({ sparkVersion: app?.sparkVersion, properties: config }, 'spark.serializer');
|
|
3162
|
+
const ser = res?.serializer ?? (effective?.source === 'default' ? null : effective?.value ?? null);
|
|
2649
3163
|
const isKryo = typeof ser === 'string' && /kryo/i.test(ser);
|
|
2650
3164
|
if (isKryo) return null;
|
|
2651
3165
|
return {
|
|
@@ -2666,26 +3180,25 @@ export const DETECTORS = [
|
|
|
2666
3180
|
const memMB = res?.executor?.memoryMB ?? null;
|
|
2667
3181
|
const ovMB = res?.executor?.memoryOverheadMB ?? null;
|
|
2668
3182
|
if (memMB == null || ovMB == null) return null;
|
|
2669
|
-
const
|
|
3183
|
+
const { minMB, factor } = defaultOverheadSettings(target.app, thresholds);
|
|
3184
|
+
const floor = Math.max(minMB, Math.round(memMB * factor));
|
|
2670
3185
|
if (ovMB >= floor) return null;
|
|
2671
3186
|
return {
|
|
2672
3187
|
type: 'configAudit', property: 'spark.executor.memoryOverhead',
|
|
2673
3188
|
impactBand: 'info', metric: 'config', valueText: `${ovMB} MiB`,
|
|
2674
|
-
recommendation: `Executor memoryOverhead (${ovMB} MiB) is below
|
|
3189
|
+
recommendation: `Executor memoryOverhead (${ovMB} MiB) is below the ${floor} MiB Spark would have defaulted to (max of ${minMB} MiB or ${Math.round(factor * 1000) / 10}% of executor memory): raise it to avoid off-heap OOM-kills.`,
|
|
2675
3190
|
remediation: [increaseConf('spark.executor.memoryOverhead', `${floor}m`)],
|
|
2676
3191
|
};
|
|
2677
3192
|
},
|
|
2678
3193
|
estimate: noWasteModel,
|
|
2679
3194
|
}),
|
|
2680
|
-
// ── Plan-metric entries (scope:'sql') ────────────────────────────────────
|
|
2681
3195
|
defineSqlDetector({
|
|
2682
3196
|
type: 'duplicatePlanSubtree', order: 130, fixEffort: 'code', version: 2,
|
|
2683
3197
|
emits: ['duplicatePlanSubtree'],
|
|
2684
3198
|
docAnchor: '#bottleneck-duplicate-plan-subtree',
|
|
2685
3199
|
// stageFloorPct: the 0.5% runtime floor. The claim counts at most each linked stage's own
|
|
2686
3200
|
// task-active time, so a repeat whose stages together lasted less than this share of the run
|
|
2687
|
-
//
|
|
2688
|
-
// floor is why they're dropped. A repeat with no linked stage time is kept.
|
|
3201
|
+
// would grade info. A repeat with no linked stage time is kept.
|
|
2689
3202
|
thresholds: { minSubtreeSize: 3, minOccurrences: 2, stageFloorPct: 0.005 },
|
|
2690
3203
|
detect(sqlExec, ctx, thresholds) {
|
|
2691
3204
|
if (!sqlExec.planTree) return null;
|
|
@@ -2708,16 +3221,15 @@ export const DETECTORS = [
|
|
|
2708
3221
|
if (runMs != null && stagesMs > 0 && stagesMs < runMs * thresholds.stageFloorPct) return null;
|
|
2709
3222
|
const occurrencesIdentical = occurrencesHaveIdenticalDetails(g.nodes);
|
|
2710
3223
|
const stageShares = stageOperatorShares(nodes, operatorsByStage);
|
|
2711
|
-
// resolvePlanTree always sets id
|
|
3224
|
+
// resolvePlanTree always sets id, so the `!` here and below is safe.
|
|
2712
3225
|
const planNodeIds = nodes.map((n) => n.id ).filter(Boolean);
|
|
2713
3226
|
const detail = duplicateSubtreeDetail({ ...g, value: g.occurrences });
|
|
2714
3227
|
const differing = occurrencesIdentical ? '' : ` ${DUPLICATE_SUBTREE_DIFFERING_NOTE}`;
|
|
2715
3228
|
return {
|
|
2716
3229
|
type: 'duplicatePlanSubtree', executionId: sqlExec.id, stageIds, planNodeIds,
|
|
2717
3230
|
stageShares, occurrencesIdentical,
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
2720
|
-
// known to be recomputed), and neither does a subtree with no stage of its own.
|
|
3231
|
+
// Repeats with differing details get no estimate (nothing is known to be recomputed),
|
|
3232
|
+
// and neither does a subtree with no stage of its own, so this fallback band stands.
|
|
2721
3233
|
impactBand: occurrencesIdentical && Object.keys(stageShares).length > 0 ? 'warning' : 'info',
|
|
2722
3234
|
metric: 'subtreeOccurrences', value: g.occurrences,
|
|
2723
3235
|
rootName: g.rootName, subtreeSize: g.subtreeSize, sampleRelation: g.sampleRelation,
|
|
@@ -2737,7 +3249,7 @@ export const DETECTORS = [
|
|
|
2737
3249
|
// detect() reports subtreeOccurrences >= 2. Only repeats past the first are redundant:
|
|
2738
3250
|
// computing the subtree once is real work, so waste is (occurrences-1)/occurrences of the stages' time.
|
|
2739
3251
|
const occurrences = typeof finding.value === 'number' ? finding.value : 0;
|
|
2740
|
-
//
|
|
3252
|
+
// One occurrence repeats nothing: reached only with minOccurrences tuned below 2 or a malformed value.
|
|
2741
3253
|
if (occurrences < 2) return costOnly('none');
|
|
2742
3254
|
// Same-shaped repeats whose details differ compute different data: nothing is known to be
|
|
2743
3255
|
// recomputed, so there is no time to claim.
|
|
@@ -2753,8 +3265,8 @@ export const DETECTORS = [
|
|
|
2753
3265
|
const s = ctx.stages.get(id);
|
|
2754
3266
|
const share = shares ? (shares[id] ?? 0) : 1;
|
|
2755
3267
|
if (s && share > 0) {
|
|
2756
|
-
// Time with tasks running, not submit-to-complete: a stage
|
|
2757
|
-
//
|
|
3268
|
+
// Time with tasks running, not submit-to-complete: a stage waiting for cores isn't
|
|
3269
|
+
// recomputing anything.
|
|
2758
3270
|
const activeMs = s.taskActiveMs ?? Math.max(0, (s.completedAt ?? 0) - (s.submittedAt ?? 0));
|
|
2759
3271
|
wasteMsByStage.set(id, activeMs * share * redundantFraction);
|
|
2760
3272
|
}
|
|
@@ -2812,8 +3324,7 @@ export const DETECTORS = [
|
|
|
2812
3324
|
estimate(finding, ctx) {
|
|
2813
3325
|
const fileMs = ((finding.fileCount ) ?? 0) * FILE_OPEN_OVERHEAD_MS;
|
|
2814
3326
|
// A read's files are opened by the scan's tasks, in parallel: spread the per-file cost over
|
|
2815
|
-
// the most tasks its stages ran at once
|
|
2816
|
-
// stage that ran 314 tasks at once). A write keeps the serial sum: the job commit moves each
|
|
3327
|
+
// the most tasks its stages ran at once. A write keeps the serial sum: the job commit moves each
|
|
2817
3328
|
// output file on the driver, one after another.
|
|
2818
3329
|
const stageIds = finding.stageIds ;
|
|
2819
3330
|
let slots = 1;
|
|
@@ -2826,50 +3337,72 @@ export const DETECTORS = [
|
|
|
2826
3337
|
defineSqlDetector({
|
|
2827
3338
|
// Entry-level type is an identifier only; it never appears on an emitted finding. Findings
|
|
2828
3339
|
// carry 'underBroadcast'/'overBroadcast' since one shared plan-walk covers both
|
|
2829
|
-
// opposite-direction rules
|
|
2830
|
-
type: 'broadcastSizing', order: 132, fixEffort: 'config', version:
|
|
3340
|
+
// opposite-direction rules.
|
|
3341
|
+
type: 'broadcastSizing', order: 132, fixEffort: 'config', version: 3,
|
|
2831
3342
|
// Listed over-first: the two share order 132, and this list order is their display tie-break.
|
|
2832
3343
|
emits: ['overBroadcast', 'underBroadcast'],
|
|
2833
3344
|
docAnchor: '#bottleneck-broadcast-sizing',
|
|
2834
3345
|
thresholds: {
|
|
2835
|
-
broadcastTiers: [10 * MB, 100 * MB, GB
|
|
2836
|
-
comparisonTiers: [10 * GB, 300 * GB
|
|
3346
|
+
broadcastTiers: [10 * MB, 100 * MB, GB],
|
|
3347
|
+
comparisonTiers: [10 * GB, 300 * GB],
|
|
2837
3348
|
overBroadcastBytes: GB,
|
|
3349
|
+
minSmallerSideBytes: MB,
|
|
2838
3350
|
},
|
|
2839
3351
|
detect(sqlExec, ctx, thresholds) {
|
|
2840
3352
|
if (!sqlExec.planTree) return null;
|
|
2841
|
-
const { broadcastTiers, comparisonTiers, overBroadcastBytes } = thresholds;
|
|
3353
|
+
const { broadcastTiers, comparisonTiers, overBroadcastBytes, minSmallerSideBytes } = thresholds;
|
|
2842
3354
|
const fallbackStageIds = stageIdsForSqlExec(sqlExec.id, ctx.stages);
|
|
3355
|
+
const app = queryApp(ctx.app, sqlExec.modifiedConfigs);
|
|
3356
|
+
const thresholdsFor = (adaptive ) => {
|
|
3357
|
+
const key = broadcastThresholdKey(app, adaptive);
|
|
3358
|
+
// A broadcast planned up front is admitted by the static threshold, one AQE converted at runtime
|
|
3359
|
+
// by the adaptive one, so an existing broadcast is judged against each that applied.
|
|
3360
|
+
const admittingKeys = key === BROADCAST_THRESHOLD_KEY ? [key] : [BROADCAST_THRESHOLD_KEY, key];
|
|
3361
|
+
return {
|
|
3362
|
+
key, threshold: effectiveBroadcastThreshold(app, key),
|
|
3363
|
+
admittingThresholds: admittingKeys.map((k) => ({ key: k, threshold: effectiveBroadcastThreshold(app, k) })),
|
|
3364
|
+
};
|
|
3365
|
+
};
|
|
3366
|
+
const staticThresholds = thresholdsFor(false);
|
|
3367
|
+
const adaptiveThresholds = thresholdsFor(true);
|
|
3368
|
+
// AdaptiveSparkPlan need not be the root: Spark 3.2-3.3 wraps only a write command's child.
|
|
3369
|
+
const underAdaptive = new Set ();
|
|
2843
3370
|
const out = [];
|
|
2844
|
-
walkPlanTree(sqlExec.planTree, (node) => {
|
|
2845
|
-
if (node.name === '
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
const
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
const
|
|
3371
|
+
walkPlanTree(sqlExec.planTree, (node, parent) => {
|
|
3372
|
+
if (node.name === 'AdaptiveSparkPlan' || (parent != null && underAdaptive.has(parent))) underAdaptive.add(node);
|
|
3373
|
+
const adaptive = underAdaptive.has(node);
|
|
3374
|
+
const { key, threshold, admittingThresholds } = adaptive ? adaptiveThresholds : staticThresholds;
|
|
3375
|
+
const candidate = node.name === 'SortMergeJoin' ? broadcastCandidate(node) : null;
|
|
3376
|
+
if (candidate) {
|
|
3377
|
+
const buildBytes = shuffleBytes(candidate.build);
|
|
3378
|
+
const otherBytes = shuffleBytes(candidate.stream);
|
|
3379
|
+
// Below the floor a broadcast saves nothing measurable, nor does broadcasting the larger side;
|
|
3380
|
+
// above the over-broadcast limit it would be flagged there instead.
|
|
3381
|
+
if (buildBytes >= minSmallerSideBytes && buildBytes <= overBroadcastBytes && buildBytes <= otherBytes) {
|
|
3382
|
+
const broadcastThreshold = threshold != null && threshold < 0 ? 'disabled'
|
|
3383
|
+
: threshold != null && threshold >= buildBytes ? 'notLimiting' : 'limits';
|
|
3384
|
+
const withinTiers = buildBytes < broadcastTiers[0]
|
|
3385
|
+
|| (buildBytes < broadcastTiers[1] && otherBytes > comparisonTiers[0])
|
|
3386
|
+
|| (buildBytes < broadcastTiers[2] && otherBytes > comparisonTiers[1]);
|
|
3387
|
+
if (broadcastThreshold === 'notLimiting' || withinTiers) {
|
|
3388
|
+
const contributors = [candidate.build, candidate.stream];
|
|
3389
|
+
const { joinType, buildSide } = candidate;
|
|
3390
|
+
const subject = `The ${buildSide} input to this ${joinType} Sort Merge Join (${formatBytes(buildBytes)})`;
|
|
3391
|
+
const otherSide = ` (the other side is ${formatBytes(otherBytes)})`;
|
|
2863
3392
|
out.push({
|
|
2864
3393
|
type: 'underBroadcast', executionId: sqlExec.id, stageIds: unionStageIds(contributors, fallbackStageIds),
|
|
2865
|
-
// resolvePlanTree always sets id; safe downstream of it.
|
|
2866
3394
|
planNodeIds: contributors.map((n) => n.id ).filter(Boolean),
|
|
2867
|
-
impactBand: 'info', metric: 'smallerSideBytes',
|
|
2868
|
-
|
|
3395
|
+
impactBand: 'info', metric: 'smallerSideBytes',
|
|
3396
|
+
value: buildBytes, largerSideBytes: otherBytes,
|
|
3397
|
+
joinType, buildSide, broadcastThreshold,
|
|
2869
3398
|
recommendation: broadcastThreshold === 'notLimiting'
|
|
2870
|
-
?
|
|
2871
|
-
|
|
2872
|
-
|
|
3399
|
+
? `${subject} is under the effective ${key} (${formatBytes(threshold )}) yet was not broadcast${otherSide}, so the threshold is not what stopped it: ${adaptive
|
|
3400
|
+
? `a join hint, a shuffle that had already run, or a low share of non-empty partitions (below ${NON_EMPTY_PARTITION_RATIO_KEY}) usually is. Consider a broadcast() hint or lowering ${NON_EMPTY_PARTITION_RATIO_KEY}.`
|
|
3401
|
+
: 'a join hint, missing table statistics, or a shuffle that had already run usually is. Consider a broadcast() hint or collecting statistics (ANALYZE TABLE).'}`
|
|
3402
|
+
: broadcastThreshold === 'disabled'
|
|
3403
|
+
? `${subject} was not broadcast${otherSide} because automatic broadcast is disabled (${key}=-1): this could have been a broadcast join. Set ${key} to a positive size or add a broadcast() hint.`
|
|
3404
|
+
: `${subject} is over the effective ${key} (${threshold == null ? 'not a size' : formatBytes(threshold)}) but small enough to broadcast${otherSide}: this could have been a broadcast join. Consider a broadcast() hint or raising ${key}.`,
|
|
3405
|
+
remediation: broadcastThreshold === 'notLimiting' ? [] : [increaseConf(key)],
|
|
2873
3406
|
});
|
|
2874
3407
|
}
|
|
2875
3408
|
}
|
|
@@ -2877,27 +3410,27 @@ export const DETECTORS = [
|
|
|
2877
3410
|
if (isBroadcastExchangeNode(node.name)) {
|
|
2878
3411
|
const m = (node.metrics ?? []).find(x => x.name === 'data size');
|
|
2879
3412
|
if (m && m.value > overBroadcastBytes) {
|
|
2880
|
-
// BroadcastExchange's own metrics are driver-computed and never on
|
|
2881
|
-
//
|
|
2882
|
-
// metrics, so only the child unions in.
|
|
3413
|
+
// BroadcastExchange's own metrics are driver-computed and never on a TaskEnd, so only
|
|
3414
|
+
// its child, which carries the executor-side metrics, contributes stage ids.
|
|
2883
3415
|
const child = (node.children ?? [])[0];
|
|
2884
|
-
// A threshold below the broadcast cannot have admitted it,
|
|
2885
|
-
const
|
|
2886
|
-
const autoBroadcastOff = threshold != null && threshold < 0;
|
|
2887
|
-
const hintForced =
|
|
3416
|
+
// A threshold below the broadcast cannot have admitted it: with none admitting it, a hint forced it.
|
|
3417
|
+
const admitted = admittingThresholds.filter((t) => t.threshold == null || t.threshold >= m.value);
|
|
3418
|
+
const autoBroadcastOff = admittingThresholds.every((t) => t.threshold != null && t.threshold < 0);
|
|
3419
|
+
const hintForced = admitted.length === 0;
|
|
2888
3420
|
const broadcastThreshold = autoBroadcastOff ? 'disabled' : hintForced ? 'notLimiting' : 'limits';
|
|
3421
|
+
const below = admittingThresholds.map((t) => `${t.key} (${t.threshold < 0 ? 'disabled' : formatBytes(t.threshold )})`).join(' and ');
|
|
3422
|
+
const admittedKeys = admitted.map((t) => t.key);
|
|
2889
3423
|
out.push({
|
|
2890
3424
|
type: 'overBroadcast', executionId: sqlExec.id,
|
|
2891
3425
|
stageIds: unionStageIds(child ? [child] : [], fallbackStageIds),
|
|
2892
|
-
// resolvePlanTree always sets id; safe downstream of it.
|
|
2893
3426
|
planNodeIds: [node.id ].filter(Boolean),
|
|
2894
3427
|
impactBand: 'warning', metric: 'broadcastBytes', value: m.value, broadcastThreshold,
|
|
2895
3428
|
recommendation: autoBroadcastOff
|
|
2896
3429
|
? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: automatic broadcast is already disabled, so remove the broadcast() hint that forced it.`
|
|
2897
3430
|
: hintForced
|
|
2898
|
-
? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold:
|
|
2899
|
-
: `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: check for a misapplied broadcast hint or a misconfigured
|
|
2900
|
-
remediation:
|
|
3431
|
+
? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: ${below} ${admittingThresholds.length > 1 ? 'are' : 'is'} below it, so a broadcast() hint forced it: remove the hint.`
|
|
3432
|
+
: `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: check for a misapplied broadcast hint or a misconfigured ${admittedKeys.join(' or ')}.`,
|
|
3433
|
+
remediation: admittedKeys.map((k) => decreaseConf(k)),
|
|
2901
3434
|
});
|
|
2902
3435
|
}
|
|
2903
3436
|
}
|
|
@@ -2906,15 +3439,138 @@ export const DETECTORS = [
|
|
|
2906
3439
|
},
|
|
2907
3440
|
estimate(finding, ctx) {
|
|
2908
3441
|
// Both finding types carry bytes as `value`: overBroadcast's broadcastBytes, underBroadcast's
|
|
2909
|
-
// smallerSideBytes (
|
|
3442
|
+
// smallerSideBytes (its build side), each priced as one broadcast transfer.
|
|
2910
3443
|
const wasteMs = (((finding.value ) ?? 0) / BROADCAST_BANDWIDTH_BPS) * 1000;
|
|
2911
3444
|
return stageMappableWasteOrCostOnly(wasteMs, finding.stageIds , ctx);
|
|
2912
3445
|
},
|
|
2913
3446
|
}),
|
|
3447
|
+
defineSqlDetector({
|
|
3448
|
+
type: 'pythonUdf', order: 134, fixEffort: 'code', version: 1,
|
|
3449
|
+
emits: ['pythonUdf'],
|
|
3450
|
+
docAnchor: '#pyspark',
|
|
3451
|
+
// Unvalidated against a real workload (no corpus or private log runs a Python UDF): set high so
|
|
3452
|
+
// only a stage that moved a lot of data for a long time is flagged.
|
|
3453
|
+
thresholds: { minBytesSent: 64 * MB, minStageMs: 30_000 },
|
|
3454
|
+
detect(sqlExec, ctx, thresholds) {
|
|
3455
|
+
if (!sqlExec.planTree) return null;
|
|
3456
|
+
const { minBytesSent, minStageMs } = thresholds;
|
|
3457
|
+
// BatchEvalPython runs a row-at-a-time UDF; ArrowEvalPython (an Arrow-optimized or pandas UDF)
|
|
3458
|
+
// is already the fix. Both Python byte metrics are executor-side; a plan that reports no sent
|
|
3459
|
+
// bytes is skipped.
|
|
3460
|
+
const nodes = [];
|
|
3461
|
+
let sentBytes = 0;
|
|
3462
|
+
let returnedBytes = 0;
|
|
3463
|
+
let reportsReturned = false;
|
|
3464
|
+
walkPlanTree(sqlExec.planTree, (node) => {
|
|
3465
|
+
if (!isBatchEvalPythonNode(node)) return;
|
|
3466
|
+
const sent = node.metrics?.find((m) => m.name === PYTHON_DATA_SENT)?.value;
|
|
3467
|
+
if (sent == null || !(node.stageIds?.length)) return;
|
|
3468
|
+
nodes.push(node);
|
|
3469
|
+
sentBytes += sent;
|
|
3470
|
+
const returned = node.metrics?.find((m) => m.name === PYTHON_DATA_RETURNED)?.value;
|
|
3471
|
+
if (returned != null) { returnedBytes += returned; reportsReturned = true; }
|
|
3472
|
+
});
|
|
3473
|
+
if (nodes.length === 0 || !(sentBytes >= minBytesSent)) return null;
|
|
3474
|
+
const stageIds = unionStageIds(nodes, []);
|
|
3475
|
+
const stageDurationMs = stageIds.reduce((sum, id) => {
|
|
3476
|
+
const stage = ctx.stages.get(id);
|
|
3477
|
+
return sum + Math.max(0, (stage?.completedAt ?? 0) - (stage?.submittedAt ?? 0));
|
|
3478
|
+
}, 0);
|
|
3479
|
+
if (!(stageDurationMs >= minStageMs)) return null;
|
|
3480
|
+
|
|
3481
|
+
const arrowFix = pythonUdfArrowFix(queryApp(ctx.app, sqlExec.modifiedConfigs));
|
|
3482
|
+
const traffic = `sent ${formatBytes(sentBytes)} to Python workers${reportsReturned ? ` and received ${formatBytes(returnedBytes)} back` : ''}`;
|
|
3483
|
+
return [{
|
|
3484
|
+
type: 'pythonUdf', executionId: sqlExec.id, stageIds, planNodeIds: nodes.flatMap(n => (n.id ? [n.id] : [])),
|
|
3485
|
+
impactBand: 'info',
|
|
3486
|
+
metric: 'dataSentBytes', value: sentBytes,
|
|
3487
|
+
dataSentBytes: sentBytes, dataReturnedBytes: reportsReturned ? returnedBytes : null, stageDurationMs,
|
|
3488
|
+
recommendation: `Row-at-a-time Python UDFs (BatchEvalPython) ${traffic} over ${formatDuration(stageDurationMs)} of stage time: ${arrowFix.text}`,
|
|
3489
|
+
remediation: arrowFix.remediation,
|
|
3490
|
+
}];
|
|
3491
|
+
},
|
|
3492
|
+
estimate: noWasteModel,
|
|
3493
|
+
}),
|
|
3494
|
+
defineSqlDetector({
|
|
3495
|
+
type: 'nestedLoopJoin', order: 133, fixEffort: 'code', version: 1,
|
|
3496
|
+
emits: ['nestedLoopJoin'],
|
|
3497
|
+
docAnchor: '#joins',
|
|
3498
|
+
// minOutputRows: below it the join is cheap whatever its shape. minExpansion: a
|
|
3499
|
+
// BroadcastNestedLoopJoin's output must be this many times its larger input, so a join that
|
|
3500
|
+
// keeps its rows, or a cross join against a one-row side, is not an explosion. The stage time the
|
|
3501
|
+
// finding is graded on decides how much it matters.
|
|
3502
|
+
thresholds: { minOutputRows: 1_000_000, minExpansion: 10 },
|
|
3503
|
+
detect(sqlExec, _ctx, thresholds) {
|
|
3504
|
+
if (!sqlExec.planTree) return null;
|
|
3505
|
+
const out = [];
|
|
3506
|
+
walkPlanTree(sqlExec.planTree, (node) => {
|
|
3507
|
+
const shape = parseNestedLoopJoin(node);
|
|
3508
|
+
if (!shape || node.children.length !== 2) return;
|
|
3509
|
+
const outputRows = ownOutputRows(node);
|
|
3510
|
+
if (outputRows == null || outputRows < thresholds.minOutputRows) return;
|
|
3511
|
+
// A CartesianProduct re-reads its inputs (UnsafeCartesianRDD in joins/CartesianProductExec
|
|
3512
|
+
// computes every left and right partition pair, so each input partition is read once per
|
|
3513
|
+
// partition of the other side), so the executors' input counts exceed the real row counts and cannot be compared with the output. It runs only when neither side can
|
|
3514
|
+
// be broadcast, and its output is the product of the inputs, so size alone qualifies it.
|
|
3515
|
+
const counted = shape.operator === 'CartesianProduct';
|
|
3516
|
+
const leftRows = counted ? null : outputRowsOf(node.children[0]);
|
|
3517
|
+
const rightRows = counted ? null : outputRowsOf(node.children[1]);
|
|
3518
|
+
let expansion = null;
|
|
3519
|
+
if (!counted) {
|
|
3520
|
+
// Without both input counts "far above both inputs" cannot be shown.
|
|
3521
|
+
if (leftRows == null || rightRows == null) return;
|
|
3522
|
+
expansion = outputRows / Math.max(leftRows, rightRows, 1);
|
|
3523
|
+
if (expansion < thresholds.minExpansion) return;
|
|
3524
|
+
}
|
|
3525
|
+
// Only the stages that run the join: the whole execution's stages would claim time the
|
|
3526
|
+
// join never spent.
|
|
3527
|
+
const stageIds = unionStageIds([node], []);
|
|
3528
|
+
const advice = shape.condition == null
|
|
3529
|
+
? 'Confirm the cross join is intended, or add a join key so the rows are matched instead of multiplied.'
|
|
3530
|
+
: 'Add an equi-join key so Spark can use a hash or sort-merge join; for a range condition, bucket the range and join on the bucket as well.';
|
|
3531
|
+
const on = shape.condition == null ? '' : ` on ${shape.condition}`;
|
|
3532
|
+
out.push({
|
|
3533
|
+
type: 'nestedLoopJoin', executionId: sqlExec.id, stageIds,
|
|
3534
|
+
planNodeIds: [node.id ].filter(Boolean),
|
|
3535
|
+
impactBand: 'info',
|
|
3536
|
+
metric: 'outputRows', value: outputRows,
|
|
3537
|
+
nodeName: shape.operator, joinType: shape.joinType, condition: shape.condition,
|
|
3538
|
+
outputRows, leftRows, rightRows,
|
|
3539
|
+
confidence: 'medium',
|
|
3540
|
+
validationRequired: 'Row counts are the executors\' measured values; the time graded is the whole stage that runs the join, which also covers its other operators.',
|
|
3541
|
+
recommendation: `${shape.operator}${on} produced ${outputRows.toLocaleString('en-US')} rows${expansion == null
|
|
3542
|
+
? shape.condition == null
|
|
3543
|
+
? ', every left row paired with every right row.'
|
|
3544
|
+
// The condition filtered the pairings: the output is the rows it kept, not the product.
|
|
3545
|
+
: ': the pairs its condition kept from every left and right pairing it compared.'
|
|
3546
|
+
: ` from ${leftRows .toLocaleString('en-US')} and ${rightRows .toLocaleString('en-US')} input rows (${Math.round(expansion).toLocaleString('en-US')}x the larger side).`} ${advice}`,
|
|
3547
|
+
remediation: [codeFix(advice)],
|
|
3548
|
+
});
|
|
3549
|
+
});
|
|
3550
|
+
return out.length > 0 ? out : null;
|
|
3551
|
+
},
|
|
3552
|
+
estimate(finding, ctx) {
|
|
3553
|
+
const stageIds = finding.stageIds ;
|
|
3554
|
+
if (!stageIds || stageIds.length === 0) return costOnly('none');
|
|
3555
|
+
// The join's cost is the time of the stages that run it: a nested-loop join is O(n x m)
|
|
3556
|
+
// inside them. Time with tasks running, not submit-to-complete (a stage waiting for cores
|
|
3557
|
+
// is not looping).
|
|
3558
|
+
const wasteMsByStage = new Map ();
|
|
3559
|
+
for (const id of stageIds) {
|
|
3560
|
+
const s = ctx.stages.get(id);
|
|
3561
|
+
if (s) wasteMsByStage.set(id, s.taskActiveMs ?? Math.max(0, (s.completedAt ?? 0) - (s.submittedAt ?? 0)));
|
|
3562
|
+
}
|
|
3563
|
+
if (wasteMsByStage.size === 0) return costOnly('none');
|
|
3564
|
+
const totalMs = [...wasteMsByStage.values()].reduce((sum, ms) => sum + ms, 0);
|
|
3565
|
+
const rawWaste = { value: totalMs, unit: 'ms' };
|
|
3566
|
+
return multiStageImpact([...wasteMsByStage.keys()], wasteMsByStage, ctx, 'modeled', rawWaste)
|
|
3567
|
+
?? costOnly('modeled', rawWaste);
|
|
3568
|
+
},
|
|
3569
|
+
}),
|
|
2914
3570
|
] ;
|
|
2915
3571
|
|
|
2916
3572
|
/** The entry that emits each finding type: the one whose estimate prices it and whose thresholds
|
|
2917
|
-
* and order describe it. Several entries can emit one type (the
|
|
3573
|
+
* and order describe it. Several entries can emit one type (the three configAudit audits), and the
|
|
2918
3574
|
* first declared wins. */
|
|
2919
3575
|
export const ENTRY_BY_TYPE = (() => {
|
|
2920
3576
|
const byType = new Map ();
|
|
@@ -2954,3 +3610,9 @@ export const ENTRY_BY_TYPE = (() => {
|
|
|
2954
3610
|
|
|
2955
3611
|
|
|
2956
3612
|
|
|
3613
|
+
|
|
3614
|
+
/** skew's thresholds for a run: its defaults with the user's `skew` overrides merged in. The one
|
|
3615
|
+
* resolution analyze() hands to both skew (through withThresholds) and straggler (DetectorCtx). */
|
|
3616
|
+
export function skewThresholdsFor(overrides ) {
|
|
3617
|
+
return mergeThresholds('skew', ENTRY_BY_TYPE.get('skew') .thresholds, overrides?.skew ?? {}) ;
|
|
3618
|
+
}
|