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.
Files changed (445) hide show
  1. package/index.js +16 -7
  2. package/package.json +1 -1
  3. package/public/assets/AutoscalingChurn-DenWKTv-.js +1 -0
  4. package/public/assets/CacheUtilization-DLz7NdjV.js +1 -0
  5. package/public/assets/CachingOpportunity-zpQIqhzm.js +1 -0
  6. package/public/assets/ChartTheme-DAiARbLP.js +8 -0
  7. package/public/assets/ColdStart-C-9HjxWl.js +1 -0
  8. package/public/assets/ConfigAudit-D0V9tcrJ.js +1 -0
  9. package/public/assets/CoreUsageArea-yeerrljd.js +1 -0
  10. package/public/assets/Dashboard-DkUPrHKV.js +1 -0
  11. package/public/assets/DisclosureContext-DK1_iz6J.js +1 -0
  12. package/public/assets/DuplicatePlanSubtree-xDNDbCd3.js +1 -0
  13. package/public/assets/DurationHistogram-B6jsE0Ov.js +1 -0
  14. package/public/assets/EmptyState-C1uL40VU.js +1 -0
  15. package/public/assets/ExecutorUtilization-DXDZKmSc.js +1 -0
  16. package/public/assets/GcPressure-MRf7I4IG.js +1 -0
  17. package/public/assets/ImpactEstimate-Dm3xWbzV.js +1 -0
  18. package/public/assets/IncompleteRun-CaLOhMtf.js +1 -0
  19. package/public/assets/JobFailures-CzmzlvhO.js +1 -0
  20. package/public/assets/MemoryUtilization-AWUMUjCH.js +1 -0
  21. package/public/assets/NestedLoopJoin-C9sNr6Ss.js +1 -0
  22. package/public/assets/OverBroadcast-BOmRE6ca.js +1 -0
  23. package/public/assets/PartitionHint-BL2EmJfE.js +1 -0
  24. package/public/assets/PartitionSizing-BzBySvUq.js +1 -0
  25. package/public/assets/PlanExplorer-DdiQRcJU.js +1 -0
  26. package/public/assets/PlanGraphRoute-CIH9gBuR.js +1 -0
  27. package/public/assets/PlanView-B1hxzFRs.js +2 -0
  28. package/public/assets/PythonUdf-DvgCCueD.js +1 -0
  29. package/public/assets/RetryWaste-uXXu_CyM.js +1 -0
  30. package/public/assets/RowPagination-C-LzWkI5.js +1 -0
  31. package/public/assets/RowStatusCluster-Dbtmte7l.js +1 -0
  32. package/public/assets/ShuffleIO-nALwzC1_.js +1 -0
  33. package/public/assets/Skew-CHMEKgvk.js +1 -0
  34. package/public/assets/SlowHost-yyvjpHk6.js +1 -0
  35. package/public/assets/SmallFiles-y6odJ97n.js +1 -0
  36. package/public/assets/SpeculationWaste-C-SD2LOt.js +1 -0
  37. package/public/assets/Spill-DHTMmOup.js +1 -0
  38. package/public/assets/StageFailed-CQ3DxC9R.js +1 -0
  39. package/public/assets/StageFindingGroup-BgYLyj5q.js +1 -0
  40. package/public/assets/StageHeader-CIETkjVZ.js +1 -0
  41. package/public/assets/StagePill--P7PDd3s.js +1 -0
  42. package/public/assets/StageShape-BIv8vx_o.js +1 -0
  43. package/public/assets/StageSlowness-MMqzERxN.js +1 -0
  44. package/public/assets/Straggler-BgrY7AyW.js +1 -0
  45. package/public/assets/TaskFailures-BHHyKYTs.js +1 -0
  46. package/public/assets/TinyTask-DDScNhCl.js +1 -0
  47. package/public/assets/UnderBroadcast-CGIC04TI.js +1 -0
  48. package/public/assets/WidgetLeadSummary-CXFZP00C.js +1 -0
  49. package/public/assets/button-DbpTz2yk.js +1 -0
  50. package/public/assets/charts-vendor-CtTxwTeu.js +59 -0
  51. package/public/assets/composite-C9Nu2EI9.js +1 -0
  52. package/public/assets/d3-vendor-DNlD2-cz.js +1 -0
  53. package/public/assets/detector-registry-Bp8hrLua.js +2 -0
  54. package/public/assets/downsample-WmNM1qc9.js +1 -0
  55. package/public/assets/finding-anchor-Dr40LkS9.js +1 -0
  56. package/public/assets/findings-of-type-BL2u3RGZ.js +1 -0
  57. package/public/assets/impact-sort-mC5ISgX8.js +1 -0
  58. package/public/assets/inbox-KPU1Gppm.js +1 -0
  59. package/public/assets/index-CaRVPvAT.css +2 -0
  60. package/public/assets/index-fDiwfuoO.js +14 -0
  61. package/public/assets/inertValue-L9VxV0VE.js +1 -0
  62. package/public/assets/instrument-sans-latin-ext-wdth-normal-C5E2Gvlv.woff2 +0 -0
  63. package/public/assets/instrument-sans-latin-wdth-normal-BVScPF0l.woff2 +0 -0
  64. package/public/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  65. package/public/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  66. package/public/assets/layout.worker-CLcBHxVp.js +3 -0
  67. package/public/assets/{parser-worker-CA5RxqKc.js → parser-worker-CRIOPMOW.js} +8 -8
  68. package/public/assets/plan-graph-vendor-Ce6d4HU6.js +7 -0
  69. package/public/assets/react-vendor-C6KcUD36.js +9 -0
  70. package/public/assets/select-BHqOqun5.js +1 -0
  71. package/public/assets/toggle-DPeN6aNa.js +1 -0
  72. package/public/assets/toggle-group-Kl3uyHSr.js +1 -0
  73. package/public/assets/usePagedRows-BafD6bEN.js +1 -0
  74. package/public/assets/useRecentFiles-DzJZAoOT.js +3 -0
  75. package/public/assets/useRegisterFieldControl-pQv-ZDoU.js +1 -0
  76. package/public/assets/useScrollLock-BCxn_dMp.js +1 -0
  77. package/public/assets/useSortMode-DjrAN-z4.js +1 -0
  78. package/public/docs/404.html +3 -7
  79. package/public/docs/assets/{app.eYTPqTeq.js → app.1gvzd5Zb.js} +1 -1
  80. package/public/docs/assets/chunks/@localSearchIndexroot.DS2TQP5_.js +1 -0
  81. package/public/docs/assets/chunks/{VPLocalSearchBox.DsZrQKQR.js → VPLocalSearchBox.CNxT3s0I.js} +1 -1
  82. package/public/docs/assets/chunks/{theme.DsTNE2pE.js → theme.C5lLqUv7.js} +2 -2
  83. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.js +1 -0
  84. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.lean.js +1 -0
  85. 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
  86. package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.ajUAVdbc.js +1 -0
  87. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.js +1 -0
  88. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.lean.js +1 -0
  89. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.CHHgywoP.js +1 -0
  90. package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.wj6Et6UD.lean.js → contributor-guide_architecture_detector-contract.md.CHHgywoP.lean.js} +1 -1
  91. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.js +1 -0
  92. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.lean.js +1 -0
  93. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.WH4tRHPL.js +1 -0
  94. 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
  95. package/public/docs/assets/{contributor-guide_architecture_drill-down.md.CqZPrdo9.js → contributor-guide_architecture_drill-down.md.yB-Hz2XT.js} +1 -1
  96. 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
  97. package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.BQZE04sw.js +1 -0
  98. 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
  99. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.K71YB-Rr.js +1 -0
  100. 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
  101. package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.DLCAffwn.js +1 -0
  102. 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
  103. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.js +1 -0
  104. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.lean.js +1 -0
  105. package/public/docs/assets/contributor-guide_architecture_overview.md.nxkpOhgf.js +1 -0
  106. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.U-9KMDFK.js +1 -0
  107. 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
  108. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.js +1 -0
  109. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.js +1 -0
  110. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.lean.js +1 -0
  111. 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
  112. 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
  113. package/public/docs/assets/{contributor-guide_architecture_worker-protocol.md.BAnhi2-d.js → contributor-guide_architecture_worker-protocol.md.Br6OE7RW.js} +1 -1
  114. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.Br6OE7RW.lean.js +1 -0
  115. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.IVyaXrGe.js +1 -0
  116. 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
  117. 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
  118. 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
  119. 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
  120. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.CtmCU57I.js +1 -0
  121. 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
  122. package/public/docs/assets/contributor-guide_contributing.md.DBW4drtZ.js +1 -0
  123. package/public/docs/assets/{contributor-guide_development-setup.md.D9bu4j_c.js → contributor-guide_development-setup.md.B21ZTDhn.js} +3 -3
  124. package/public/docs/assets/contributor-guide_development-setup.md.B21ZTDhn.lean.js +1 -0
  125. package/public/docs/assets/contributor-guide_testing.md.DpkozNoG.js +4 -0
  126. package/public/docs/assets/{contributor-guide_testing.md.By6XGR9a.lean.js → contributor-guide_testing.md.DpkozNoG.lean.js} +1 -1
  127. package/public/docs/assets/{index.md.CHJVslga.js → index.md.lMm7SUMz.js} +1 -1
  128. package/public/docs/assets/{index.md.CHJVslga.lean.js → index.md.lMm7SUMz.lean.js} +1 -1
  129. package/public/docs/assets/instrument-sans-latin-ext-wdth-normal.C5E2Gvlv.woff2 +0 -0
  130. package/public/docs/assets/instrument-sans-latin-wdth-normal.BVScPF0l.woff2 +0 -0
  131. package/public/docs/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  132. package/public/docs/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  133. package/public/docs/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  134. package/public/docs/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  135. package/public/docs/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  136. package/public/docs/assets/style.Cfxqlj0y.css +1 -0
  137. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.C0pcQoNc.js +29 -0
  138. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.C0pcQoNc.lean.js +1 -0
  139. package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.CGaxPX_1.js +1 -0
  140. 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
  141. 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
  142. package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.js → user-guide_mcp-tools.md.49r3L2YE.js} +1 -1
  143. 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
  144. package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.BQ_kIptC.lean.js +1 -0
  145. package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.azCZXkH8.js +44 -0
  146. 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
  147. package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.js → user-guide_mcp-tools_evaluate-budgets.md.dpQpRQ7z.js} +2 -2
  148. 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
  149. 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
  150. 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
  151. 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
  152. package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.js +1 -0
  153. package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.lean.js +1 -0
  154. package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.js +1 -0
  155. package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.lean.js +1 -0
  156. package/public/docs/contributor-guide/architecture/board-widgets/caching-and-memory.html +6 -10
  157. package/public/docs/contributor-guide/architecture/board-widgets/plan-advisor.html +6 -10
  158. package/public/docs/contributor-guide/architecture/board-widgets/visual-system-and-templating.html +6 -10
  159. package/public/docs/contributor-guide/architecture/board-widgets.html +6 -10
  160. package/public/docs/contributor-guide/architecture/detector-contract/plan-attribution.html +6 -10
  161. package/public/docs/contributor-guide/architecture/detector-contract/threshold-tables.html +6 -10
  162. package/public/docs/contributor-guide/architecture/detector-contract.html +6 -10
  163. package/public/docs/contributor-guide/architecture/drill-down/plan-graph.html +6 -10
  164. package/public/docs/contributor-guide/architecture/drill-down.html +6 -10
  165. package/public/docs/contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.html +6 -10
  166. package/public/docs/contributor-guide/architecture/impact-estimation/stage-union-rollup.html +6 -10
  167. package/public/docs/contributor-guide/architecture/impact-estimation.html +6 -10
  168. package/public/docs/contributor-guide/architecture/index.html +5 -9
  169. package/public/docs/contributor-guide/architecture/overview.html +6 -10
  170. package/public/docs/contributor-guide/architecture/state-and-history/history-server-intake.html +5 -9
  171. package/public/docs/contributor-guide/architecture/state-and-history/run-comparison-internals.html +6 -10
  172. package/public/docs/contributor-guide/architecture/state-and-history.html +5 -9
  173. package/public/docs/contributor-guide/architecture/widget-rendering/findings-tab.html +6 -10
  174. package/public/docs/contributor-guide/architecture/widget-rendering/investigation-routing.html +5 -9
  175. package/public/docs/contributor-guide/architecture/widget-rendering/list-sort-mode.html +6 -10
  176. package/public/docs/contributor-guide/architecture/widget-rendering/reference-section.html +5 -9
  177. package/public/docs/contributor-guide/architecture/widget-rendering/row-expand-contract.html +6 -10
  178. package/public/docs/contributor-guide/architecture/widget-rendering.html +6 -10
  179. package/public/docs/contributor-guide/architecture/worker-protocol/cli-and-mcp.html +6 -10
  180. package/public/docs/contributor-guide/architecture/worker-protocol/core-and-event-validation.html +6 -10
  181. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-availability.html +7 -11
  182. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-report.html +6 -10
  183. package/public/docs/contributor-guide/architecture/worker-protocol.html +6 -10
  184. package/public/docs/contributor-guide/contributing.html +6 -10
  185. package/public/docs/contributor-guide/development-setup.html +8 -12
  186. package/public/docs/contributor-guide/testing.html +9 -10
  187. package/public/docs/favicon.svg +2 -2
  188. package/public/docs/hashmap.json +1 -1
  189. package/public/docs/index.html +6 -10
  190. package/public/docs/logo-dark.svg +1 -0
  191. package/public/docs/logo-light.svg +1 -0
  192. package/public/docs/tuning-reference/anti-patterns.html +5 -9
  193. package/public/docs/tuning-reference/aqe.html +5 -9
  194. package/public/docs/tuning-reference/bottleneck-broadcast-sizing.html +5 -9
  195. package/public/docs/tuning-reference/bottleneck-cold-start.html +5 -9
  196. package/public/docs/tuning-reference/bottleneck-duplicate-plan-subtree.html +5 -9
  197. package/public/docs/tuning-reference/bottleneck-failures.html +5 -9
  198. package/public/docs/tuning-reference/bottleneck-gc.html +5 -9
  199. package/public/docs/tuning-reference/bottleneck-job-failure-rate.html +5 -9
  200. package/public/docs/tuning-reference/bottleneck-memory-utilization.html +5 -9
  201. package/public/docs/tuning-reference/bottleneck-retry-waste.html +5 -9
  202. package/public/docs/tuning-reference/bottleneck-shuffle.html +5 -9
  203. package/public/docs/tuning-reference/bottleneck-skew.html +5 -9
  204. package/public/docs/tuning-reference/bottleneck-slow-host.html +5 -9
  205. package/public/docs/tuning-reference/bottleneck-small-files.html +5 -9
  206. package/public/docs/tuning-reference/bottleneck-spill.html +5 -9
  207. package/public/docs/tuning-reference/bottleneck-straggler.html +5 -9
  208. package/public/docs/tuning-reference/bottleneck-tiny-tasks.html +5 -9
  209. package/public/docs/tuning-reference/bottleneck-utilization.html +5 -9
  210. package/public/docs/tuning-reference/caching.html +5 -9
  211. package/public/docs/tuning-reference/cluster-config.html +5 -9
  212. package/public/docs/tuning-reference/config.html +5 -9
  213. package/public/docs/tuning-reference/data-formats.html +5 -9
  214. package/public/docs/tuning-reference/index.html +5 -9
  215. package/public/docs/tuning-reference/intro.html +5 -9
  216. package/public/docs/tuning-reference/joins.html +5 -9
  217. package/public/docs/tuning-reference/memory-model.html +5 -9
  218. package/public/docs/tuning-reference/metrics.html +5 -9
  219. package/public/docs/tuning-reference/partitioning.html +5 -9
  220. package/public/docs/tuning-reference/pyspark.html +5 -9
  221. package/public/docs/tuning-reference/shuffle.html +5 -9
  222. package/public/docs/tuning-reference/spark-architecture.html +5 -9
  223. package/public/docs/tuning-reference/table-formats.html +5 -9
  224. package/public/docs/user-guide/alternative-log-retrieval/managed-platforms.html +5 -9
  225. package/public/docs/user-guide/alternative-log-retrieval/ssh-bastion.html +5 -9
  226. package/public/docs/user-guide/alternative-log-retrieval.html +5 -9
  227. package/public/docs/user-guide/getting-started/ci-and-automation.html +17 -15
  228. package/public/docs/user-guide/getting-started/reading-the-dashboard.html +6 -10
  229. package/public/docs/user-guide/getting-started/regression-metric-keys.html +5 -9
  230. package/public/docs/user-guide/getting-started/tuning-thresholds.html +7 -11
  231. package/public/docs/user-guide/getting-started.html +5 -9
  232. package/public/docs/user-guide/mcp-tools/compare-runs.html +16 -15
  233. package/public/docs/user-guide/mcp-tools/diagnose-run.html +28 -11
  234. package/public/docs/user-guide/mcp-tools/evaluate-budgets.html +6 -10
  235. package/public/docs/user-guide/mcp-tools/get-finding-documentation.html +5 -9
  236. package/public/docs/user-guide/mcp-tools/get-finding-evidence.html +7 -10
  237. package/public/docs/user-guide/mcp-tools/get-reference-doc.html +5 -9
  238. package/public/docs/user-guide/mcp-tools/get-run-summary.html +6 -10
  239. package/public/docs/user-guide/mcp-tools/list-runs.html +5 -9
  240. package/public/docs/user-guide/mcp-tools.html +6 -10
  241. package/public/docs/user-guide/run-comparison/how-stages-are-matched.html +5 -9
  242. package/public/docs/user-guide/run-comparison.html +6 -10
  243. package/public/docs/user-guide/understanding-findings.html +6 -10
  244. package/public/export-template.html +46 -46
  245. package/public/favicon.svg +2 -2
  246. package/public/index.html +8 -10
  247. package/public/sample-runs/sample-run.ndjson.gz +0 -0
  248. package/vendor-core/allocation.js +126 -39
  249. package/vendor-core/analyzer.js +85 -58
  250. package/vendor-core/api-merge-writes.js +3 -0
  251. package/vendor-core/aqe-skew.js +347 -0
  252. package/vendor-core/cli/budgets.js +6 -4
  253. package/vendor-core/cli/collect-run-pair.js +89 -0
  254. package/vendor-core/cli/collect-run-worker.js +16 -0
  255. package/vendor-core/cli/collect-run.js +1 -2
  256. package/vendor-core/cli/native-zstd.js +12 -16
  257. package/vendor-core/comparability.js +234 -0
  258. package/vendor-core/comparison-output.js +15 -7
  259. package/vendor-core/comparison-verdict.js +105 -25
  260. package/vendor-core/core-source-hash.txt +1 -1
  261. package/vendor-core/core-time-series.js +0 -1
  262. package/vendor-core/core-usage-locality.js +1 -1
  263. package/vendor-core/delta-log-paths.js +10 -1
  264. package/vendor-core/detectors.js +1078 -416
  265. package/vendor-core/docs-config.js +0 -4
  266. package/vendor-core/docs-content/detection/cache.md +2 -1
  267. package/vendor-core/docs-content/detection/cfg.md +19 -11
  268. package/vendor-core/docs-content/detection/cold.md +2 -1
  269. package/vendor-core/docs-content/detection/gc.md +7 -3
  270. package/vendor-core/docs-content/detection/host.md +11 -1
  271. package/vendor-core/docs-content/detection/mem.md +17 -6
  272. package/vendor-core/docs-content/detection/part.md +3 -1
  273. package/vendor-core/docs-content/detection/plan.md +55 -13
  274. package/vendor-core/docs-content/detection/shfl.md +11 -5
  275. package/vendor-core/docs-content/detection/skew.md +48 -1
  276. package/vendor-core/docs-content/detection/slow.md +4 -2
  277. package/vendor-core/docs-content/detection/spec.md +6 -1
  278. package/vendor-core/docs-content/detection/spill.md +6 -3
  279. package/vendor-core/docs-content/detection/strag.md +19 -6
  280. package/vendor-core/docs-content/detection/tiny.md +9 -3
  281. package/vendor-core/docs-content/detection/util.md +6 -2
  282. package/vendor-core/effective-conf.js +7 -10
  283. package/vendor-core/efficiency-model.js +24 -16
  284. package/vendor-core/etl-phases.js +1 -2
  285. package/vendor-core/event-handlers.js +175 -57
  286. package/vendor-core/event-schemas.js +21 -16
  287. package/vendor-core/evidence-availability.js +9 -2
  288. package/vendor-core/evidence-report.js +118 -35
  289. package/vendor-core/executor-peaks.js +41 -0
  290. package/vendor-core/export-data.js +5 -5
  291. package/vendor-core/finding-identity.js +78 -0
  292. package/vendor-core/finding-presentation.js +129 -39
  293. package/vendor-core/finding-types.js +108 -30
  294. package/vendor-core/format-utils.js +14 -15
  295. package/vendor-core/html-export.js +1 -1
  296. package/vendor-core/impact-band.js +2 -2
  297. package/vendor-core/impact-format.js +47 -19
  298. package/vendor-core/impact-model.js +28 -17
  299. package/vendor-core/job-groups.js +4 -4
  300. package/vendor-core/mcp-server-factory.js +48 -12
  301. package/vendor-core/mcp-tools.js +179 -24
  302. package/vendor-core/median.js +2 -2
  303. package/vendor-core/nested-loop-join.js +73 -0
  304. package/vendor-core/occupancy.js +1 -2
  305. package/vendor-core/parser-worker.js +1 -1
  306. package/vendor-core/plan-graph-model.js +14 -22
  307. package/vendor-core/plan-node-detail.js +2 -3
  308. package/vendor-core/plan-summary.js +24 -19
  309. package/vendor-core/plan-tree-walk.js +6 -8
  310. package/vendor-core/python-stage.js +12 -1
  311. package/vendor-core/recommendation-rollup.js +13 -12
  312. package/vendor-core/redact.js +52 -46
  313. package/vendor-core/remediation.js +10 -5
  314. package/vendor-core/run-aggregates.js +6 -8
  315. package/vendor-core/run-comparison.js +226 -66
  316. package/vendor-core/run-identity.js +12 -0
  317. package/vendor-core/run-interpretation.js +21 -0
  318. package/vendor-core/run-metrics.js +2 -2
  319. package/vendor-core/run-output.js +44 -11
  320. package/vendor-core/run-totals.js +12 -0
  321. package/vendor-core/run-verdict.js +76 -3
  322. package/vendor-core/scaling-sim.js +3 -4
  323. package/vendor-core/snappy-block.js +2 -2
  324. package/vendor-core/spark-conf.js +168 -0
  325. package/vendor-core/sql-stages.js +2 -2
  326. package/vendor-core/stage-alignment.js +2 -12
  327. package/vendor-core/stage-identity.js +58 -3
  328. package/vendor-core/stage-label.js +14 -0
  329. package/vendor-core/stage-plan-nodes.js +17 -3
  330. package/vendor-core/stage-quantiles.js +141 -9
  331. package/vendor-core/stage-structure.js +17 -3
  332. package/vendor-core/threshold-overrides.js +9 -3
  333. package/vendor-core/types.js +70 -23
  334. package/vendor-core/wall-clock.js +8 -0
  335. package/vendor-core/wasted-core-hours.js +12 -12
  336. package/vendor-core/write-targets.js +146 -14
  337. package/vendor-core/zstd-worker-client.js +1 -1
  338. package/vendor-core/zstd-worker.js +1 -1
  339. package/public/assets/AutoscalingChurn-8t3U3isq.js +0 -1
  340. package/public/assets/CacheUtilization-CJs9tTum.js +0 -1
  341. package/public/assets/CachingOpportunity-D1NcHs0V.js +0 -1
  342. package/public/assets/ColdStart-CdRJ9gD8.js +0 -1
  343. package/public/assets/ConfigAudit-B5Gbe5js.js +0 -1
  344. package/public/assets/CoreUsageArea-a--NTRGY.js +0 -1
  345. package/public/assets/Dashboard-C1hNeX49.js +0 -2
  346. package/public/assets/DisclosureContext-ZpE6CkHx.js +0 -1
  347. package/public/assets/DuplicatePlanSubtree-dobXdz7E.js +0 -1
  348. package/public/assets/ExecutorUtilization-D9grwi7t.js +0 -1
  349. package/public/assets/GcPressure-8-G2ri10.js +0 -1
  350. package/public/assets/IncompleteRun-B91lky7a.js +0 -1
  351. package/public/assets/JobFailures-BsWvBMyJ.js +0 -1
  352. package/public/assets/MemoryUtilization-BM5schZI.js +0 -1
  353. package/public/assets/OverBroadcast-DdN8JIJH.js +0 -1
  354. package/public/assets/PartitionHint-CUvbnnxy.js +0 -1
  355. package/public/assets/PartitionSizing-CY5HDYtp.js +0 -1
  356. package/public/assets/PlanExplorer-DgHCDBD_.js +0 -1
  357. package/public/assets/PlanGraphRoute-DH3KJb8j.js +0 -1
  358. package/public/assets/RetryWaste-DOnE9vGF.js +0 -1
  359. package/public/assets/ShuffleIO-MMnDESkF.js +0 -1
  360. package/public/assets/Skew-BmZZJ5ms.js +0 -1
  361. package/public/assets/SlowHost-CsJzfEqo.js +0 -1
  362. package/public/assets/SmallFiles-BoK3R1Ex.js +0 -1
  363. package/public/assets/SpeculationWaste-CnVxB5AJ.js +0 -1
  364. package/public/assets/Spill-BhNicyBy.js +0 -1
  365. package/public/assets/StageFailed-mAhHU4l_.js +0 -1
  366. package/public/assets/StageFindingGroup-BEtPWcT0.js +0 -1
  367. package/public/assets/StageHeader-DJkYkrsU.js +0 -1
  368. package/public/assets/StageShape-BDxRknLp.js +0 -1
  369. package/public/assets/StageSlowness-BPRw-otN.js +0 -1
  370. package/public/assets/Straggler-CZCbnOWP.js +0 -1
  371. package/public/assets/TaskFailures-C57YcLFQ.js +0 -1
  372. package/public/assets/TinyTask-Bi84qU7n.js +0 -1
  373. package/public/assets/UnderBroadcast-DdQV7htE.js +0 -1
  374. package/public/assets/charts-vendor-0VMaLVLN.js +0 -59
  375. package/public/assets/finding-anchor-Cl6_5hfV.js +0 -1
  376. package/public/assets/impact-sort-CssZ6MT2.js +0 -1
  377. package/public/assets/index-2h_TgUAb.css +0 -2
  378. package/public/assets/index-B-03mG7K.js +0 -14
  379. package/public/assets/plan-graph-vendor-BYl-AzN5.js +0 -7
  380. package/public/assets/react-vendor-DEdfrRTJ.js +0 -9
  381. package/public/assets/select-CQjNhprm.js +0 -1
  382. package/public/assets/usePagedRows-Dyl6T8Ok.js +0 -1
  383. package/public/assets/useRecentFiles-XZEnLlal.js +0 -11
  384. package/public/assets/useSortMode-Dhvit8dt.js +0 -1
  385. package/public/docs/assets/chunks/@localSearchIndexroot.u_e5bQz4.js +0 -1
  386. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.js +0 -1
  387. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.lean.js +0 -1
  388. package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.js +0 -1
  389. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.js +0 -1
  390. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.lean.js +0 -1
  391. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.wj6Et6UD.js +0 -1
  392. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.js +0 -1
  393. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.lean.js +0 -1
  394. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.CKjWG56Y.js +0 -1
  395. package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.js +0 -1
  396. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.js +0 -1
  397. package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.js +0 -1
  398. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.js +0 -1
  399. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.lean.js +0 -1
  400. package/public/docs/assets/contributor-guide_architecture_overview.md.C1sSvm9i.js +0 -1
  401. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.js +0 -1
  402. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BOZ1azXG.js +0 -1
  403. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.js +0 -1
  404. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.lean.js +0 -1
  405. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BAnhi2-d.lean.js +0 -1
  406. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.DC1YqXBp.js +0 -1
  407. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.js +0 -1
  408. package/public/docs/assets/contributor-guide_contributing.md.CQMvGwFt.js +0 -1
  409. package/public/docs/assets/contributor-guide_development-setup.md.D9bu4j_c.lean.js +0 -1
  410. package/public/docs/assets/contributor-guide_testing.md.By6XGR9a.js +0 -1
  411. package/public/docs/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
  412. package/public/docs/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
  413. package/public/docs/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
  414. package/public/docs/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
  415. package/public/docs/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
  416. package/public/docs/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
  417. package/public/docs/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
  418. package/public/docs/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
  419. package/public/docs/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
  420. package/public/docs/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
  421. package/public/docs/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
  422. package/public/docs/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
  423. package/public/docs/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
  424. package/public/docs/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
  425. package/public/docs/assets/style.tmDhD3c8.css +0 -1
  426. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.OUQqw3mI.js +0 -23
  427. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.OUQqw3mI.lean.js +0 -1
  428. package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.js +0 -1
  429. package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.Cc_gLoix.lean.js +0 -1
  430. package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.js +0 -23
  431. package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.js +0 -1
  432. package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.lean.js +0 -1
  433. package/public/docs/assets/user-guide_understanding-findings.md.C9YRruMT.js +0 -1
  434. package/public/docs/assets/user-guide_understanding-findings.md.C9YRruMT.lean.js +0 -1
  435. /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
  436. /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
  437. /package/public/docs/assets/{contributor-guide_architecture_overview.md.C1sSvm9i.lean.js → contributor-guide_architecture_overview.md.nxkpOhgf.lean.js} +0 -0
  438. /package/public/docs/assets/{contributor-guide_architecture_widget-rendering.md.BOZ1azXG.lean.js → contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.lean.js} +0 -0
  439. /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
  440. /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
  441. /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
  442. /package/public/docs/assets/{contributor-guide_contributing.md.CQMvGwFt.lean.js → contributor-guide_contributing.md.DBW4drtZ.lean.js} +0 -0
  443. /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
  444. /package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.lean.js → user-guide_mcp-tools.md.49r3L2YE.lean.js} +0 -0
  445. /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
