sparkforensics-server 0.6.0 → 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 (410) 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/layout.worker-CLcBHxVp.js +3 -0
  63. package/public/assets/{parser-worker-CA5RxqKc.js → parser-worker-CRIOPMOW.js} +8 -8
  64. package/public/assets/plan-graph-vendor-Ce6d4HU6.js +7 -0
  65. package/public/assets/react-vendor-C6KcUD36.js +9 -0
  66. package/public/assets/select-BHqOqun5.js +1 -0
  67. package/public/assets/toggle-DPeN6aNa.js +1 -0
  68. package/public/assets/toggle-group-Kl3uyHSr.js +1 -0
  69. package/public/assets/usePagedRows-BafD6bEN.js +1 -0
  70. package/public/assets/useRecentFiles-DzJZAoOT.js +3 -0
  71. package/public/assets/useRegisterFieldControl-pQv-ZDoU.js +1 -0
  72. package/public/assets/useScrollLock-BCxn_dMp.js +1 -0
  73. package/public/assets/useSortMode-DjrAN-z4.js +1 -0
  74. package/public/docs/404.html +3 -3
  75. package/public/docs/assets/{app.CnWL6bKV.js → app.1gvzd5Zb.js} +1 -1
  76. package/public/docs/assets/chunks/@localSearchIndexroot.DS2TQP5_.js +1 -0
  77. package/public/docs/assets/chunks/{VPLocalSearchBox.C0IBKeRQ.js → VPLocalSearchBox.CNxT3s0I.js} +1 -1
  78. package/public/docs/assets/chunks/{theme.BH-2HuRf.js → theme.C5lLqUv7.js} +2 -2
  79. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.js +1 -0
  80. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.CqKM_jEG.lean.js +1 -0
  81. package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.BYDo3ClR.js} +1 -1
  82. package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.ajUAVdbc.js +1 -0
  83. package/public/docs/assets/{contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.js → contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.js} +1 -1
  84. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.WqY74T0T.lean.js +1 -0
  85. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.CHHgywoP.js +1 -0
  86. package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.N4hhenrA.lean.js → contributor-guide_architecture_detector-contract.md.CHHgywoP.lean.js} +1 -1
  87. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.js +1 -0
  88. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.DIz3_oz9.lean.js +1 -0
  89. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.WH4tRHPL.js +1 -0
  90. package/public/docs/assets/{contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.lean.js → contributor-guide_architecture_detector-contract_threshold-tables.md.WH4tRHPL.lean.js} +1 -1
  91. package/public/docs/assets/{contributor-guide_architecture_drill-down.md.CqZPrdo9.js → contributor-guide_architecture_drill-down.md.yB-Hz2XT.js} +1 -1
  92. 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
  93. package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.BQZE04sw.js +1 -0
  94. 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
  95. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.K71YB-Rr.js +1 -0
  96. package/public/docs/assets/{contributor-guide_architecture_impact-estimation.md.DfvbqEUu.lean.js → contributor-guide_architecture_impact-estimation.md.K71YB-Rr.lean.js} +1 -1
  97. package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.DLCAffwn.js +1 -0
  98. package/public/docs/assets/{contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.lean.js → contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.DLCAffwn.lean.js} +1 -1
  99. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.js +1 -0
  100. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.JFrzDMwo.lean.js +1 -0
  101. package/public/docs/assets/contributor-guide_architecture_overview.md.nxkpOhgf.js +1 -0
  102. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.U-9KMDFK.js +1 -0
  103. 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
  104. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.js +1 -0
  105. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.js +1 -0
  106. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.B7rjSF_h.lean.js +1 -0
  107. 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
  108. 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
  109. package/public/docs/assets/{contributor-guide_architecture_worker-protocol.md.BAnhi2-d.js → contributor-guide_architecture_worker-protocol.md.Br6OE7RW.js} +1 -1
  110. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.Br6OE7RW.lean.js +1 -0
  111. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.IVyaXrGe.js +1 -0
  112. package/public/docs/assets/{contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.lean.js → contributor-guide_architecture_worker-protocol_cli-and-mcp.md.IVyaXrGe.lean.js} +1 -1
  113. 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
  114. 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
  115. 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
  116. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.CtmCU57I.js +1 -0
  117. package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.lean.js → contributor-guide_architecture_worker-protocol_evidence-report.md.CtmCU57I.lean.js} +1 -1
  118. package/public/docs/assets/contributor-guide_contributing.md.DBW4drtZ.js +1 -0
  119. package/public/docs/assets/contributor-guide_development-setup.md.B21ZTDhn.js +15 -0
  120. package/public/docs/assets/contributor-guide_development-setup.md.B21ZTDhn.lean.js +1 -0
  121. package/public/docs/assets/contributor-guide_testing.md.DpkozNoG.js +4 -0
  122. package/public/docs/assets/{contributor-guide_testing.md.By6XGR9a.lean.js → contributor-guide_testing.md.DpkozNoG.lean.js} +1 -1
  123. package/public/docs/assets/{index.md.CHJVslga.js → index.md.lMm7SUMz.js} +1 -1
  124. package/public/docs/assets/{index.md.CHJVslga.lean.js → index.md.lMm7SUMz.lean.js} +1 -1
  125. package/public/docs/assets/{style.CyK9Ss73.css → style.Cfxqlj0y.css} +1 -1
  126. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.C0pcQoNc.js +29 -0
  127. package/public/docs/assets/{user-guide_getting-started_ci-and-automation.md.BEepbDDL.lean.js → user-guide_getting-started_ci-and-automation.md.C0pcQoNc.lean.js} +1 -1
  128. package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.CGaxPX_1.js +1 -0
  129. 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
  130. 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
  131. package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.js → user-guide_mcp-tools.md.49r3L2YE.js} +1 -1
  132. 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
  133. package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.BQ_kIptC.lean.js +1 -0
  134. package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.azCZXkH8.js +44 -0
  135. 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
  136. package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.js → user-guide_mcp-tools_evaluate-budgets.md.dpQpRQ7z.js} +2 -2
  137. 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
  138. 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
  139. 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
  140. package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.js +1 -0
  141. package/public/docs/assets/user-guide_run-comparison.md.B_LOkdBf.lean.js +1 -0
  142. package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.js +1 -0
  143. package/public/docs/assets/user-guide_understanding-findings.md.C4riKCKl.lean.js +1 -0
  144. package/public/docs/contributor-guide/architecture/board-widgets/caching-and-memory.html +6 -6
  145. package/public/docs/contributor-guide/architecture/board-widgets/plan-advisor.html +6 -6
  146. package/public/docs/contributor-guide/architecture/board-widgets/visual-system-and-templating.html +6 -6
  147. package/public/docs/contributor-guide/architecture/board-widgets.html +6 -6
  148. package/public/docs/contributor-guide/architecture/detector-contract/plan-attribution.html +6 -6
  149. package/public/docs/contributor-guide/architecture/detector-contract/threshold-tables.html +6 -6
  150. package/public/docs/contributor-guide/architecture/detector-contract.html +6 -6
  151. package/public/docs/contributor-guide/architecture/drill-down/plan-graph.html +6 -6
  152. package/public/docs/contributor-guide/architecture/drill-down.html +6 -6
  153. package/public/docs/contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.html +6 -6
  154. package/public/docs/contributor-guide/architecture/impact-estimation/stage-union-rollup.html +6 -6
  155. package/public/docs/contributor-guide/architecture/impact-estimation.html +6 -6
  156. package/public/docs/contributor-guide/architecture/index.html +5 -5
  157. package/public/docs/contributor-guide/architecture/overview.html +6 -6
  158. package/public/docs/contributor-guide/architecture/state-and-history/history-server-intake.html +5 -5
  159. package/public/docs/contributor-guide/architecture/state-and-history/run-comparison-internals.html +6 -6
  160. package/public/docs/contributor-guide/architecture/state-and-history.html +5 -5
  161. package/public/docs/contributor-guide/architecture/widget-rendering/findings-tab.html +6 -6
  162. package/public/docs/contributor-guide/architecture/widget-rendering/investigation-routing.html +5 -5
  163. package/public/docs/contributor-guide/architecture/widget-rendering/list-sort-mode.html +6 -6
  164. package/public/docs/contributor-guide/architecture/widget-rendering/reference-section.html +5 -5
  165. package/public/docs/contributor-guide/architecture/widget-rendering/row-expand-contract.html +6 -6
  166. package/public/docs/contributor-guide/architecture/widget-rendering.html +6 -6
  167. package/public/docs/contributor-guide/architecture/worker-protocol/cli-and-mcp.html +6 -6
  168. package/public/docs/contributor-guide/architecture/worker-protocol/core-and-event-validation.html +6 -6
  169. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-availability.html +7 -7
  170. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-report.html +6 -6
  171. package/public/docs/contributor-guide/architecture/worker-protocol.html +6 -6
  172. package/public/docs/contributor-guide/contributing.html +6 -6
  173. package/public/docs/contributor-guide/development-setup.html +8 -8
  174. package/public/docs/contributor-guide/testing.html +9 -6
  175. package/public/docs/hashmap.json +1 -1
  176. package/public/docs/index.html +6 -6
  177. package/public/docs/logo-dark.svg +1 -0
  178. package/public/docs/logo-light.svg +1 -0
  179. package/public/docs/tuning-reference/anti-patterns.html +5 -5
  180. package/public/docs/tuning-reference/aqe.html +5 -5
  181. package/public/docs/tuning-reference/bottleneck-broadcast-sizing.html +5 -5
  182. package/public/docs/tuning-reference/bottleneck-cold-start.html +5 -5
  183. package/public/docs/tuning-reference/bottleneck-duplicate-plan-subtree.html +5 -5
  184. package/public/docs/tuning-reference/bottleneck-failures.html +5 -5
  185. package/public/docs/tuning-reference/bottleneck-gc.html +5 -5
  186. package/public/docs/tuning-reference/bottleneck-job-failure-rate.html +5 -5
  187. package/public/docs/tuning-reference/bottleneck-memory-utilization.html +5 -5
  188. package/public/docs/tuning-reference/bottleneck-retry-waste.html +5 -5
  189. package/public/docs/tuning-reference/bottleneck-shuffle.html +5 -5
  190. package/public/docs/tuning-reference/bottleneck-skew.html +5 -5
  191. package/public/docs/tuning-reference/bottleneck-slow-host.html +5 -5
  192. package/public/docs/tuning-reference/bottleneck-small-files.html +5 -5
  193. package/public/docs/tuning-reference/bottleneck-spill.html +5 -5
  194. package/public/docs/tuning-reference/bottleneck-straggler.html +5 -5
  195. package/public/docs/tuning-reference/bottleneck-tiny-tasks.html +5 -5
  196. package/public/docs/tuning-reference/bottleneck-utilization.html +5 -5
  197. package/public/docs/tuning-reference/caching.html +5 -5
  198. package/public/docs/tuning-reference/cluster-config.html +5 -5
  199. package/public/docs/tuning-reference/config.html +5 -5
  200. package/public/docs/tuning-reference/data-formats.html +5 -5
  201. package/public/docs/tuning-reference/index.html +5 -5
  202. package/public/docs/tuning-reference/intro.html +5 -5
  203. package/public/docs/tuning-reference/joins.html +5 -5
  204. package/public/docs/tuning-reference/memory-model.html +5 -5
  205. package/public/docs/tuning-reference/metrics.html +5 -5
  206. package/public/docs/tuning-reference/partitioning.html +5 -5
  207. package/public/docs/tuning-reference/pyspark.html +5 -5
  208. package/public/docs/tuning-reference/shuffle.html +5 -5
  209. package/public/docs/tuning-reference/spark-architecture.html +5 -5
  210. package/public/docs/tuning-reference/table-formats.html +5 -5
  211. package/public/docs/user-guide/alternative-log-retrieval/managed-platforms.html +5 -5
  212. package/public/docs/user-guide/alternative-log-retrieval/ssh-bastion.html +5 -5
  213. package/public/docs/user-guide/alternative-log-retrieval.html +5 -5
  214. package/public/docs/user-guide/getting-started/ci-and-automation.html +16 -11
  215. package/public/docs/user-guide/getting-started/reading-the-dashboard.html +6 -6
  216. package/public/docs/user-guide/getting-started/regression-metric-keys.html +5 -5
  217. package/public/docs/user-guide/getting-started/tuning-thresholds.html +7 -7
  218. package/public/docs/user-guide/getting-started.html +5 -5
  219. package/public/docs/user-guide/mcp-tools/compare-runs.html +16 -11
  220. package/public/docs/user-guide/mcp-tools/diagnose-run.html +28 -7
  221. package/public/docs/user-guide/mcp-tools/evaluate-budgets.html +6 -6
  222. package/public/docs/user-guide/mcp-tools/get-finding-documentation.html +5 -5
  223. package/public/docs/user-guide/mcp-tools/get-finding-evidence.html +7 -6
  224. package/public/docs/user-guide/mcp-tools/get-reference-doc.html +5 -5
  225. package/public/docs/user-guide/mcp-tools/get-run-summary.html +5 -5
  226. package/public/docs/user-guide/mcp-tools/list-runs.html +5 -5
  227. package/public/docs/user-guide/mcp-tools.html +6 -6
  228. package/public/docs/user-guide/run-comparison/how-stages-are-matched.html +5 -5
  229. package/public/docs/user-guide/run-comparison.html +6 -6
  230. package/public/docs/user-guide/understanding-findings.html +6 -6
  231. package/public/export-template.html +45 -45
  232. package/public/index.html +8 -7
  233. package/public/sample-runs/sample-run.ndjson.gz +0 -0
  234. package/vendor-core/allocation.js +25 -23
  235. package/vendor-core/analyzer.js +85 -58
  236. package/vendor-core/api-merge-writes.js +2 -0
  237. package/vendor-core/aqe-skew.js +347 -0
  238. package/vendor-core/cli/budgets.js +6 -4
  239. package/vendor-core/cli/collect-run-pair.js +89 -0
  240. package/vendor-core/cli/collect-run-worker.js +16 -0
  241. package/vendor-core/cli/collect-run.js +1 -2
  242. package/vendor-core/cli/native-zstd.js +12 -16
  243. package/vendor-core/comparability.js +234 -0
  244. package/vendor-core/comparison-output.js +15 -7
  245. package/vendor-core/comparison-verdict.js +105 -25
  246. package/vendor-core/core-source-hash.txt +1 -1
  247. package/vendor-core/core-time-series.js +0 -1
  248. package/vendor-core/core-usage-locality.js +1 -1
  249. package/vendor-core/detectors.js +1007 -380
  250. package/vendor-core/docs-config.js +0 -4
  251. package/vendor-core/docs-content/detection/cache.md +2 -1
  252. package/vendor-core/docs-content/detection/cfg.md +19 -11
  253. package/vendor-core/docs-content/detection/cold.md +2 -1
  254. package/vendor-core/docs-content/detection/gc.md +7 -3
  255. package/vendor-core/docs-content/detection/host.md +11 -1
  256. package/vendor-core/docs-content/detection/mem.md +17 -6
  257. package/vendor-core/docs-content/detection/part.md +3 -1
  258. package/vendor-core/docs-content/detection/plan.md +55 -13
  259. package/vendor-core/docs-content/detection/shfl.md +11 -5
  260. package/vendor-core/docs-content/detection/skew.md +47 -1
  261. package/vendor-core/docs-content/detection/slow.md +4 -2
  262. package/vendor-core/docs-content/detection/spec.md +6 -1
  263. package/vendor-core/docs-content/detection/spill.md +6 -3
  264. package/vendor-core/docs-content/detection/strag.md +19 -6
  265. package/vendor-core/docs-content/detection/tiny.md +9 -3
  266. package/vendor-core/effective-conf.js +7 -10
  267. package/vendor-core/efficiency-model.js +2 -2
  268. package/vendor-core/etl-phases.js +1 -2
  269. package/vendor-core/event-handlers.js +175 -57
  270. package/vendor-core/event-schemas.js +21 -16
  271. package/vendor-core/evidence-availability.js +9 -2
  272. package/vendor-core/evidence-report.js +118 -35
  273. package/vendor-core/executor-peaks.js +41 -0
  274. package/vendor-core/export-data.js +5 -5
  275. package/vendor-core/finding-identity.js +78 -0
  276. package/vendor-core/finding-presentation.js +116 -33
  277. package/vendor-core/finding-types.js +88 -21
  278. package/vendor-core/format-utils.js +14 -15
  279. package/vendor-core/html-export.js +1 -1
  280. package/vendor-core/impact-band.js +2 -2
  281. package/vendor-core/impact-format.js +47 -19
  282. package/vendor-core/impact-model.js +26 -15
  283. package/vendor-core/job-groups.js +4 -4
  284. package/vendor-core/mcp-server-factory.js +48 -12
  285. package/vendor-core/mcp-tools.js +179 -24
  286. package/vendor-core/median.js +2 -2
  287. package/vendor-core/nested-loop-join.js +73 -0
  288. package/vendor-core/occupancy.js +1 -2
  289. package/vendor-core/parser-worker.js +1 -1
  290. package/vendor-core/plan-graph-model.js +14 -22
  291. package/vendor-core/plan-node-detail.js +2 -3
  292. package/vendor-core/plan-summary.js +24 -19
  293. package/vendor-core/plan-tree-walk.js +6 -8
  294. package/vendor-core/python-stage.js +12 -1
  295. package/vendor-core/recommendation-rollup.js +13 -12
  296. package/vendor-core/redact.js +52 -46
  297. package/vendor-core/run-aggregates.js +6 -8
  298. package/vendor-core/run-comparison.js +226 -66
  299. package/vendor-core/run-identity.js +12 -0
  300. package/vendor-core/run-interpretation.js +21 -0
  301. package/vendor-core/run-metrics.js +2 -2
  302. package/vendor-core/run-output.js +32 -10
  303. package/vendor-core/run-totals.js +12 -0
  304. package/vendor-core/run-verdict.js +76 -3
  305. package/vendor-core/scaling-sim.js +3 -4
  306. package/vendor-core/snappy-block.js +2 -2
  307. package/vendor-core/spark-conf.js +168 -0
  308. package/vendor-core/sql-stages.js +2 -2
  309. package/vendor-core/stage-alignment.js +2 -12
  310. package/vendor-core/stage-identity.js +58 -3
  311. package/vendor-core/stage-label.js +14 -0
  312. package/vendor-core/stage-plan-nodes.js +17 -3
  313. package/vendor-core/stage-quantiles.js +141 -9
  314. package/vendor-core/stage-structure.js +17 -3
  315. package/vendor-core/threshold-overrides.js +9 -3
  316. package/vendor-core/types.js +58 -21
  317. package/vendor-core/wasted-core-hours.js +0 -1
  318. package/vendor-core/write-targets.js +114 -14
  319. package/vendor-core/zstd-worker-client.js +1 -1
  320. package/vendor-core/zstd-worker.js +1 -1
  321. package/public/assets/AutoscalingChurn-CRA2Mb7F.js +0 -1
  322. package/public/assets/CacheUtilization-Bh9OY_V9.js +0 -1
  323. package/public/assets/CachingOpportunity-oCiQwmTJ.js +0 -1
  324. package/public/assets/ColdStart-BJrtjfmn.js +0 -1
  325. package/public/assets/ConfigAudit-DMvKxySP.js +0 -1
  326. package/public/assets/CoreUsageArea-REC2zsKw.js +0 -1
  327. package/public/assets/Dashboard-BHZ76JCu.js +0 -2
  328. package/public/assets/DisclosureContext-OO7X1HAx.js +0 -1
  329. package/public/assets/DuplicatePlanSubtree-BgG-L-cK.js +0 -1
  330. package/public/assets/ExecutorUtilization-D2KhSVbY.js +0 -1
  331. package/public/assets/GcPressure-D1BTE8qh.js +0 -1
  332. package/public/assets/IncompleteRun-DD40xUpy.js +0 -1
  333. package/public/assets/JobFailures-YwNzCF19.js +0 -1
  334. package/public/assets/MemoryUtilization-9b8ee_3_.js +0 -1
  335. package/public/assets/OverBroadcast-CfctS8a8.js +0 -1
  336. package/public/assets/PartitionHint-CUvbnnxy.js +0 -1
  337. package/public/assets/PartitionSizing-VG8kBgG5.js +0 -1
  338. package/public/assets/PlanExplorer-DmubTAOP.js +0 -1
  339. package/public/assets/PlanGraphRoute-DctPZjS0.js +0 -1
  340. package/public/assets/RetryWaste-CnpM8bB7.js +0 -1
  341. package/public/assets/ShuffleIO-D13vGmas.js +0 -1
  342. package/public/assets/Skew-DlagstYP.js +0 -1
  343. package/public/assets/SlowHost-cSkLRZ_e.js +0 -1
  344. package/public/assets/SmallFiles-B-kERMcq.js +0 -1
  345. package/public/assets/SpeculationWaste-DaysAzXO.js +0 -1
  346. package/public/assets/Spill-H44Gz4US.js +0 -1
  347. package/public/assets/StageFailed-DCfii9h_.js +0 -1
  348. package/public/assets/StageFindingGroup-BXJ0Mj5n.js +0 -1
  349. package/public/assets/StageHeader--oN2J-oD.js +0 -1
  350. package/public/assets/StageShape-IZ80CZiJ.js +0 -1
  351. package/public/assets/StageSlowness--BRIaBc1.js +0 -1
  352. package/public/assets/Straggler-Ctq0Df_o.js +0 -1
  353. package/public/assets/TaskFailures-Bks_gqhL.js +0 -1
  354. package/public/assets/TinyTask-BssfzGFB.js +0 -1
  355. package/public/assets/UnderBroadcast-DsScrxc-.js +0 -1
  356. package/public/assets/charts-vendor-0VMaLVLN.js +0 -59
  357. package/public/assets/finding-anchor-BYW1oz5R.js +0 -1
  358. package/public/assets/impact-sort-xzy0g-Vh.js +0 -1
  359. package/public/assets/index-DhpRGMZc.css +0 -2
  360. package/public/assets/index-DwqkFUFk.js +0 -14
  361. package/public/assets/plan-graph-vendor-BYl-AzN5.js +0 -7
  362. package/public/assets/react-vendor-DfpuPQZf.js +0 -9
  363. package/public/assets/select-C8edjYTq.js +0 -1
  364. package/public/assets/usePagedRows-CsuUFeYx.js +0 -1
  365. package/public/assets/useRecentFiles-BvkKo3JN.js +0 -11
  366. package/public/assets/useSortMode-joX71BZN.js +0 -1
  367. package/public/docs/assets/chunks/@localSearchIndexroot.tE5pz54z.js +0 -1
  368. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.js +0 -1
  369. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.lean.js +0 -1
  370. package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.js +0 -1
  371. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.lean.js +0 -1
  372. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.N4hhenrA.js +0 -1
  373. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.js +0 -1
  374. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.lean.js +0 -1
  375. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.js +0 -1
  376. package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.js +0 -1
  377. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.DfvbqEUu.js +0 -1
  378. package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.js +0 -1
  379. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.js +0 -1
  380. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.lean.js +0 -1
  381. package/public/docs/assets/contributor-guide_architecture_overview.md.C1sSvm9i.js +0 -1
  382. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.js +0 -1
  383. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BOZ1azXG.js +0 -1
  384. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.js +0 -1
  385. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.lean.js +0 -1
  386. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BAnhi2-d.lean.js +0 -1
  387. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.js +0 -1
  388. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.js +0 -1
  389. package/public/docs/assets/contributor-guide_contributing.md.CQMvGwFt.js +0 -1
  390. package/public/docs/assets/contributor-guide_development-setup.md.DQdzera7.js +0 -15
  391. package/public/docs/assets/contributor-guide_development-setup.md.DQdzera7.lean.js +0 -1
  392. package/public/docs/assets/contributor-guide_testing.md.By6XGR9a.js +0 -1
  393. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.BEepbDDL.js +0 -24
  394. package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.js +0 -1
  395. package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.Cc_gLoix.lean.js +0 -1
  396. package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.js +0 -23
  397. package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.js +0 -1
  398. package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.lean.js +0 -1
  399. package/public/docs/assets/user-guide_understanding-findings.md.3C7KCEZO.js +0 -1
  400. package/public/docs/assets/user-guide_understanding-findings.md.3C7KCEZO.lean.js +0 -1
  401. /package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.lean.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.BYDo3ClR.lean.js} +0 -0
  402. /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
  403. /package/public/docs/assets/{contributor-guide_architecture_overview.md.C1sSvm9i.lean.js → contributor-guide_architecture_overview.md.nxkpOhgf.lean.js} +0 -0
  404. /package/public/docs/assets/{contributor-guide_architecture_widget-rendering.md.BOZ1azXG.lean.js → contributor-guide_architecture_widget-rendering.md.BrSz8Ezh.lean.js} +0 -0
  405. /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
  406. /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
  407. /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
  408. /package/public/docs/assets/{contributor-guide_contributing.md.CQMvGwFt.lean.js → contributor-guide_contributing.md.DBW4drtZ.lean.js} +0 -0
  409. /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
  410. /package/public/docs/assets/{user-guide_mcp-tools.md.C65SjWUL.lean.js → user-guide_mcp-tools.md.49r3L2YE.lean.js} +0 -0
@@ -1,33 +1,38 @@
1
- import { pathBasename, formatBytes, IMPACT_BAND_ORDER, MS_PER_CORE_HOUR } 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';
4
+ import { scanRelationId, parseJoinType } from './plan-summary.js';
5
5
  import { allocatedCoreMs, computeAllocation } from './allocation.js';
6
- import { computePeakConcurrentCores, computePeakConcurrentExecutorCount } from './core-count.js';
6
+ import { executorHeapPeaks } from './executor-peaks.js';
7
+ import { computePeakConcurrentCores } from './core-count.js';
7
8
  import { walkPlanTree } from './plan-tree-walk.js';
9
+ import { diagnoseJoinSkew, isSkewJoinNode, planShowsSkewSplit } from './aqe-skew.js';
10
+ import { isBatchEvalPythonNode } from './python-stage.js';
8
11
  import { computeCoreLocalityRatio } from './core-locality-ratio.js';
12
+ import { STRAGGLER_FACTOR } from './stage-quantiles.js';
9
13
  import { tailRecoveryMs, tailRemovedWorkMs, stragglerFixLongestTaskMs, } from './occupancy.js';
10
14
  import { IMPACT_FLOOR_PCT_WARN, IMPACT_FLOOR_PCT_CRIT, appDurationMs } from './impact-band.js';