@@ -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 { computePeakConcurrentCores, computePeakConcurrentExecutorCount } from './core-count.js';
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
- // SpillPressureDetector (5a) + SpillSkewDetector (5b).
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
- // 5a: absolute volume, branching on task count.
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
- // 5b: ratio+floor skew (requires enough tasks).
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, per spec) + a digest
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 ?? []).map((m) => m.name).sort().join(',');
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 the 6.8 KB average join detail of the largest real
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 ?? []).map((m) => m.name).sort().join(',');
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
- // Defensive, not load-bearing: computePlanShapes's visit() already skips
401
- // straight through a read node to its write half's real children, so no
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. On the 14 real logs, 270 of 546 groups differed beyond that.
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 also let sibling groups claim the same stage twice. A WholeStageCodegen wrapper and
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; on the 14 real logs they were the
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
- // Byte size of a join-side subtree for broadcast sizing. Stops
520
- // descending at a node with a "data size" metric: that value already aggregates everything
521
- // beneath it, so summing further would double-count. Only recurses when no such metric.
522
- function sumBoundarySize(node ) {
523
- const m = (node.metrics ?? []).find(x => x.name === 'data size');
524
- if (m) return m.value;
525
- let sum = 0;
526
- for (const c of (node.children ?? [])) sum += sumBoundarySize(c);
527
- return sum;
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
- // Nodes that fed sumBoundarySize's total: mirrors its recursion exactly so implicated stageIds
531
- // line up with the size actually compared.
532
- function boundarySizeContributors(node ) {
533
- const m = (node.metrics ?? []).find((x) => x.name === 'data size');
534
- if (m) return [node];
535
- const out = [];
536
- for (const c of (node.children ?? [])) out.push(...boundarySizeContributors(c));
537
- return out;
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 top = samples.reduce((a, b) => (b.value > a.value ? b : a));
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
- // Spark's default for the properties a detector's fix reads or suggests, for a run that did not log
660
- // them. Undefined when the property is not modeled or its default depends on a version the log did
661
- // not record. A property is modeled only where its default has made a suggestion a no-op; every
662
- // other property a detector suggests defaults off, or to a value other than the suggested one.
663
- function versionDefault(app , key ) {
664
- if (key === 'spark.sql.shuffle.partitions') return '200';
665
- if (key === 'spark.sql.autoBroadcastJoinThreshold') return String(10 * MB);
666
- const version = /^(\d+)\.(\d+)/.exec(app?.sparkVersion ?? '');
667
- if (version == null) return undefined;
668
- const [major, minor] = [Number(version[1]), Number(version[2])];
669
- if (key === 'spark.sql.adaptive.enabled') return major > 3 || (major === 3 && minor >= 2) ? 'true' : 'false';
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
- // The run's effective value of a property: the logged one, else versionDefault's.
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?.config?.[key] ?? versionDefault(app, key))?.trim();
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 (a node's stageIds are empty in real logs), so the join is
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 (SKEW_JOIN_NODES.has(node.name)) hasJoin = true; });
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
- return { origin, ...skewJoinFix(ctx.app) };
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
- return switchFix(loggedAs(app, key, true), key, true,
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
- // Whether partition-count advice fits a shuffle-reading stage, from the effective conf and the
778
- // stage's own task sizes. The partition count that brings each shuffle partition down to the ideal
779
- // size is per stage; the property is job-wide. 'sufficient': the stage's tasks are already at or
780
- // under the ideal size. 'aqeCoalesced': AQE merged the property's partitions into fewer tasks, so
781
- // the advisory partition size is the lever. 'ownPartitioning': the property already gives at least
782
- // the count needed, so the stage's own repartition(n) or RDD parallelism limits it. Otherwise the
783
- // property limits the stage: 'raise'.
784
- function shufflePartitionCase(stage , app ) {
785
- const count = effectiveShufflePartitions(app);
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 (count != null && stage.taskCount < count
791
- && loggedAs(app, 'spark.sql.adaptive.enabled', true) && loggedAs(app, 'spark.sql.adaptive.coalescePartitions.enabled', true)) partitions = 'aqeCoalesced';
792
- else if (count != null && count >= needed) partitions = 'ownPartitioning';
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 , app ) {
801
- const { partitions, count, needed } = shufflePartitionCase(stage, app);
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: `spark.sql.shuffle.partitions is already ${count}, so raise this stage's own partition count (its repartition(n) or RDD parallelism) so each partition is smaller`,
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: 'raise spark.sql.shuffle.partitions so each partition is smaller',
819
- remediation: [increaseConf('spark.sql.shuffle.partitions', count == null ? null : needed)],
1149
+ text: `raise ${key} so each partition is smaller`,
1150
+ remediation: [increaseConf(key, count == null ? null : needed)],
820
1151
  };
821
1152
  }
822
1153
 
823
- // A Spark byte-size property as bytes (a plain number, or with a k/m/g/t suffix, optionally 'b'), or
824
- // null when it is not one. Negative numbers pass through: -1 turns auto-broadcast off.
825
- function parseSparkBytes(value ) {
826
- const m = /^(-?\d+(?:\.\d+)?)\s*([kmgt]?)b?$/i.exec(value?.trim() ?? '');
827
- if (m == null) return null;
828
- return m[2] === '' ? Number(m[1]) : Number(m[1]) * 1024 ** ('kmgt'.indexOf(m[2].toLowerCase()) + 1);
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 spark.sql.autoBroadcastJoinThreshold in bytes (logged, else Spark's 10 MiB);
832
- // negative when auto-broadcast is disabled, null when the logged value is not a size.
833
- function effectiveBroadcastThreshold(app ) {
834
- const raw = effectiveConf(app, 'spark.sql.autoBroadcastJoinThreshold');
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 whose logged conf turns it off.
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
- return app?.resources?.dynamicAllocationEnabled === false || app?.config?.['spark.dynamicAllocation.enabled']?.trim().toLowerCase() === 'false';
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 dedicated critical tier of its own (see the "no dedicated critical tier" comment
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
- thresholds: { ratioWarn: 3, minTasksForP95: 20, floorPctWarn: IMPACT_FLOOR_PCT_WARN },
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 result = computeSkewRatio(stage, thresholds.minTasksForP95);
1173
- if (result === null) return null;
1174
- const { ratio, metric } = result;
1175
- if (ratio <= thresholds.ratioWarn) return null;
1176
- // The claim estimate() reports as savings, clipped the same way, so the gate agrees with it.
1177
- const floorWasteMs = tailClaimFloorMs(skewTailClaim(stage, metric === 'P95/median'), stage.id, ctx);
1178
- if (!meetsRuntimeFloor(floorWasteMs, appDurationMs(ctx.app), thresholds.floorPctWarn)) return null;
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}×: ${fix.text}.`,
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; on
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 idleCoreMs =
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
- return costOnly('measured', { value: idleCoreMs, unit: 'coreMs', idle: true });
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 graded info: 182 of 284 shuffle findings on the 14 real logs. The shuffle
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.app);
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: `spark.sql.shuffle.partitions is already ${count}, so consider raising this stage's own partition count (its repartition(n) or RDD parallelism) or adding a broadcast join`, remediation: [] }
1315
- : { text: 'consider increasing spark.sql.shuffle.partitions or adding a broadcast join', remediation: [increaseConf('spark.sql.shuffle.partitions')] };
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
- return singleStageImpact(wasteMs, finding.stageId, ctx,
1336
- measuredMs != null && measuredMs < modeledMs ? 'measured' : 'modeled', { value: shuffleReadBytes, unit: 'bytes' });
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.app);
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 (12 of 38 spill findings on the 14 real
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 partitionAdvice = reads === 'shuffle';
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
- : partitionAdvice
1438
- ? `${formatBytes(stage.memoryBytesSpilled)} spilled: raise spark.sql.shuffle.partitions or increase executor memory.`
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' ? [] : [...(partitionAdvice ? [increaseConf('spark.sql.shuffle.partitions')] : []), increaseConf('spark.executor.memory')],
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 singleStageImpact(wasteMs, finding.stageId, ctx, 'modeled', { value: diskBytesSpilled, unit: 'bytes' });
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
- // Descending tier: ExecutorGcHeuristic, ported as-is.
2001
+ // A GC share below this reads as over-provisioned memory (the low-GC note).
1460
2002
  lowInfoPct100: 5,
1461
- // NOT SOURCED: our own noise floor so a stage that barely ran doesn't flag either direction.
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. On the 14 real logs that was 464 of 685 low-GC notes (of 701 gc
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: 1,
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 graded info, and a byte-dimension imbalance (no time estimate, so its ratio tier was
1535
- // its band) was graded info here since 6298149. So the stage is skipped: on the 14 real logs
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
- 'check what it was running, and consider enabling spark.speculation to relaunch a lagging task automatically',
1548
- 'check what it was running; speculation is already on, so a lagging task there is already relaunched');
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 dims = [
1595
- { dimension: 'taskTime', floor: floorMs, samples: execs.filter(e => e.taskCount > 0).map(e => ({ key: e.executorId, value: e.totalDuration / e.taskCount })) },
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 here: fixed fallback
1610
- // (its current floor case), overwritten by deriveImpactBand whenever this
1611
- // finding gets a real wallClock estimate. The other three dimensions never
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, _ctx, thresholds) {
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.partitions and default.parallelism size a shuffle's reduce side: a stage that reads
1666
- // no shuffle gets the input-partitioning remedy instead.
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 recommendation = reads === 'shuffle'
1669
- ? `This stage ran ${value} minutes with no more specific cause flagged: often a partition-count problem, raise parallelism via spark.sql.shuffle.partitions or spark.default.parallelism, or check for a large per-task data volume driving heavy shuffle and spill.`
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: reads === 'shuffle'
1678
- ? [increaseConf('spark.sql.shuffle.partitions'), increaseConf('spark.default.parallelism')]
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 (a 1-task JDBC count stage open 27 minutes on 5s of CPU
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. Scored against a task-level replay of every stage on 14 real logs
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 graded info. On the 14 real logs that
1771
- // was 671 of 753 straggler findings, none above info; the slow tail is still real on those
1772
- // stages, the floor is why they're dropped.
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
- if (stage.taskCount < thresholds.minTasks) return null;
1776
- const runMs = appDurationMs(ctx.app);
1777
- if (stageBelowRuntimeFloor(stage, ctx, thresholds.floorPctWarn)) return null;
1778
- const stragglerShare = (stage.stragglerCount ?? 0) / stage.taskCount;
1779
- const useSpeculative = (stage.speculativeTasks ?? 0) > 0;
1780
- if (!useSpeculative && stragglerShare <= thresholds.shareWarnAtFloor) return null;
1781
- const speculativeShare = useSpeculative ? stage.speculativeTasks / stage.taskCount : 0;
1782
- // The claim estimate() reports as savings: a high straggler/speculative share on a stage
1783
- // whose tasks barely vary models near-zero savings, so it must not outrank 'info'.
1784
- const floorWasteMs = tailClaimFloorMs(stragglerTailClaim(stage), stage.id, ctx);
1785
- const meetsWarnFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctWarn);
1786
- const meetsCritFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctCrit);
1787
- // The lower gate needs positive evidence the tail matters: meetsRuntimeFloor passes by
1788
- // default when the app's duration is unknown (an incomplete run), which isn't that.
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, impactBand, origin: fix.origin,
1811
- metric: useSpeculativeMetric ? 'speculativeTasks' : 'stragglerShare',
1812
- value,
1813
- unit: useSpeculativeMetric ? 'count' : 'pct',
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: useSpeculativeMetric
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: `${detail}: rule out a GC pause or a slow shuffle fetch before assuming a hardware issue; if uneven data is the cause, ${fix.text}.`,
1821
- remediation: fix.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, _ctx, thresholds) {
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 graded info: on the 14 real logs
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: reads === 'shuffle' ? [decreaseConf('spark.sql.shuffle.partitions')] : [],
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 (36-47s on every real log, whatever the
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
- // computePeakConcurrentCores (not executorsAdded.length/computeTotalCores): real concurrent
2013
- // capacity, not a cumulative sum that double-counts a churned-through executor against its
2014
- // replacement's (spot preemption, dynamicAllocation replacement).
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 rather than falling back to the
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 (sparkMeasure): metric only, no threshold.
2028
- let cpuUtilizationPct = null;
2029
- if (totalCores > 0) {
2030
- // Null when no stage recorded CPU time (older Spark), the same rule as the CLI metrics block.
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 = dynamicAllocationFix(app, 'consider reducing cluster size or enabling dynamic allocation',
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 appDurationMs = finding.appDurationMs ;
2052
- const totalCores = finding.totalCores ;
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
- const idleCoreHours = (1 - fraction) * appDurationMs * totalCores / 3.6e6;
2057
- return costOnly('measured', { value: idleCoreHours, unit: 'coreHours', idle: true });
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, // WastedCoresAlertsReducer
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, // UNVERIFIED
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
- // ── 1a idle-cores rate ────────────────────────────────────────────────
2610
+ // Idle-cores rate.
2088
2611
  const busyCoreMs = runAggregates?.busyCoreMs;
2089
- if (busyCoreMs != null && totalCores > 0) {
2090
- const capacityCoreMs = totalCores * appDurationMs;
2091
- const idleRate = capacityCoreMs > 0 ? 1 - (busyCoreMs / capacityCoreMs) : 0;
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 fix = dynamicAllocationFix(app, 'reduce cluster size or enable dynamic allocation',
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 sizing inputs for the impact estimator: `value` is rounded pct.
2100
- idleRateFraction: idleRate, allocatedMB, peakExecutors, appDurationMs,
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
- // ── 1b memory bands (executor only; driver half dropped since there is no driver metric) ─
2108
- // Peak heap per executor = max jvmHeapMemory across all stages' executorMetrics.
2109
- const peakHeapByExec = new Map ();
2110
- for (const s of stages.values()) {
2111
- const em = s.executorMetrics;
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
- const key = 'spark.eventLog.logStageExecutorMetrics';
2120
- const fix = switchFix(loggedAs(app, key, true), key, true,
2121
- `Per-executor memory usage requires ${key}=true: not enabled for this run.`,
2122
- 'Per-executor memory usage is missing from this log even though executor metrics logging is on for this run.');
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: fix.text,
2127
- remediation: fix.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
- for (const [execId, heap] of peakHeapByExec) {
2132
- const ratio = heap / allocatedBytes;
2133
- // The two bands are opposite signals: an explicit `rule` discriminator lets consumers
2134
- // tell OOM-risk from over-provisioning without re-deriving the ratio.
2135
- if (ratio > thresholds.bandTooSmall) {
2136
- out.push({
2137
- type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapNearCapacity',
2138
- stageId: null, executorId: execId,
2139
- impactBand: 'warning', metric: 'heapUsedRatio', value: Math.round(ratio * 100),
2140
- recommendation: `Executor ${execId} peaked at ${Math.round(ratio * 100)}% of allocated heap: memory may be too small; raise spark.executor.memory to avoid OOM/spill.`,
2141
- remediation: [increaseConf('spark.executor.memory')],
2142
- });
2143
- } else if (ratio < thresholds.bandTooHigh) {
2144
- out.push({
2145
- type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapOverProvisioned',
2146
- stageId: null, executorId: execId,
2147
- impactBand: 'info', metric: 'heapUsedRatio', value: Math.round(ratio * 100),
2148
- // Absolute figures behind the rounded ratio, for the estimator's
2149
- // unused-memory-over-time model.
2150
- allocatedBytes, heap, appDurationMs,
2151
- recommendation: `Executor ${execId} used only ${Math.round(ratio * 100)}% of allocated heap: memory may be over-provisioned; consider reducing spark.executor.memory for cost savings.`,
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
- // ── 1c Spark Memory Limit waste model (UNVERIFIED buffer) ─
2159
- if (allocatedMB != null && peakExecutors > 0) {
2160
- const allocatedMBSeconds = peakExecutors * allocatedMB * (appDurationMs / 1000);
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 same MB-seconds unit as wasteModel, so comparable.
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 allocatedMB = finding.allocatedMB ;
2189
- const peakExecutors = finding.peakExecutors ;
2190
- const appDurationMs = finding.appDurationMs ;
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 near-capacity band is an OOM-risk signal with
2198
- // no magnitude, and the dataUnavailable shape has no inputs: both stay informational.
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 appDurationMs = finding.appDurationMs ;
2203
- if (allocatedBytes != null && heap != null && appDurationMs != 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 * (appDurationMs / 1000);
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 = String(ctx.app?.config?.['spark.eventLog.logBlockUpdates.enabled']).toLowerCase() === 'true';
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 costOnly('modeled', { value: wasteMs, unit: 'ms' });
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
- const wasteMs = (totalReadBytes / RE_READ_THROUGHPUT_BPS) * 1000;
2550
- return costOnly('modeled', { value: wasteMs, unit: 'ms' });
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 config = app?.config ?? {}; const res = app?.resources ?? null;
3131
+ const app = target.app; const res = app?.resources ?? null;
2618
3132
  if (res?.dynamicAllocationEnabled !== true) return null;
2619
- const minN = config['spark.dynamicAllocation.minExecutors'] != null ? parseInt(config['spark.dynamicAllocation.minExecutors'], 10) : null;
2620
- const maxN = config['spark.dynamicAllocation.maxExecutors'] != null ? parseInt(config['spark.dynamicAllocation.maxExecutors'], 10) : null;
2621
- if (minN != null && maxN != null && minN > maxN) {
2622
- return {
2623
- type: 'configAudit', property: 'spark.dynamicAllocation.minExecutors',
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
- const ser = res?.serializer ?? config['spark.serializer'] ?? null;
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 floor = Math.max(thresholds.floorMB, Math.round(memMB * thresholds.floorPct));
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 Spark's default floor of ${floor} MiB (max of 384 MiB or 10% of executor memory): raise it to avoid off-heap OOM-kills.`,
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
- // graded info: 340 of 545 findings on the 14 real logs. The repeat is still in the plan; the
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; safe downstream of it.
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
- // Fixed fallback: overwritten by deriveImpactBand when this finding gets a real
2719
- // wallClock estimate. Repeats with differing details get no estimate (nothing is
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
- // Defensive only: minOccurrences guarantees occurrences >= 2 on real data; a malformed-value fallback.
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 left waiting for cores
2757
- // (2491s open, 60s of tasks on a real log) isn't recomputing anything while it waits.
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 (91344 files x 10ms is 913s, claimed against a 117s
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 (JoinToBroadcastAlert / BroadcastTooLargeAlert).
2830
- type: 'broadcastSizing', order: 132, fixEffort: 'config', version: 2,
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, 5 * GB],
2836
- comparisonTiers: [10 * GB, 300 * GB, TB],
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 === 'SortMergeJoin' && (node.children ?? []).length === 2) {
2846
- const [childA, childB] = node.children;
2847
- const a = sumBoundarySize(childA);
2848
- const b = sumBoundarySize(childB);
2849
- const smaller = Math.min(a, b);
2850
- const larger = Math.max(a, b);
2851
- if (smaller > 0) {
2852
- const fires = smaller < broadcastTiers[0]
2853
- || (smaller < broadcastTiers[1] && larger > comparisonTiers[0])
2854
- || (smaller < broadcastTiers[2] && larger > comparisonTiers[1])
2855
- || (smaller < broadcastTiers[3] && larger > comparisonTiers[2]);
2856
- if (fires) {
2857
- const contributors = [...boundarySizeContributors(childA), ...boundarySizeContributors(childB)];
2858
- // A threshold that already admits the smaller side cannot be what stopped the broadcast.
2859
- const threshold = effectiveBroadcastThreshold(ctx.app);
2860
- const broadcastThreshold = threshold != null && threshold < 0 ? 'disabled'
2861
- : threshold != null && threshold > smaller ? 'notLimiting' : 'limits';
2862
- const raiseAdvice = 'Consider a broadcast() hint or raising spark.sql.autoBroadcastJoinThreshold.';
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', value: smaller,
2868
- largerSideBytes: larger, broadcastThreshold,
3395
+ impactBand: 'info', metric: 'smallerSideBytes',
3396
+ value: buildBytes, largerSideBytes: otherBytes,
3397
+ joinType, buildSide, broadcastThreshold,
2869
3398
  recommendation: broadcastThreshold === 'notLimiting'
2870
- ? `The smaller input to this Sort Merge Join (${formatBytes(smaller)}) is under the effective spark.sql.autoBroadcastJoinThreshold (${formatBytes(threshold )}) yet was not broadcast (the larger side is ${formatBytes(larger)}), so the threshold is not what stopped it: missing table statistics or a join type that cannot broadcast usually is. Consider a broadcast() hint or collecting statistics (ANALYZE TABLE).`
2871
- : `The smaller input to this Sort Merge Join (${formatBytes(smaller)}) is well under the broadcast threshold relative to the larger side (${formatBytes(larger)}): this could have been a broadcast join. ${raiseAdvice}`,
2872
- remediation: broadcastThreshold === 'notLimiting' ? [] : [increaseConf('spark.sql.autoBroadcastJoinThreshold')],
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 any TaskEnd
2881
- // (node.stageIds always empty in real data); its child carries the executor-side
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, so a hint forced it.
2885
- const threshold = effectiveBroadcastThreshold(ctx.app);
2886
- const autoBroadcastOff = threshold != null && threshold < 0;
2887
- const hintForced = autoBroadcastOff || (threshold != null && m.value > threshold);
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: spark.sql.autoBroadcastJoinThreshold (${formatBytes(threshold )}) is below it, so a broadcast() hint forced it: remove the hint.`
2899
- : `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: check for a misapplied broadcast hint or a misconfigured spark.sql.autoBroadcastJoinThreshold.`,
2900
- remediation: hintForced ? [] : [decreaseConf('spark.sql.autoBroadcastJoinThreshold')],
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 (the smaller join side), each priced as one broadcast transfer.
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 four configAudit audits), and 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
+ }