11
15
  import {
12
16
  BROADCAST_BANDWIDTH_BPS, EXECUTOR_STARTUP_OVERHEAD_MS, FILE_OPEN_OVERHEAD_MS, IDEAL_BYTES_PER_PARTITION_TASK,
13
17
  NETWORK_FETCH_PENALTY_MS, RE_READ_THROUGHPUT_BPS, SHUFFLE_THROUGHPUT_BPS, SPILL_IO_THROUGHPUT_BPS, TAIL_CLAIM,
14
18
  TASK_SCHEDULING_OVERHEAD_MS, costOnly, fetchWaitWallClockMs, measuredTaskOverhead, multiStageImpact, noWasteModel,
15
- retryWallClockMs, singleStageImpact, stageIoParallelism, stageMappableWasteOrCostOnly, tasksMostlyIdle,
19
+ ioModelAssumption, retryWallClockMs, singleStageImpact, stageIoParallelism, stageMappableWasteOrCostOnly, tasksMostlyIdle, withAssumptions,
16
20
 
17
21
  } from './impact-model.js';
18
22
  import { isExchangeNode, isBroadcastExchangeNode } from './plan-node-detail.js';
23
+ import { outputRowsOf, ownOutputRows, parseNestedLoopJoin } from './nested-loop-join.js';
19
24
  import { totalExecutorCpuMs } from './run-totals.js';
20
25
  import { DUPLICATE_SUBTREE_DIFFERING_NOTE, duplicateSubtreeDetail, SLOW_HOST_DIMENSION_LABEL } from './finding-generic-recommendation.js';
21
26
  import { stageIdsForSqlExec } from './sql-stages.js';
22
27
  import { cyrb53 } from './string-hash.js';
23
28
  import { codeFix, decreaseConf, increaseConf, setConf } from './remediation.js';
29
+ import { effectiveSparkConf, executorOverheadSettings, overlayModifiedConfigs, parseSparkBytes } from './spark-conf.js';
24
30
  import { MAX_FAILURE_GROUPS, describeTaskFailure, } from './task-failure.js';
25
-
26
-
31
+
32
+
27
33
 
28
34
  const MB = 1024 * 1024;
29
35
  const GB = 1024 * MB;
30
- const TB = 1024 * GB;
31
36
 
32
37
  // Labels a byte threshold in this file's binary units, so a 1 GiB default reads '1 GB'.
33
38
  function binaryThresholdLabel(bytes ) {
@@ -45,6 +50,9 @@ function binaryThresholdLabel(bytes ) {
45
50
 
46
51
 
47
52
 
53
+
54
+
55
+
48
56
 
49
57
 
50
58
 
@@ -93,6 +101,7 @@ function binaryThresholdLabel(bytes ) {
93
101
 
94
102
 
95
103
 
104
+
96
105
 
97
106
 
98
107
 
@@ -106,6 +115,8 @@ function binaryThresholdLabel(bytes ) {
106
115
 
107
116
 
108
117
 
118
+
119
+
109
120
 
110
121
 
111
122
 
@@ -157,16 +168,33 @@ function binaryThresholdLabel(bytes ) {
157
168
 
158
169
 
159
170
 
160
-
171
+
161
172
 
162
173
 
163
174
 
175
+
176
+
177
+
164
178
 
165
179
 
166
180
  // auditConfig(app) calls every config-scope detect({ app }) with whatever appModel.app is
167
181
  // (SparkAppInfo | null), independent of DetectorCtx.
168
182
 
169
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
+
170
198
 
171
199
 
172
200
 
@@ -177,13 +205,13 @@ function binaryThresholdLabel(bytes ) {
177
205
 
178
206
 
179
207
 
180
- // SpillPressureDetector (5a) + SpillSkewDetector (5b).
208
+ // Spill magnitude: absolute-volume tiers first, then ratio-plus-floor skew tiers.
181
209
  function computeSpillMagnitude(
182
210
  stage ,
183
211
  t ,
184
212
  ) {
185
213
  const { taskCount, spillDiskMax = 0, spillMemMax = 0, spillDiskP50 = 0, spillMemP50 = 0 } = stage;
186
- // 5a: absolute volume, branching on task count.
214
+ // Absolute volume, branching on task count.
187
215
  if (taskCount === 1) {
188
216
  if (spillDiskMax >= t.singleTaskDiskGiB * GB || spillMemMax >= t.singleTaskMemGiB * GB) return { magnitude: 'severe' };
189
217
  } else {
@@ -191,7 +219,7 @@ function computeSpillMagnitude(
191
219
  if (spillDiskMax >= t.highDiskGiB * GB || perTaskDisk >= t.highTaskDiskMB * MB || spillMemMax >= t.highMemGiB * GB) return { magnitude: 'high' };
192
220
  if (spillDiskMax >= t.medDiskMB * MB || spillMemMax >= t.medMemGiB * GB) return { magnitude: 'medium' };
193
221
  }
194
- // 5b: ratio+floor skew (requires enough tasks).
222
+ // Ratio plus floor skew (requires enough tasks).
195
223
  if (taskCount >= t.skewMinTasks) {
196
224
  if (spillDiskP50 > 0 && spillDiskMax / spillDiskP50 > t.skewRatio && spillDiskMax >= t.skewDiskFloorMB * MB) return { magnitude: 'high' };
197
225
  if (spillMemP50 > 0 && spillMemMax / spillMemP50 > t.skewRatio && spillMemMax >= t.skewMemFloorMB * MB) return { magnitude: 'medium' };
@@ -215,7 +243,7 @@ export function unionStageIds(nodes , fallback ) {
215
243
 
216
244
  // Bottom-up shape computation for duplicate-subtree detection and the
217
245
  // cachingOpportunity composite detector. `size` is the subtree node count; the default
218
- // 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
219
247
  // of each child's fingerprint, so two subtrees with the same shape but different values still
220
248
  // collide. Digesting children keeps each fingerprint O(own size): embedding the full child
221
249
  // strings made every node carry its whole subtree, O(n x depth) text on deep real plans.
@@ -225,6 +253,13 @@ export function unionStageIds(nodes , fallback ) {
225
253
  // without descendant scan detail entering the comparison.
226
254
 
227
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
+
228
263
  export function computePlanShapes(
229
264
  root ,
230
265
  opts = {},
@@ -249,7 +284,7 @@ export function computePlanShapes(
249
284
  const realMetrics = writeHalf ? writeHalf.metrics : node.metrics;
250
285
  const childShapes = realChildren.map((c) => visit(c, false));
251
286
  const size = 1 + childShapes.reduce((sum, c) => sum + c.size, 0);
252
- const metricNames = (realMetrics ?? []).map((m) => m.name).sort().join(',');
287
+ const metricNames = fingerprintMetricNames(realMetrics);
253
288
  const childFingerprints = childShapes.map((c) => cyrb53(c.fingerprint)).join(',');
254
289
  const fingerprint = isRoot && includeDetail
255
290
  ? `${node.name}[${metricNames}]<${normalize(node.detail ?? '')}>{${childFingerprints}}`
@@ -274,8 +309,7 @@ export function normalizeDetail(detail ) {
274
309
  .replace(/\bBuild(Left|Right)\b/g, 'BuildSide');
275
310
  // The lookbehind only skips starts inside an identifier, which can't match unless the
276
311
  // identifier's own start already did: same output, without re-scanning each identifier from
277
- // every one of its letters (O(length^2) on the 6.8 KB average join detail of the largest real
278
- // log; 64ms -> 40ms per analyze()).
312
+ // every one of its letters (O(length^2) on long join details).
279
313
  s = s.replace(/(?<![A-Za-z_])([A-Za-z_][\w.]*)\s*=\s*([A-Za-z_][\w.]*)/g, (_m, l, r) => {
280
314
  const [a, b] = [l, r].sort();
281
315
  return `${a} = ${b}`;
@@ -331,7 +365,7 @@ export function findCompositeCandidates(root ) {
331
365
  const childResults = (node.children ?? []).map(visit);
332
366
  path.pop();
333
367
 
334
- const metricNames = (node.metrics ?? []).map((m) => m.name).sort().join(',');
368
+ const metricNames = fingerprintMetricNames(node.metrics);
335
369
  // Child digests, as in computePlanShapes: deterministic, so still comparable across executions.
336
370
  const childFingerprints = childResults.map((r) => cyrb53(r.fingerprint)).join(',');
337
371
  const fingerprint = `${node.name}[${metricNames}]{${childFingerprints}}`;
@@ -398,11 +432,8 @@ export function findDuplicateSubtrees(
398
432
  { minSubtreeSize, minOccurrences } ,
399
433
  ) {
400
434
  const { shapeOf, allNodes } = computePlanShapes(root);
401
- // Defensive, not load-bearing: computePlanShapes's visit() already skips
402
- // straight through a read node to its write half's real children, so no
403
- // write-half node is ever pushed into allNodes in the first place, this
404
- // filter can structurally never exclude anything. Kept in case that
405
- // 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.
406
437
  const eligible = allNodes.filter(n => shapeOf.get(n) .size >= minSubtreeSize && n.exchangeRole !== 'write');
407
438
 
408
439
  const groups = new Map ();
@@ -446,7 +477,7 @@ export function findDuplicateSubtrees(
446
477
  // scanned table, literals). The fingerprint ignores detail, so occurrences can be same-shaped
447
478
  // branches over different data; only identical ones are repeated work that computing once would
448
479
  // save. AQE query-stage numbers (`ShuffleQueryStage 718` vs `720`) name the same computation's
449
- // 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.
450
481
  function occurrencesHaveIdenticalDetails(occurrences ) {
451
482
  const detailsOf = (root ) => {
452
483
  const out = [];
@@ -470,11 +501,10 @@ function occurrencesHaveIdenticalDetails(occurrences ) {
470
501
 
471
502
  // Stages that run nothing but the duplicated occurrences' operators: every operator attributed to
472
503
  // the stage is inside `nodes`. A stage shared with operators outside them (the join consuming the
473
- // subtree, the other join side) does other work too, so its time isn't the subtree's to claim;
474
- // 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
475
506
  // an Exchange's write half aren't other work (the fused pipeline around an operator, the shuffle
476
- // write of its output), so they're left out of both counts; on the 14 real logs they were the
477
- // only outside node on 426 stages.
507
+ // write of its output), so they're left out of both counts.
478
508
  function countsTowardStage(node ) {
479
509
  return node.exchangeRole !== 'write' && !node.name.startsWith('WholeStageCodegen');
480
510
  }
@@ -517,40 +547,119 @@ const FILES_READ_BYTES = 'size of files read';
517
547
  const FILES_WRITTEN_COUNT = 'number of written files';
518
548
  const FILES_WRITTEN_BYTES = 'written output';
519
549
 
520
- // Byte size of a join-side subtree for broadcast sizing. Stops
521
- // descending at a node with a "data size" metric: that value already aggregates everything
522
- // beneath it, so summing further would double-count. Only recurses when no such metric.
523
- function sumBoundarySize(node ) {
524
- const m = (node.metrics ?? []).find(x => x.name === 'data size');
525
- if (m) return m.value;
526
- let sum = 0;
527
- for (const c of (node.children ?? [])) sum += sumBoundarySize(c);
528
- 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]);
529
563
  }
530
564
 
531
- // Nodes that fed sumBoundarySize's total: mirrors its recursion exactly so implicated stageIds
532
- // line up with the size actually compared.
533
- function boundarySizeContributors(node ) {
534
- const m = (node.metrics ?? []).find((x) => x.name === 'data size');
535
- if (m) return [node];
536
- const out = [];
537
- for (const c of (node.children ?? [])) out.push(...boundarySizeContributors(c));
538
- return out;
565
+ function shuffleBytes(exchange ) {
566
+ return (exchange.metrics ?? []).find((m) => m.name === 'data size')?.value ?? 0;
567
+ }
568
+
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);
539
594
  }
540
595
 
541
596
  // Max/median deviation ratio over a list of {key, value}; returns the
542
- // 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).
543
599
  function maxMedianRatio(
544
600
  samples ,
601
+ qualifies = () => true,
545
602
  ) {
546
603
  if (samples.length < 3) return null;
547
604
  const vals = samples.map(s => s.value).sort((a, b) => a - b);
548
605
  const median = medianOfSorted(vals);
549
606
  if (median <= 0) return null;
550
- 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));
551
610
  return { key: top.key, ratio: top.value / median, value: top.value };
552
611
  }
553
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
+
554
663
 
555
664
 
556
665
 
@@ -595,7 +704,6 @@ export function computeSkewRatio(
595
704
  // ratio on a few ms is noise in an hours-long run but real in a seconds-long one; a fixed-ms
596
705
  // floor can't scale. Used by skew/straggler, gated on the same occupancy-clipped tail claim their
597
706
  // estimate() displays as savings (tailClaimImpact).
598
- // NOT SOURCED: floor percentages are our own noise floor, unvalidated.
599
707
  // Unknown app timing never suppresses a finding; it just skips the floor gate.
600
708
  function meetsRuntimeFloor(wasteMs , runMs , floorPct ) {
601
709
  return runMs == null || wasteMs >= runMs * floorPct;
@@ -657,26 +765,90 @@ function tailClaimFloorMs(claim , stageId , ctx )
657
765
  return tailClaimImpact(claim, stageId, ctx.impact).wallClock?.high ?? claim.wasteMs;
658
766
  }
659
767
 
660
- // Spark's default for the properties a detector's fix reads or suggests, for a run that did not log
661
- // them. Undefined when the property is not modeled or its default depends on a version the log did
662
- // not record. A property is modeled only where its default has made a suggestion a no-op; every
663
- // other property a detector suggests defaults off, or to a value other than the suggested one.
664
- function versionDefault(app , key ) {
665
- if (key === 'spark.sql.shuffle.partitions') return '200';
666
- if (key === 'spark.sql.autoBroadcastJoinThreshold') return String(10 * MB);
667
- const version = /^(\d+)\.(\d+)/.exec(app?.sparkVersion ?? '');
668
- if (version == null) return undefined;
669
- const [major, minor] = [Number(version[1]), Number(version[2])];
670
- if (key === 'spark.sql.adaptive.enabled') return major > 3 || (major === 3 && minor >= 2) ? 'true' : 'false';
671
- if (key === 'spark.sql.adaptive.skewJoin.enabled' || key === 'spark.sql.adaptive.coalescePartitions.enabled') {
672
- return major >= 3 ? 'true' : undefined;
673
- }
674
- 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;
675
779
  }
676
780
 
677
- // 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.
678
807
  function effectiveConf(app , key ) {
679
- 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})`;
680
852
  }
681
853
 
682
854
  // A setting is only a fix when the run's effective conf doesn't already have it: a run that set it,
@@ -699,6 +871,34 @@ function switchFix(on , key , suggested , recomm
699
871
  return on ? { text: alreadyOn, remediation: [] } : { text: recommend, remediation: [setConf(key, suggested)] };
700
872
  }
701
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
+
702
902
  const SKEW_KEY_REMEDY = 'salt the key or repartition on a better key';
703
903
 
704
904
  // What a stage reads, by the dominant side: a stage that reads a shuffle and mostly input files is
@@ -717,24 +917,22 @@ function predatesAqeSkewJoin(app ) {
717
917
  // What a skewed stage reads, which decides whether AQE skew-join handling can act on it. It splits
718
918
  // skewed partitions on the shuffle-read side of a sort-merge or shuffled-hash join, so only a stage
719
919
  // that reads a shuffle, in a SQL execution whose plan has such a join, is 'shuffleJoin'. A plan does
720
- // not say which stage runs which join (a node's stageIds are empty in real logs), so the join is
721
- // 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);
722
921
  // 'other' is any stage the plan cannot tie to a join (an aggregation's shuffle, no plan, no input),
723
922
  // and any stage of a run on Spark before 3.0 (skewFix).
724
923
  function skewOrigin(stage , ctx , shuffleEvidence ) {
725
924
  if (shuffleEvidence || (stage.shuffleReadBytes > 0 && stage.shuffleReadBytes >= stage.inputBytes)) {
726
925
  let hasJoin = false;
727
926
  const plan = stage.sqlExecutionId != null ? ctx.sql.get(stage.sqlExecutionId)?.planTree : null;
728
- walkPlanTree(plan, (node) => { if (SKEW_JOIN_NODES.has(node.name)) hasJoin = true; });
927
+ walkPlanTree(plan, (node) => { if (isSkewJoinNode(node.name)) hasJoin = true; });
729
928
  return hasJoin ? 'shuffleJoin' : 'other';
730
929
  }
731
930
  return stage.inputBytes > 0 ? 'inputScan' : 'other';
732
931
  }
733
- const SKEW_JOIN_NODES = new Set(['SortMergeJoin', 'ShuffledHashJoin']);
734
932
 
735
933
  // The skew finding's fix for the stage's origin. Join-driven skew gets AQE skew-join handling,
736
934
  // unless the run's effective conf already has it; uneven input gets the file-size remedy.
737
- function skewFix(stage , ctx , shuffleEvidence = false) {
935
+ function skewFix(stage , ctx , shuffleEvidence = false, tail ) {
738
936
  const origin = skewOrigin(stage, ctx, shuffleEvidence);
739
937
  if (origin === 'inputScan') {
740
938
  return {
@@ -746,7 +944,22 @@ function skewFix(stage , ctx , shuffleEvidence = false
746
944
  if (origin === 'other') return { origin, text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
747
945
  // Before Spark 3.0 there is no AQE skew-join handling, so the stage gets the generic advice.
748
946
  if (predatesAqeSkewJoin(ctx.app)) return { origin: 'other', text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
749
- return { origin, ...skewJoinFix(ctx.app) };
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 };
750
963
  }
751
964
 
752
965
  function skewJoinFix(app ) {
@@ -780,7 +993,7 @@ function idleCapacityFix(app , recommend , alreadyOnLead
780
993
  const fix = dynamicAllocationFix(app, recommend, alreadyOnLead);
781
994
  // dynamicAllocationFix's remediation is empty exactly when dynamic allocation is already on.
782
995
  if (fix.remediation.length > 0) return { ...fix, remediation: [...fix.remediation, decreaseConf('spark.executor.instances')] };
783
- const holdsFloor = Number.parseInt(app.config?.['spark.dynamicAllocation.minExecutors'] ?? '', 10) > 0;
996
+ const holdsFloor = Number.parseInt(effectiveConf(app, 'spark.dynamicAllocation.minExecutors') ?? '', 10) > 0;
784
997
  return holdsFloor
785
998
  ? { text: `${alreadyOnLead} by lowering spark.dynamicAllocation.maxExecutors and spark.dynamicAllocation.minExecutors`,
786
999
  remediation: [decreaseConf('spark.dynamicAllocation.maxExecutors'), decreaseConf('spark.dynamicAllocation.minExecutors')] }
@@ -794,31 +1007,129 @@ function effectiveShufflePartitions(app ) {
794
1007
  return value != null && /^\d+$/.test(value) ? Number(value) : null;
795
1008
  }
796
1009
 
797
- // Whether partition-count advice fits a shuffle-reading stage, from the effective conf and the
798
- // stage's own task sizes. The partition count that brings each shuffle partition down to the ideal
799
- // size is per stage; the property is job-wide. 'sufficient': the stage's tasks are already at or
800
- // under the ideal size. 'aqeCoalesced': AQE merged the property's partitions into fewer tasks, so
801
- // the advisory partition size is the lever. 'ownPartitioning': the property already gives at least
802
- // the count needed, so the stage's own repartition(n) or RDD parallelism limits it. Otherwise the
803
- // property limits the stage: 'raise'.
804
- function shufflePartitionCase(stage , app ) {
805
- 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;
806
1074
  const needed = Math.ceil(stage.shuffleReadBytes / IDEAL_BYTES_PER_PARTITION_TASK);
807
1075
  const tasks = Math.max(1, stage.taskCount);
1076
+ const relation = countRelation(stage, basis);
808
1077
  let partitions = 'raise';
809
1078
  if (stage.shuffleReadBytes / tasks <= IDEAL_BYTES_PER_PARTITION_TASK) partitions = 'sufficient';
810
- else if (count != null && stage.taskCount < count
811
- && loggedAs(app, 'spark.sql.adaptive.enabled', true) && loggedAs(app, 'spark.sql.adaptive.coalescePartitions.enabled', true)) partitions = 'aqeCoalesced';
812
- else if (count != null && count >= needed) partitions = 'ownPartitioning';
813
- 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 };
814
1082
  }
815
1083
 
816
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
+ }
817
1128
 
818
1129
  // lowShuffleParallelism's fix. Unlike the shuffle finding it fires on stages whose tasks are larger
819
1130
  // than the ideal size, so its cases are 'raise', 'aqeCoalesced' and 'ownPartitioning'.
820
- function lowShuffleParallelismFix(stage , app ) {
821
- const { partitions, count, needed } = shufflePartitionCase(stage, app);
1131
+ function lowShuffleParallelismFix(stage , ctx ) {
1132
+ const { partitions, count, key, needed } = shufflePartitionCase(stage, ctx);
822
1133
  if (partitions === 'aqeCoalesced') {
823
1134
  return {
824
1135
  partitions,
@@ -829,36 +1140,104 @@ function lowShuffleParallelismFix(stage , app )
829
1140
  if (partitions === 'ownPartitioning') {
830
1141
  return {
831
1142
  partitions,
832
- 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`,
833
1144
  remediation: [],
834
1145
  };
835
1146
  }
836
1147
  return {
837
1148
  partitions,
838
- text: 'raise spark.sql.shuffle.partitions so each partition is smaller',
839
- 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)],
840
1151
  };
841
1152
  }
842
1153
 
843
- // A Spark byte-size property as bytes (a plain number, or with a k/m/g/t suffix, optionally 'b'), or
844
- // null when it is not one. Negative numbers pass through: -1 turns auto-broadcast off.
845
- function parseSparkBytes(value ) {
846
- const m = /^(-?\d+(?:\.\d+)?)\s*([kmgt]?)b?$/i.exec(value?.trim() ?? '');
847
- if (m == null) return null;
848
- 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)] };
849
1172
  }
850
1173
 
851
- // The run's effective spark.sql.autoBroadcastJoinThreshold in bytes (logged, else Spark's 10 MiB);
852
- // negative when auto-broadcast is disabled, null when the logged value is not a size.
853
- function effectiveBroadcastThreshold(app ) {
854
- const raw = effectiveConf(app, 'spark.sql.autoBroadcastJoinThreshold');
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;
1225
+ }
1226
+
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);
855
1232
  if (raw != null && /^-\d+$/.test(raw)) return -1;
856
1233
  return parseSparkBytes(raw);
857
1234
  }
858
1235
 
859
- // 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.
860
1238
  function dynamicAllocationOff(app ) {
861
- 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);
862
1241
  }
863
1242
 
864
1243
  // Shared by cacheUtilization's two variants, worded per storage source: neither is a runtime
@@ -1110,8 +1489,7 @@ function gcConfidence(
1110
1489
  // warnFloor gates the finding, so a value just past it is the weakest evidence this detector can
1111
1490
  // produce. highFloor is critPct: straggler's own second (speculative-share) tier, 2x warnPct
1112
1491
  // (0.10 -> 0.20); reused as the high-confidence bar for the stragglerShare path too since that
1113
- // metric has no dedicated critical tier of its own (see the "no dedicated critical tier" comment
1114
- // 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.
1115
1493
  function stragglerConfidence(shareValue , warnFloor , highFloor ) {
1116
1494
  if (shareValue < warnFloor * 1.5) return 'low';
1117
1495
  if (shareValue >= highFloor) return 'high';
@@ -1182,29 +1560,156 @@ function gcValidation(minRunTimeMs ) {
1182
1560
 
1183
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.';
1184
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
+
1185
1684
  export const DETECTORS = [
1186
1685
  defineStageDetector({
1187
1686
  type: 'skew', order: 30, fixEffort: 'code', version: 1,
1188
1687
  emits: ['skew'],
1189
1688
  docAnchor: '#bottleneck-skew',
1190
- 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 },
1191
1692
  detect(stage, ctx, thresholds) {
1192
- const result = computeSkewRatio(stage, thresholds.minTasksForP95);
1193
- if (result === null) return null;
1194
- const { ratio, metric } = result;
1195
- if (ratio <= thresholds.ratioWarn) return null;
1196
- // The claim estimate() reports as savings, clipped the same way, so the gate agrees with it.
1197
- const floorWasteMs = tailClaimFloorMs(skewTailClaim(stage, metric === 'P95/median'), stage.id, ctx);
1198
- 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;
1199
1700
  const value = Math.round(ratio * 10) / 10;
1200
- 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;
1201
1704
  return {
1202
- 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 } : {}),
1203
1708
  impactBand: 'warning',
1204
1709
  metric, value,
1205
1710
  confidence: skewConfidence(ratio, thresholds.ratioWarn),
1206
1711
  validationRequired: `Flagged only when it costs at least ${shareLabel(thresholds.floorPctWarn)} of run time.`,
1207
- 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}.`,
1208
1713
  remediation: fix.remediation,
1209
1714
  };
1210
1715
  },
@@ -1221,8 +1726,7 @@ export const DETECTORS = [
1221
1726
  emits: ['stageShape'],
1222
1727
  docAnchor: '#bottleneck-stage-shape',
1223
1728
  // lowParallelismFloorPct: the same 0.5% runtime floor the tiered detectors use. Parallelizing a
1224
- // stage can't save more than the stage's own duration, so a shorter stage can't clear it; on
1225
- // 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.
1226
1730
  // App-wide idle capacity stays covered by utilization.
1227
1731
  // taskStageSkew: stageShareMin is the share of the stage's wall-clock the longest task must
1228
1732
  // span, and skewWarn how far past the median task it must run (skew's own max/median 3×).
@@ -1304,10 +1808,13 @@ export const DETECTORS = [
1304
1808
  // Cores idle during the straggler's tail, at achieved concurrency (not full cluster
1305
1809
  // capacity, which is lowParallelism's territory): resourceOnly, since skew and straggler
1306
1810
  // already claim that tail's wall-clock time.
1307
- const idleCoreMs =
1811
+ const tailIdleCoreMs =
1308
1812
  Math.max(0, Math.min(totalCores, taskCount) - 1) *
1309
1813
  Math.max(0, (stage.taskDurationMax ?? 0) - (stage.taskDurationP50 ?? 0));
1310
- 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 });
1311
1818
  }
1312
1819
  return null;
1313
1820
  },
@@ -1317,22 +1824,21 @@ export const DETECTORS = [
1317
1824
  emits: ['shuffle'],
1318
1825
  docAnchor: '#bottleneck-shuffle',
1319
1826
  // stageFloorPct: the 0.5% runtime floor. The shuffle claim is clipped to the stage, so on a
1320
- // shorter stage it graded info: 182 of 284 shuffle findings on the 14 real logs. The shuffle
1321
- // 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.
1322
1828
  thresholds: { minBytes: 50 * MB, stageFloorPct: 0.005 },
1323
1829
  detect(stage, ctx, thresholds) {
1324
1830
  const bytes = stage.shuffleReadBytes;
1325
1831
  if (bytes <= thresholds.minBytes) return null;
1326
1832
  if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
1327
- const { partitions, count } = shufflePartitionCase(stage, ctx.app);
1833
+ const { partitions, count, key } = shufflePartitionCase(stage, ctx);
1328
1834
  const perTask = formatBytes(bytes / Math.max(1, stage.taskCount));
1329
1835
  const fix = partitions === 'sufficient'
1330
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: [] }
1331
1837
  : partitions === 'aqeCoalesced'
1332
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)] }
1333
1839
  : partitions === 'ownPartitioning'
1334
- ? { 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: [] }
1335
- : { 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)] };
1336
1842
  return {
1337
1843
  type: 'shuffle', stageId: stage.id,
1338
1844
  impactBand: 'info', partitions,
@@ -1352,8 +1858,11 @@ export const DETECTORS = [
1352
1858
  const measuredMs = fetchWaitWallClockMs(stage);
1353
1859
  const wasteMs = measuredMs == null ? modeledMs : Math.min(modeledMs, measuredMs);
1354
1860
  // rawWaste: the measured byte volume behind the modeled figure.
1355
- return singleStageImpact(wasteMs, finding.stageId, ctx,
1356
- 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
+ );
1357
1866
  },
1358
1867
  }),
1359
1868
  defineStageDetector({
@@ -1373,13 +1882,13 @@ export const DETECTORS = [
1373
1882
  const fix = skewFix(stage, ctx, true);
1374
1883
  out.push({
1375
1884
  type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
1376
- rule: 'shufflePartitionSkew', origin: fix.origin, metric: 'shuffleReadMax', value: max,
1885
+ rule: 'shufflePartitionSkew', origin: fix.origin, aqeSkew: fix.aqeSkew, metric: 'shuffleReadMax', value: max,
1377
1886
  recommendation: `The largest shuffle partition (${formatBytes(max)}) is ${ratioText}: ${fix.text}.`,
1378
1887
  remediation: fix.remediation,
1379
1888
  });
1380
1889
  }
1381
1890
  if (total >= thresholds.lowParTotalBytes && taskCount <= thresholds.lowParMaxTasks) {
1382
- const fix = lowShuffleParallelismFix(stage, ctx.app);
1891
+ const fix = lowShuffleParallelismFix(stage, ctx);
1383
1892
  out.push({
1384
1893
  type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
1385
1894
  rule: 'lowShuffleParallelism', partitions: fix.partitions, metric: 'taskCount', value: taskCount,
@@ -1442,8 +1951,7 @@ export const DETECTORS = [
1442
1951
  type: 'spill', order: 10, fixEffort: 'code', version: 1,
1443
1952
  emits: ['spill'],
1444
1953
  docAnchor: '#bottleneck-spill',
1445
- // stageFloorPct: the 0.5% runtime floor, as for shuffle (12 of 38 spill findings on the 14 real
1446
- // 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.
1447
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 },
1448
1956
  detect(stage, ctx, thresholds) {
1449
1957
  if (stage.memoryBytesSpilled === 0) return null;
@@ -1454,7 +1962,7 @@ export const DETECTORS = [
1454
1962
  const impactBand = 'warning';
1455
1963
  // shuffle.partitions sizes a shuffle's reduce side: a stage that reads no shuffle gets memory only.
1456
1964
  const reads = stageReads(stage);
1457
- const partitionAdvice = reads === 'shuffle';
1965
+ const shuffleFix = reads === 'shuffle' ? spillShuffleFix(stage, ctx) : null;
1458
1966
  return {
1459
1967
  type: 'spill', stageId: stage.id, impactBand, reads,
1460
1968
  spillMagnitude: mag?.magnitude,
@@ -1465,10 +1973,10 @@ export const DETECTORS = [
1465
1973
  : 'Spill cause could not be classified: inspect per-task spill metrics in the Spark UI before acting.',
1466
1974
  recommendation: cls === 'skew'
1467
1975
  ? `${formatBytes(stage.memoryBytesSpilled)} spilled, skew-driven: fix task skew first; adding memory will not help.`
1468
- : partitionAdvice
1469
- ? `${formatBytes(stage.memoryBytesSpilled)} spilled: raise spark.sql.shuffle.partitions or increase executor memory.`
1976
+ : shuffleFix
1977
+ ? `${formatBytes(stage.memoryBytesSpilled)} spilled: ${shuffleFix.text}.`
1470
1978
  : `${formatBytes(stage.memoryBytesSpilled)} spilled: this stage reads no shuffle, so increase executor memory or process less data per task.`,
1471
- remediation: cls === 'skew' ? [] : [...(partitionAdvice ? [increaseConf('spark.sql.shuffle.partitions')] : []), increaseConf('spark.executor.memory')],
1979
+ remediation: cls === 'skew' ? [] : shuffleFix?.remediation ?? [increaseConf('spark.executor.memory')],
1472
1980
  };
1473
1981
  },
1474
1982
  estimate(finding, ctx) {
@@ -1478,7 +1986,10 @@ export const DETECTORS = [
1478
1986
  const diskBytesSpilled = stage.diskBytesSpilled ?? 0;
1479
1987
  const wasteMs = (diskBytesSpilled / (SPILL_IO_THROUGHPUT_BPS * stageIoParallelism(stage))) * 1000;
1480
1988
  // Surfaces the number the formula uses: the displayed metric is memoryBytesSpilled, but disk spill costs the I/O time.
1481
- 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
+ );
1482
1993
  },
1483
1994
  }),
1484
1995
  defineStageDetector({
@@ -1487,14 +1998,13 @@ export const DETECTORS = [
1487
1998
  docAnchor: '#bottleneck-gc',
1488
1999
  thresholds: {
1489
2000
  warnPct100: 10,
1490
- // Descending tier: ExecutorGcHeuristic, ported as-is.
2001
+ // A GC share below this reads as over-provisioned memory (the low-GC note).
1491
2002
  lowInfoPct100: 5,
1492
- // 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.
1493
2004
  minRunTimeMs: 10000,
1494
2005
  // lowInfoFloorPct: the 0.5% runtime floor stageShape's lowParallelism uses. The low-GC note is
1495
2006
  // an app-level memory-sizing signal; on a stage shorter than this share of the run it adds
1496
- // nothing to that call. On the 14 real logs that was 464 of 685 low-GC notes (of 701 gc
1497
- // findings); the low-GC pattern is still true on those stages, the floor is why they're dropped.
2007
+ // nothing to that call.
1498
2008
  lowInfoFloorPct: 0.005,
1499
2009
  },
1500
2010
  detect(stage, ctx, thresholds) {
@@ -1511,8 +2021,14 @@ export const DETECTORS = [
1511
2021
  remediation: [increaseConf('spark.executor.memory')],
1512
2022
  };
1513
2023
  }
1514
- // 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).
1515
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
1516
2032
  && pct < thresholds.lowInfoPct100
1517
2033
  && !stageBelowRuntimeFloor(stage, ctx, thresholds.lowInfoFloorPct)) {
1518
2034
  const value = Math.round(pct * 10) / 10;
@@ -1551,7 +2067,7 @@ export const DETECTORS = [
1551
2067
  },
1552
2068
  }),
1553
2069
  defineStageDetector({
1554
- type: 'slowHost', order: 60, fixEffort: 'config', version: 1,
2070
+ type: 'slowHost', order: 60, fixEffort: 'config', version: 2,
1555
2071
  emits: ['slowHost'],
1556
2072
  docAnchor: '#bottleneck-slow-host',
1557
2073
  thresholds: {
@@ -1562,11 +2078,15 @@ export const DETECTORS = [
1562
2078
  floorMs: 1000, floorBytes: 64 * MB,
1563
2079
  // stageFloorPct: the tiered detectors' 0.5% runtime floor. On a stage shorter than this share
1564
2080
  // of the run a slow host can't cost that much: duration findings are clipped to the stage
1565
- // and graded info, and a byte-dimension imbalance (no time estimate, so its ratio tier was
1566
- // its band) was graded info here since 6298149. So the stage is skipped: on the 14 real logs
1567
- // 324 of 452 slowHost findings, all info. The imbalance is still there on those stages; the
1568
- // 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.
1569
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,
1570
2090
  },
1571
2091
  detect(stage, ctx, thresholds) {
1572
2092
  const hosts = stage.hostStats ?? [];
@@ -1574,9 +2094,10 @@ export const DETECTORS = [
1574
2094
  if ((hosts.length < thresholds.minHosts && execs0.length < thresholds.minHosts) || stage.taskCount < thresholds.minTasks) return null;
1575
2095
  if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
1576
2096
  const out = [];
2097
+ const rule = speculationRule(ctx.app);
1577
2098
  const speculation = switchFix(loggedAs(ctx.app, 'spark.speculation', true), 'spark.speculation', true,
1578
- 'check what it was running, and consider enabling spark.speculation to relaunch a lagging task automatically',
1579
- '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})` : ''}`);
1580
2101
  if (hosts.length >= thresholds.minHosts) {
1581
2102
  const means = hosts.map(h => ({ host: h.host, taskCount: h.taskCount, mean: h.totalDuration / h.taskCount }));
1582
2103
  const sorted = [...means].map(h => h.mean).sort((a, b) => a - b);
@@ -1622,8 +2143,15 @@ export const DETECTORS = [
1622
2143
  const impactBandFor = (r ) =>
1623
2144
  r >= tiers[3] ? 'critical' : (r >= tiers[1] ? 'warning' : (r >= tiers[0] ? 'info' : null));
1624
2145
  const floorMs = thresholds.floorMs, floorBytes = thresholds.floorBytes;
1625
- const dims = [
1626
- { 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
+ },
1627
2155
  { dimension: 'inputBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: e.inputBytes ?? 0 })) },
1628
2156
  { dimension: 'shuffleBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: (e.shuffleReadBytes ?? 0) + (e.shuffleWriteBytes ?? 0) })) },
1629
2157
  ];
@@ -1633,14 +2161,13 @@ export const DETECTORS = [
1633
2161
  dims.push({ dimension: 'storageMemory', floor: floorBytes, samples: [...em.entries()].map(([id, m]) => ({ key: id, value: (m.onHeapStorageMemory ?? 0) + (m.offHeapStorageMemory ?? 0) })) });
1634
2162
  }
1635
2163
  for (const d of dims) {
1636
- const r = maxMedianRatio(d.samples);
2164
+ const r = maxMedianRatio(d.samples, d.qualifies);
1637
2165
  if (!r || r.value < d.floor) continue; // absolute-magnitude floor: same ratio+floor shape as computeSpillMagnitude
1638
2166
  const tier = impactBandFor(r.ratio);
1639
2167
  if (!tier) continue;
1640
- // taskTime is the only wallClock-bearing dimension here: fixed fallback
1641
- // (its current floor case), overwritten by deriveImpactBand whenever this
1642
- // finding gets a real wallClock estimate. The other three dimensions never
1643
- // 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.
1644
2171
  const impactBand = d.dimension === 'taskTime' ? 'info' : tier;
1645
2172
  out.push({
1646
2173
  type: 'slowHost', stageId: stage.id, impactBand,
@@ -1650,6 +2177,7 @@ export const DETECTORS = [
1650
2177
  // in this dimension's own unit (ms for taskTime, bytes for the rest).
1651
2178
  execMaxValue: r.value,
1652
2179
  executorId: r.key,
2180
+ ...(d.dimension === 'taskTime' ? executorTaskTimeCaveat(r.key, r.ratio, d.samples.length, stage.taskCount) : {}),
1653
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.`,
1654
2182
  });
1655
2183
  }
@@ -1683,7 +2211,7 @@ export const DETECTORS = [
1683
2211
  thresholds: { infoMin: 15 },
1684
2212
  // A stage slowHost already explains needs no generic "this stage is slow" finding on top.
1685
2213
  suppressedBy: 'slowHost',
1686
- detect(stage, _ctx, thresholds) {
2214
+ detect(stage, ctx, thresholds) {
1687
2215
  // Basis is real wall-clock stage duration, not per-executor average; the impact-estimator
1688
2216
  // formula reuses this exact stageDurationMs computation.
1689
2217
  const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
@@ -1693,11 +2221,12 @@ export const DETECTORS = [
1693
2221
  const impactBand = durationMinutes >= t.infoMin ? 'info' : null;
1694
2222
  if (!impactBand) return null;
1695
2223
  const value = Math.round(durationMinutes * 10) / 10;
1696
- // shuffle.partitions and default.parallelism size a shuffle's reduce side: a stage that reads
1697
- // 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.
1698
2226
  const reads = stageReads(stage);
1699
- const recommendation = reads === 'shuffle'
1700
- ? `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.`
1701
2230
  : reads === 'input'
1702
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.`
1703
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.`;
@@ -1705,8 +2234,8 @@ export const DETECTORS = [
1705
2234
  type: 'stageSlowness', stageId: stage.id, impactBand,
1706
2235
  metric: 'stageDurationMinutes', value, reads,
1707
2236
  recommendation,
1708
- remediation: reads === 'shuffle'
1709
- ? [increaseConf('spark.sql.shuffle.partitions'), increaseConf('spark.default.parallelism')]
2237
+ remediation: shuffleFix
2238
+ ? shuffleFix.remediation
1710
2239
  : reads === 'input' ? [decreaseConf('spark.sql.files.maxPartitionBytes')] : [],
1711
2240
  };
1712
2241
  },
@@ -1721,8 +2250,7 @@ export const DETECTORS = [
1721
2250
  // too, hence TAIL_CLAIM's post-fix floor. Unknown cluster size: no defensible figure.
1722
2251
  if (ctx.totalCores <= 0) return costOnly('modeled');
1723
2252
  // A stage that read no input and no shuffle, its tasks idle waiting on an external system,
1724
- // gains nothing from more partitions (a 1-task JDBC count stage open 27 minutes on 5s of CPU
1725
- // 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.
1726
2254
  const readBytes = (stage.inputBytes ?? 0) + (stage.shuffleReadBytes ?? 0);
1727
2255
  const activeMs = typeof stage.taskActiveMs === 'number'
1728
2256
  ? stage.taskActiveMs
@@ -1793,63 +2321,39 @@ export const DETECTORS = [
1793
2321
  // floorPctWarn/floorPctCrit default to impact-band.ts's run-wide noise floor, so a tail this
1794
2322
  // gate admits at its warn floor grades at least warning there too.
1795
2323
  // shareWarnAtFloor: in a large stage, the few stragglers that gate it for tens of seconds can be
1796
- // only 2.5-5% of its tasks. Scored against a task-level replay of every stage on 14 real logs
1797
- // (recoverable = replay with each task over 4x P50 capped at P50), admitting 2.5-5% shares
1798
- // whose clipped tail already clears floorPctWarn found 3 such stages (10-48s) for 1 borderline
1799
- // 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.
1800
2325
  // A stage shorter than floorPctWarn of the run is skipped outright: its tail can't cost more
1801
- // than the stage's own duration, so every finding there graded info. On the 14 real logs that
1802
- // was 671 of 753 straggler findings, none above info; the slow tail is still real on those
1803
- // 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.
1804
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 },
1805
2330
  detect(stage, ctx, thresholds) {
1806
- if (stage.taskCount < thresholds.minTasks) return null;
1807
- const runMs = appDurationMs(ctx.app);
1808
- if (stageBelowRuntimeFloor(stage, ctx, thresholds.floorPctWarn)) return null;
1809
- const stragglerShare = (stage.stragglerCount ?? 0) / stage.taskCount;
1810
- const useSpeculative = (stage.speculativeTasks ?? 0) > 0;
1811
- if (!useSpeculative && stragglerShare <= thresholds.shareWarnAtFloor) return null;
1812
- const speculativeShare = useSpeculative ? stage.speculativeTasks / stage.taskCount : 0;
1813
- // The claim estimate() reports as savings: a high straggler/speculative share on a stage
1814
- // whose tasks barely vary models near-zero savings, so it must not outrank 'info'.
1815
- const floorWasteMs = tailClaimFloorMs(stragglerTailClaim(stage), stage.id, ctx);
1816
- const meetsWarnFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctWarn);
1817
- const meetsCritFloor = meetsRuntimeFloor(floorWasteMs, runMs, thresholds.floorPctCrit);
1818
- // The lower gate needs positive evidence the tail matters: meetsRuntimeFloor passes by
1819
- // default when the app's duration is unknown (an incomplete run), which isn't that.
1820
- const stragglerShareFires = stragglerShare > thresholds.shareWarn
1821
- || (stragglerShare > thresholds.shareWarnAtFloor && runMs != null && meetsWarnFloor);
1822
- if (!useSpeculative && !stragglerShareFires) return null;
1823
- const speculativeTier = speculativeShare >= thresholds.critPct && meetsCritFloor ? 'critical'
1824
- : speculativeShare >= thresholds.warnPct && meetsWarnFloor ? 'warning' : 'info';
1825
- // Straggler share has no dedicated critical tier per detector-contract.md; only warning.
1826
- const stragglerTier = stragglerShareFires && meetsWarnFloor ? 'warning' : 'info';
1827
- // Fixed fallback: overwritten by deriveImpactBand when this finding gets a real wallClock
1828
- // estimate (the common case). Only surfaces on the rare occupancy-sweep miss.
1829
- const impactBand = 'info';
1830
- // Report whichever signal actually drove the finding, not just whether speculation was on:
1831
- // a high stragglerShare with few speculative retries must not be reported as a low-value
1832
- // speculativeTasks count. Ties keep the speculative-driven default.
1833
- const useSpeculativeMetric = useSpeculative && !(IMPACT_BAND_ORDER[stragglerTier] < IMPACT_BAND_ORDER[speculativeTier]);
1834
- const value = useSpeculativeMetric ? stage.speculativeTasks : Math.round(stragglerShare * 100);
1835
- const detail = useSpeculativeMetric
1836
- ? `${value} speculative attempt${value === 1 ? '' : 's'} discarded`
1837
- : `${value}% of tasks straggled`;
1838
- // The skew advice fits only a stage that reads a shuffle feeding a join (skewFix).
1839
- 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);
1840
2345
  return {
1841
- type: 'straggler', stageId: stage.id, impactBand, origin: fix.origin,
1842
- metric: useSpeculativeMetric ? 'speculativeTasks' : 'stragglerShare',
1843
- value,
1844
- 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,
1845
2351
  speculativeTasks: stage.speculativeTasks ?? 0,
1846
2352
  stragglerCount: stage.stragglerCount ?? 0,
1847
- confidence: useSpeculativeMetric
1848
- ? stragglerConfidence(speculativeShare, thresholds.warnPct, thresholds.critPct)
1849
- : stragglerConfidence(stragglerShare, thresholds.shareWarn, thresholds.critPct),
2353
+ confidence: own?.confidence ?? skewConfidence(skewTail .ratio, skew.ratioWarn),
1850
2354
  validationRequired: `Warning needs at least ${shareLabel(thresholds.floorPctWarn)} of run time at stake, critical ${shareLabel(thresholds.floorPctCrit)}.`,
1851
- 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}.`,
1852
- remediation: fix.remediation,
2355
+ recommendation: advice.recommendation,
2356
+ remediation: advice.remediation,
1853
2357
  };
1854
2358
  },
1855
2359
  estimate(finding, ctx) {
@@ -1864,7 +2368,7 @@ export const DETECTORS = [
1864
2368
  emits: ['speculationWaste'],
1865
2369
  docAnchor: '#bottleneck-speculation-waste',
1866
2370
  thresholds: { minWasted: 5, minWasteMs: 60000 },
1867
- detect(stage, _ctx, thresholds) {
2371
+ detect(stage, ctx, thresholds) {
1868
2372
  const wasted = stage.speculationWastedAttempts ?? 0;
1869
2373
  const wastedMs = stage.speculationWasteMs ?? 0;
1870
2374
  if (wasted < thresholds.minWasted || wastedMs < thresholds.minWasteMs) return null;
@@ -1873,7 +2377,7 @@ export const DETECTORS = [
1873
2377
  impactBand: 'warning',
1874
2378
  metric: 'speculationWasteMs', value: wastedMs,
1875
2379
  confidence: speculationWasteConfidence(wastedMs, thresholds.minWasteMs),
1876
- 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)}.`,
1877
2381
  remediation: [increaseConf('spark.speculation.multiplier'), increaseConf('spark.speculation.quantile')],
1878
2382
  };
1879
2383
  },
@@ -1921,8 +2425,7 @@ export const DETECTORS = [
1921
2425
  emits: ['tinyTask'],
1922
2426
  docAnchor: '#bottleneck-tiny-tasks',
1923
2427
  // stageFloorPct: the tiered detectors' 0.5% runtime floor. Coalescing can't save more than the
1924
- // stage's own duration, so on a shorter stage every finding graded info: on the 14 real logs
1925
- // 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.
1926
2429
  thresholds: { minTasks: 100, maxP50: 500, maxP95: 1000, stageFloorPct: 0.005 },
1927
2430
  detect(stage, ctx, thresholds) {
1928
2431
  if (stage.taskCount < thresholds.minTasks) return null;
@@ -1930,14 +2433,12 @@ export const DETECTORS = [
1930
2433
  if (stage.taskDurationP50 > thresholds.maxP50 || stage.taskDurationP95 > thresholds.maxP95) return null;
1931
2434
  const coalesceTo = Math.max(1, Math.round(stage.taskCount / 10));
1932
2435
  const reads = stageReads(stage);
1933
- const fix = reads === 'shuffle'
1934
- ? `lower spark.sql.shuffle.partitions or .coalesce(${coalesceTo})`
1935
- : `.coalesce(${coalesceTo})`;
2436
+ const fix = reads === 'shuffle' ? tinyShuffleFix(stage, ctx, coalesceTo) : { text: `.coalesce(${coalesceTo})`, remediation: [] };
1936
2437
  return {
1937
2438
  type: 'tinyTask', stageId: stage.id, impactBand: 'info', reads,
1938
2439
  metric: 'taskDurationP50', value: Math.round(stage.taskDurationP50),
1939
- recommendation: `Many small tasks (${stage.taskCount}, P50 ${Math.round(stage.taskDurationP50)}ms): scheduler overhead may dominate. Try ${fix}.`,
1940
- 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,
1941
2442
  };
1942
2443
  },
1943
2444
  estimate(finding, ctx) {
@@ -1990,10 +2491,7 @@ export const DETECTORS = [
1990
2491
  // No stage ever recorded a submission timestamp: no basis to measure a startup gap against.
1991
2492
  if (!Number.isFinite(firstStageSubmitted)) return null;
1992
2493
  // The wait is from the first runnable stage to the first executor, not from app start: the
1993
- // driver's own startup before its first job (36-47s on every real log, whatever the
1994
- // executors did) isn't something executors could shorten. On the 9 real logs that fired,
1995
- // the first executor arrived 146-192s after the first stage on two whose old gap read ~40s,
1996
- // 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.
1997
2495
  // An executor added before the first stage only counts if it was still alive at submission:
1998
2496
  // with dynamic allocation scaling to zero, early executors can idle out before any job runs.
1999
2497
  const removedAt = new Map ();
@@ -2036,7 +2534,6 @@ export const DETECTORS = [
2036
2534
  thresholds: { minUtil: 0.60 },
2037
2535
  detect(ctx, thresholds) {
2038
2536
  const { app, executorsAdded, executorsRemoved, runAggregates } = ctx;
2039
- // Nullish (not falsy) check: a literal startTime:0 must not be treated as "missing".
2040
2537
  if (!app || executorsAdded.length === 0 || app.startTime == null || app.endTime == null) return null;
2041
2538
  const appDuration = app.endTime - app.startTime;
2042
2539
  if (appDuration <= 0) return null;
@@ -2052,13 +2549,12 @@ export const DETECTORS = [
2052
2549
  // Busy core-time (from the whole-run core-time-series, same signal memoryUtilization's
2053
2550
  // idleCores variant already uses), not executor lifetime: an executor that exists for the
2054
2551
  // whole run but sits fully idle must not score as 100% used. Missing runAggregates (older
2055
- // callers, synthetic fixtures) reads as 0 busy time rather than falling back to the
2056
- // lifetime-based measure this replaces.
2552
+ // callers, synthetic fixtures) reads as 0 busy time.
2057
2553
  const busyCoreMs = runAggregates?.busyCoreMs ?? 0;
2058
2554
  const utilization = busyCoreMs / capacityCoreMs;
2059
2555
  if (utilization >= thresholds.minUtil) return null;
2060
2556
 
2061
- // CPU-time-based utilization (sparkMeasure): metric only, no threshold.
2557
+ // CPU-time-based utilization: metric only, no threshold.
2062
2558
  // Null when no stage recorded CPU time (older Spark), the same rule as the CLI metrics block.
2063
2559
  const cpuMs = totalExecutorCpuMs(ctx.stages.values());
2064
2560
  const cpuUtilizationPct = cpuMs == null ? null : Math.round((cpuMs / capacityCoreMs) * 100);
@@ -2098,26 +2594,20 @@ export const DETECTORS = [
2098
2594
  emits: ['memoryUtilization'],
2099
2595
  docAnchor: '#bottleneck-memory-utilization',
2100
2596
  thresholds: {
2101
- idleCoreWarn: 0.50, // WastedCoresAlertsReducer
2102
- bandTooSmall: 0.95, // MemoryAlertsReducer: used/allocated
2597
+ idleCoreWarn: 0.50, // idle share of allocated core time above which idleCores fires
2103
2598
  bandTooHigh: 0.70, // below this => over-provisioned (cost signal)
2104
- wasteBufferMultiplier: 1.5, // UNVERIFIED
2599
+ wasteBufferMultiplier: 1.5, // wasted memory-time must exceed used by this multiple
2105
2600
  },
2106
2601
  detect(ctx, thresholds) {
2107
2602
  const { app, executorsAdded, executorsRemoved, runAggregates, stages } = ctx;
2108
2603
  const out = [];
2109
- // Nullish (not falsy) check: a literal startTime:0 must not be treated as "missing".
2110
2604
  if (app?.startTime == null || app?.endTime == null) return out;
2111
2605
  const appDurationMs = app.endTime - app.startTime;
2112
2606
  if (appDurationMs <= 0) return out;
2113
2607
 
2114
- // Peak concurrent executors/cores (not executorsAdded.length/computeTotalCores): a
2115
- // cumulative sum or count double-counts a churned-through executor against its replacement's
2116
- // (spot preemption, dynamicAllocation replacement), inflating idle-rate and waste-model figures.
2117
- const peakExecutors = computePeakConcurrentExecutorCount(executorsAdded, executorsRemoved);
2118
2608
  const allocatedMB = app.resources?.executor?.memoryMB ?? null;
2119
2609
 
2120
- // ── 1a idle-cores rate ────────────────────────────────────────────────
2610
+ // Idle-cores rate.
2121
2611
  const busyCoreMs = runAggregates?.busyCoreMs;
2122
2612
  // Allocated core-time, as for `utilization`, so the two findings of one idle condition agree.
2123
2613
  const allocationInput = { app, stages, executors: { added: executorsAdded, removed: executorsRemoved } };
@@ -2141,63 +2631,63 @@ export const DETECTORS = [
2141
2631
  }
2142
2632
  }
2143
2633
 
2144
- // ── 1b memory bands (executor only; driver half dropped since there is no driver metric) ─
2145
- // Peak heap per executor = max jvmHeapMemory across all stages' executorMetrics.
2146
- const peakHeapByExec = new Map ();
2147
- for (const s of stages.values()) {
2148
- const em = s.executorMetrics;
2149
- if (!(em instanceof Map)) continue;
2150
- for (const [execId, m] of em) {
2151
- const heap = m?.jvmHeapMemory ?? 0;
2152
- if (heap > (peakHeapByExec.get(execId) ?? 0)) peakHeapByExec.set(execId, heap);
2153
- }
2154
- }
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);
2155
2639
  if (peakHeapByExec.size === 0) {
2156
- const key = 'spark.eventLog.logStageExecutorMetrics';
2157
- const fix = switchFix(loggedAs(app, key, true), key, true,
2158
- `Per-executor memory usage requires ${key}=true: not enabled for this run.`,
2159
- '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.`);
2160
2647
  out.push({
2161
2648
  type: 'memoryUtilization', variant: 'memoryBand', stageId: null,
2162
2649
  impactBand: 'info', metric: 'memoryBand', dataUnavailable: true,
2163
- recommendation: fix.text,
2164
- 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,
2165
2652
  });
2166
2653
  } else if (allocatedMB != null && allocatedMB > 0) {
2167
2654
  const allocatedBytes = allocatedMB * 1024 * 1024;
2168
- for (const [execId, heap] of peakHeapByExec) {
2169
- const ratio = heap / allocatedBytes;
2170
- // The two bands are opposite signals: an explicit `rule` discriminator lets consumers
2171
- // tell OOM-risk from over-provisioning without re-deriving the ratio.
2172
- if (ratio > thresholds.bandTooSmall) {
2173
- out.push({
2174
- type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapNearCapacity',
2175
- stageId: null, executorId: execId,
2176
- impactBand: 'warning', metric: 'heapUsedRatio', value: Math.round(ratio * 100),
2177
- 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.`,
2178
- remediation: [increaseConf('spark.executor.memory')],
2179
- });
2180
- } else if (ratio < thresholds.bandTooHigh) {
2181
- out.push({
2182
- type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapOverProvisioned',
2183
- stageId: null, executorId: execId,
2184
- impactBand: 'info', metric: 'heapUsedRatio', value: Math.round(ratio * 100),
2185
- // Absolute figures behind the rounded ratio, for the estimator's
2186
- // unused-memory-over-time model.
2187
- allocatedBytes, heap, appDurationMs,
2188
- 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.`,
2189
- remediation: [decreaseConf('spark.executor.memory')],
2190
- });
2191
- }
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
+ });
2192
2676
  }
2193
2677
  }
2194
2678
 
2195
- // ── 1c Spark Memory Limit waste model (UNVERIFIED buffer) ─
2196
- if (allocatedMB != null && peakExecutors > 0) {
2197
- 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;
2198
2688
  let usedRunTimeMs = 0;
2199
2689
  for (const s of stages.values()) usedRunTimeMs += s.executorRunTime ?? 0;
2200
- const usedMBSeconds = allocatedMB * (usedRunTimeMs / 1000);
2690
+ const usedMBSeconds = allocatedMB * (usedRunTimeMs / 1000 / executorCores);
2201
2691
  const wastedMBSeconds = allocatedMBSeconds - usedMBSeconds;
2202
2692
  if (wastedMBSeconds > thresholds.wasteBufferMultiplier * usedMBSeconds) {
2203
2693
  const value = Math.round(wastedMBSeconds);
@@ -2229,15 +2719,15 @@ export const DETECTORS = [
2229
2719
  }
2230
2720
  return costOnly('modeled');
2231
2721
  }
2232
- // Only the over-provisioned band is a waste; the near-capacity band is an OOM-risk signal with
2233
- // 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.
2234
2724
  if (finding.variant === 'memoryBand' && finding.rule === 'heapOverProvisioned') {
2235
2725
  const allocatedBytes = finding.allocatedBytes ;
2236
2726
  const heap = finding.heap ;
2237
- const appDurationMs = finding.appDurationMs ;
2238
- if (allocatedBytes != null && heap != null && appDurationMs != null) {
2727
+ const executorSeconds = finding.executorSeconds ;
2728
+ if (allocatedBytes != null && heap != null && executorSeconds != null) {
2239
2729
  const unusedMB = (allocatedBytes - heap) / (1024 * 1024);
2240
- const wastedMBSeconds = unusedMB * (appDurationMs / 1000);
2730
+ const wastedMBSeconds = unusedMB * executorSeconds;
2241
2731
  return costOnly('modeled', { value: wastedMBSeconds, unit: 'mbSeconds' });
2242
2732
  }
2243
2733
  }
@@ -2264,7 +2754,7 @@ export const DETECTORS = [
2264
2754
  const out = [];
2265
2755
  let persistedRddCount = 0;
2266
2756
  // With block-update logging on, zero rdd_* updates means nothing was ever cached, not a gap.
2267
- const blockUpdatesLogged = String(ctx.app?.config?.['spark.eventLog.logBlockUpdates.enabled']).toLowerCase() === 'true';
2757
+ const blockUpdatesLogged = loggedAs(ctx.app, 'spark.eventLog.logBlockUpdates.enabled', true);
2268
2758
  // Spark before 2.3 has no block-update logging and writes RDD Info's cache figures only on
2269
2759
  // StageCompleted, which isn't read: the caveat's advice doesn't apply there. A log with no
2270
2760
  // version is pre-1.3 (no SparkListenerLogStart); every 2.3+ log records one.
@@ -2311,7 +2801,10 @@ export const DETECTORS = [
2311
2801
  const uncachedBytes = numCachedPartitions > 0 ? (cachedBytes / numCachedPartitions) * numUncachedPartitions : 0;
2312
2802
  const uncachedOrSpilledBytes = uncachedBytes + diskSize;
2313
2803
  const wasteMs = (uncachedOrSpilledBytes / RE_READ_THROUGHPUT_BPS) * 1000;
2314
- 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
+ );
2315
2808
  },
2316
2809
  }),
2317
2810
  defineAppDetector({
@@ -2581,8 +3074,13 @@ export const DETECTORS = [
2581
3074
  },
2582
3075
  estimate(finding) {
2583
3076
  const totalReadBytes = (finding.totalReadBytes ) ?? 0;
2584
- const wasteMs = (totalReadBytes / RE_READ_THROUGHPUT_BPS) * 1000;
2585
- 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
+ );
2586
3084
  },
2587
3085
  }),
2588
3086
  defineAppDetector({
@@ -2625,43 +3123,18 @@ export const DETECTORS = [
2625
3123
  return costOnly('modeled', { value: coreHoursIsh, unit: 'coreHours' });
2626
3124
  },
2627
3125
  }),
2628
- // ── Config-sanity entries (scope:'config', inScorecard:false) ────────────────
2629
- defineConfigDetector({
2630
- type: 'configAudit', order: 120, fixEffort: 'config', version: 1, inScorecard: false,
2631
- emits: ['configAudit'],
2632
- docAnchor: '#config-shuffle-service', thresholds: {}, property: 'spark.shuffle.service.enabled',
2633
- detect(target) {
2634
- const res = target.app?.resources ?? null;
2635
- if (res?.dynamicAllocationEnabled === true && res?.shuffleServiceEnabled === false) {
2636
- return {
2637
- type: 'configAudit', property: 'spark.shuffle.service.enabled',
2638
- impactBand: 'warning', metric: 'config', valueText: 'false',
2639
- recommendation: 'Dynamic allocation is on but the external shuffle service is off: set spark.shuffle.service.enabled=true so shuffle data survives executor removal.',
2640
- remediation: setConfUnlessLogged(target.app, 'spark.shuffle.service.enabled', true),
2641
- };
2642
- }
2643
- return null;
2644
- },
2645
- estimate: noWasteModel,
2646
- }),
2647
3126
  defineConfigDetector({
2648
3127
  type: 'configAudit', order: 121, fixEffort: 'config', version: 1, inScorecard: false,
2649
3128
  emits: ['configAudit'],
2650
3129
  docAnchor: '#config-autoscale-bounds', thresholds: {}, property: 'spark.dynamicAllocation.maxExecutors',
2651
3130
  detect(target) {
2652
- const app = target.app; const config = app?.config ?? {}; const res = app?.resources ?? null;
3131
+ const app = target.app; const res = app?.resources ?? null;
2653
3132
  if (res?.dynamicAllocationEnabled !== true) return null;
2654
- const minN = config['spark.dynamicAllocation.minExecutors'] != null ? parseInt(config['spark.dynamicAllocation.minExecutors'], 10) : null;
2655
- const maxN = config['spark.dynamicAllocation.maxExecutors'] != null ? parseInt(config['spark.dynamicAllocation.maxExecutors'], 10) : null;
2656
- if (minN != null && maxN != null && minN > maxN) {
2657
- return {
2658
- type: 'configAudit', property: 'spark.dynamicAllocation.minExecutors',
2659
- impactBand: 'critical', metric: 'config', valueText: `${minN} > ${maxN}`,
2660
- recommendation: `spark.dynamicAllocation.minExecutors (${minN}) exceeds maxExecutors (${maxN}): set min ≤ max.`,
2661
- remediation: [decreaseConf('spark.dynamicAllocation.minExecutors', maxN)],
2662
- };
2663
- }
2664
- 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) {
2665
3138
  return {
2666
3139
  type: 'configAudit', property: 'spark.dynamicAllocation.maxExecutors',
2667
3140
  impactBand: 'info', metric: 'config', valueText: '(unset)',
@@ -2680,7 +3153,13 @@ export const DETECTORS = [
2680
3153
  detect(target) {
2681
3154
  const app = target.app; const config = app?.config ?? {}; const res = app?.resources ?? null;
2682
3155
  if (Object.keys(config).length === 0) return null;
2683
- 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);
2684
3163
  const isKryo = typeof ser === 'string' && /kryo/i.test(ser);
2685
3164
  if (isKryo) return null;
2686
3165
  return {
@@ -2701,26 +3180,25 @@ export const DETECTORS = [
2701
3180
  const memMB = res?.executor?.memoryMB ?? null;
2702
3181
  const ovMB = res?.executor?.memoryOverheadMB ?? null;
2703
3182
  if (memMB == null || ovMB == null) return null;
2704
- 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));
2705
3185
  if (ovMB >= floor) return null;
2706
3186
  return {
2707
3187
  type: 'configAudit', property: 'spark.executor.memoryOverhead',
2708
3188
  impactBand: 'info', metric: 'config', valueText: `${ovMB} MiB`,
2709
- 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.`,
2710
3190
  remediation: [increaseConf('spark.executor.memoryOverhead', `${floor}m`)],
2711
3191
  };
2712
3192
  },
2713
3193
  estimate: noWasteModel,
2714
3194
  }),
2715
- // ── Plan-metric entries (scope:'sql') ────────────────────────────────────
2716
3195
  defineSqlDetector({
2717
3196
  type: 'duplicatePlanSubtree', order: 130, fixEffort: 'code', version: 2,
2718
3197
  emits: ['duplicatePlanSubtree'],
2719
3198
  docAnchor: '#bottleneck-duplicate-plan-subtree',
2720
3199
  // stageFloorPct: the 0.5% runtime floor. The claim counts at most each linked stage's own
2721
3200
  // task-active time, so a repeat whose stages together lasted less than this share of the run
2722
- // graded info: 340 of 545 findings on the 14 real logs. The repeat is still in the plan; the
2723
- // 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.
2724
3202
  thresholds: { minSubtreeSize: 3, minOccurrences: 2, stageFloorPct: 0.005 },
2725
3203
  detect(sqlExec, ctx, thresholds) {
2726
3204
  if (!sqlExec.planTree) return null;
@@ -2743,16 +3221,15 @@ export const DETECTORS = [
2743
3221
  if (runMs != null && stagesMs > 0 && stagesMs < runMs * thresholds.stageFloorPct) return null;
2744
3222
  const occurrencesIdentical = occurrencesHaveIdenticalDetails(g.nodes);
2745
3223
  const stageShares = stageOperatorShares(nodes, operatorsByStage);
2746
- // resolvePlanTree always sets id; safe downstream of it.
3224
+ // resolvePlanTree always sets id, so the `!` here and below is safe.
2747
3225
  const planNodeIds = nodes.map((n) => n.id ).filter(Boolean);
2748
3226
  const detail = duplicateSubtreeDetail({ ...g, value: g.occurrences });
2749
3227
  const differing = occurrencesIdentical ? '' : ` ${DUPLICATE_SUBTREE_DIFFERING_NOTE}`;
2750
3228
  return {
2751
3229
  type: 'duplicatePlanSubtree', executionId: sqlExec.id, stageIds, planNodeIds,
2752
3230
  stageShares, occurrencesIdentical,
2753
- // Fixed fallback: overwritten by deriveImpactBand when this finding gets a real
2754
- // wallClock estimate. Repeats with differing details get no estimate (nothing is
2755
- // 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.
2756
3233
  impactBand: occurrencesIdentical && Object.keys(stageShares).length > 0 ? 'warning' : 'info',
2757
3234
  metric: 'subtreeOccurrences', value: g.occurrences,
2758
3235
  rootName: g.rootName, subtreeSize: g.subtreeSize, sampleRelation: g.sampleRelation,
@@ -2772,7 +3249,7 @@ export const DETECTORS = [
2772
3249
  // detect() reports subtreeOccurrences >= 2. Only repeats past the first are redundant:
2773
3250
  // computing the subtree once is real work, so waste is (occurrences-1)/occurrences of the stages' time.
2774
3251
  const occurrences = typeof finding.value === 'number' ? finding.value : 0;
2775
- // 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.
2776
3253
  if (occurrences < 2) return costOnly('none');
2777
3254
  // Same-shaped repeats whose details differ compute different data: nothing is known to be
2778
3255
  // recomputed, so there is no time to claim.
@@ -2788,8 +3265,8 @@ export const DETECTORS = [
2788
3265
  const s = ctx.stages.get(id);
2789
3266
  const share = shares ? (shares[id] ?? 0) : 1;
2790
3267
  if (s && share > 0) {
2791
- // Time with tasks running, not submit-to-complete: a stage left waiting for cores
2792
- // (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.
2793
3270
  const activeMs = s.taskActiveMs ?? Math.max(0, (s.completedAt ?? 0) - (s.submittedAt ?? 0));
2794
3271
  wasteMsByStage.set(id, activeMs * share * redundantFraction);
2795
3272
  }
@@ -2847,8 +3324,7 @@ export const DETECTORS = [
2847
3324
  estimate(finding, ctx) {
2848
3325
  const fileMs = ((finding.fileCount ) ?? 0) * FILE_OPEN_OVERHEAD_MS;
2849
3326
  // A read's files are opened by the scan's tasks, in parallel: spread the per-file cost over
2850
- // the most tasks its stages ran at once (91344 files x 10ms is 913s, claimed against a 117s
2851
- // 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
2852
3328
  // output file on the driver, one after another.
2853
3329
  const stageIds = finding.stageIds ;
2854
3330
  let slots = 1;
@@ -2861,50 +3337,72 @@ export const DETECTORS = [
2861
3337
  defineSqlDetector({
2862
3338
  // Entry-level type is an identifier only; it never appears on an emitted finding. Findings
2863
3339
  // carry 'underBroadcast'/'overBroadcast' since one shared plan-walk covers both
2864
- // opposite-direction rules (JoinToBroadcastAlert / BroadcastTooLargeAlert).
2865
- type: 'broadcastSizing', order: 132, fixEffort: 'config', version: 2,
3340
+ // opposite-direction rules.
3341
+ type: 'broadcastSizing', order: 132, fixEffort: 'config', version: 3,
2866
3342
  // Listed over-first: the two share order 132, and this list order is their display tie-break.
2867
3343
  emits: ['overBroadcast', 'underBroadcast'],
2868
3344
  docAnchor: '#bottleneck-broadcast-sizing',
2869
3345
  thresholds: {
2870
- broadcastTiers: [10 * MB, 100 * MB, GB, 5 * GB],
2871
- comparisonTiers: [10 * GB, 300 * GB, TB],
3346
+ broadcastTiers: [10 * MB, 100 * MB, GB],
3347
+ comparisonTiers: [10 * GB, 300 * GB],
2872
3348
  overBroadcastBytes: GB,
3349
+ minSmallerSideBytes: MB,
2873
3350
  },
2874
3351
  detect(sqlExec, ctx, thresholds) {
2875
3352
  if (!sqlExec.planTree) return null;
2876
- const { broadcastTiers, comparisonTiers, overBroadcastBytes } = thresholds;
3353
+ const { broadcastTiers, comparisonTiers, overBroadcastBytes, minSmallerSideBytes } = thresholds;
2877
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 ();
2878
3370
  const out = [];
2879
- walkPlanTree(sqlExec.planTree, (node) => {
2880
- if (node.name === 'SortMergeJoin' && (node.children ?? []).length === 2) {
2881
- const [childA, childB] = node.children;
2882
- const a = sumBoundarySize(childA);
2883
- const b = sumBoundarySize(childB);
2884
- const smaller = Math.min(a, b);
2885
- const larger = Math.max(a, b);
2886
- if (smaller > 0) {
2887
- const fires = smaller < broadcastTiers[0]
2888
- || (smaller < broadcastTiers[1] && larger > comparisonTiers[0])
2889
- || (smaller < broadcastTiers[2] && larger > comparisonTiers[1])
2890
- || (smaller < broadcastTiers[3] && larger > comparisonTiers[2]);
2891
- if (fires) {
2892
- const contributors = [...boundarySizeContributors(childA), ...boundarySizeContributors(childB)];
2893
- // A threshold that already admits the smaller side cannot be what stopped the broadcast.
2894
- const threshold = effectiveBroadcastThreshold(ctx.app);
2895
- const broadcastThreshold = threshold != null && threshold < 0 ? 'disabled'
2896
- : threshold != null && threshold > smaller ? 'notLimiting' : 'limits';
2897
- 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)})`;
2898
3392
  out.push({
2899
3393
  type: 'underBroadcast', executionId: sqlExec.id, stageIds: unionStageIds(contributors, fallbackStageIds),
2900
- // resolvePlanTree always sets id; safe downstream of it.
2901
3394
  planNodeIds: contributors.map((n) => n.id ).filter(Boolean),
2902
- impactBand: 'info', metric: 'smallerSideBytes', value: smaller,
2903
- largerSideBytes: larger, broadcastThreshold,
3395
+ impactBand: 'info', metric: 'smallerSideBytes',
3396
+ value: buildBytes, largerSideBytes: otherBytes,
3397
+ joinType, buildSide, broadcastThreshold,
2904
3398
  recommendation: broadcastThreshold === 'notLimiting'
2905
- ? `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).`
2906
- : `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}`,
2907
- 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)],
2908
3406
  });
2909
3407
  }
2910
3408
  }
@@ -2912,27 +3410,27 @@ export const DETECTORS = [
2912
3410
  if (isBroadcastExchangeNode(node.name)) {
2913
3411
  const m = (node.metrics ?? []).find(x => x.name === 'data size');
2914
3412
  if (m && m.value > overBroadcastBytes) {
2915
- // BroadcastExchange's own metrics are driver-computed and never on any TaskEnd
2916
- // (node.stageIds always empty in real data); its child carries the executor-side
2917
- // 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.
2918
3415
  const child = (node.children ?? [])[0];
2919
- // A threshold below the broadcast cannot have admitted it, so a hint forced it.
2920
- const threshold = effectiveBroadcastThreshold(ctx.app);
2921
- const autoBroadcastOff = threshold != null && threshold < 0;
2922
- 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;
2923
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);
2924
3423
  out.push({
2925
3424
  type: 'overBroadcast', executionId: sqlExec.id,
2926
3425
  stageIds: unionStageIds(child ? [child] : [], fallbackStageIds),
2927
- // resolvePlanTree always sets id; safe downstream of it.
2928
3426
  planNodeIds: [node.id ].filter(Boolean),
2929
3427
  impactBand: 'warning', metric: 'broadcastBytes', value: m.value, broadcastThreshold,
2930
3428
  recommendation: autoBroadcastOff
2931
3429
  ? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: automatic broadcast is already disabled, so remove the broadcast() hint that forced it.`
2932
3430
  : hintForced
2933
- ? `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.`
2934
- : `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: check for a misapplied broadcast hint or a misconfigured spark.sql.autoBroadcastJoinThreshold.`,
2935
- 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)),
2936
3434
  });
2937
3435
  }
2938
3436
  }
@@ -2941,15 +3439,138 @@ export const DETECTORS = [
2941
3439
  },
2942
3440
  estimate(finding, ctx) {
2943
3441
  // Both finding types carry bytes as `value`: overBroadcast's broadcastBytes, underBroadcast's
2944
- // smallerSideBytes (the smaller join side), each priced as one broadcast transfer.
3442
+ // smallerSideBytes (its build side), each priced as one broadcast transfer.
2945
3443
  const wasteMs = (((finding.value ) ?? 0) / BROADCAST_BANDWIDTH_BPS) * 1000;
2946
3444
  return stageMappableWasteOrCostOnly(wasteMs, finding.stageIds , ctx);
2947
3445
  },
2948
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
+ }),
2949
3570
  ] ;
2950
3571
 
2951
3572
  /** The entry that emits each finding type: the one whose estimate prices it and whose thresholds
2952
- * 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
2953
3574
  * first declared wins. */
2954
3575
  export const ENTRY_BY_TYPE = (() => {
2955
3576
  const byType = new Map ();
@@ -2989,3 +3610,9 @@ export const ENTRY_BY_TYPE = (() => {
2989
3610
 
2990
3611
 
2991
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
+ }