sparkforensics-server 0.5.1 → 0.6.0

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 (275) hide show
  1. package/package.json +1 -1
  2. package/public/assets/AutoscalingChurn-CRA2Mb7F.js +1 -0
  3. package/public/assets/CacheUtilization-Bh9OY_V9.js +1 -0
  4. package/public/assets/CachingOpportunity-oCiQwmTJ.js +1 -0
  5. package/public/assets/{ColdStart-B38OiAOp.js → ColdStart-BJrtjfmn.js} +1 -1
  6. package/public/assets/ConfigAudit-DMvKxySP.js +1 -0
  7. package/public/assets/CoreUsageArea-REC2zsKw.js +1 -0
  8. package/public/assets/Dashboard-BHZ76JCu.js +2 -0
  9. package/public/assets/DisclosureContext-OO7X1HAx.js +1 -0
  10. package/public/assets/{DuplicatePlanSubtree-BqS2A2j4.js → DuplicatePlanSubtree-BgG-L-cK.js} +1 -1
  11. package/public/assets/ExecutorUtilization-D2KhSVbY.js +1 -0
  12. package/public/assets/{GcPressure-CT1yzR_s.js → GcPressure-D1BTE8qh.js} +1 -1
  13. package/public/assets/{IncompleteRun-C7Qkjtjn.js → IncompleteRun-DD40xUpy.js} +1 -1
  14. package/public/assets/{JobFailures-WHl3ELfh.js → JobFailures-YwNzCF19.js} +1 -1
  15. package/public/assets/MemoryUtilization-9b8ee_3_.js +1 -0
  16. package/public/assets/{OverBroadcast-6oSSAQ_R.js → OverBroadcast-CfctS8a8.js} +1 -1
  17. package/public/assets/PartitionSizing-VG8kBgG5.js +1 -0
  18. package/public/assets/{PlanExplorer-DcxmiQWL.js → PlanExplorer-DmubTAOP.js} +1 -1
  19. package/public/assets/PlanGraphRoute-DctPZjS0.js +1 -0
  20. package/public/assets/RetryWaste-CnpM8bB7.js +1 -0
  21. package/public/assets/{ShuffleIO-CRhrYNE0.js → ShuffleIO-D13vGmas.js} +1 -1
  22. package/public/assets/Skew-DlagstYP.js +1 -0
  23. package/public/assets/SlowHost-cSkLRZ_e.js +1 -0
  24. package/public/assets/{SmallFiles-C082c2rd.js → SmallFiles-B-kERMcq.js} +1 -1
  25. package/public/assets/{SpeculationWaste-BS2vpadr.js → SpeculationWaste-DaysAzXO.js} +1 -1
  26. package/public/assets/Spill-H44Gz4US.js +1 -0
  27. package/public/assets/{StageFailed-D3ac9Sou.js → StageFailed-DCfii9h_.js} +1 -1
  28. package/public/assets/StageFindingGroup-BXJ0Mj5n.js +1 -0
  29. package/public/assets/{StageHeader-pI1nTwVf.js → StageHeader--oN2J-oD.js} +1 -1
  30. package/public/assets/StageShape-IZ80CZiJ.js +1 -0
  31. package/public/assets/{StageSlowness-BaNyfNvL.js → StageSlowness--BRIaBc1.js} +1 -1
  32. package/public/assets/Straggler-Ctq0Df_o.js +1 -0
  33. package/public/assets/TaskFailures-Bks_gqhL.js +1 -0
  34. package/public/assets/{TinyTask-8_5lpq_2.js → TinyTask-BssfzGFB.js} +1 -1
  35. package/public/assets/{UnderBroadcast-DJ0XSwth.js → UnderBroadcast-DsScrxc-.js} +1 -1
  36. package/public/assets/{finding-anchor-Cys8Z6Yn.js → finding-anchor-BYW1oz5R.js} +1 -1
  37. package/public/assets/{impact-sort-BTQndbof.js → impact-sort-xzy0g-Vh.js} +1 -1
  38. package/public/assets/index-DhpRGMZc.css +2 -0
  39. package/public/assets/index-DwqkFUFk.js +14 -0
  40. package/public/assets/instrument-sans-latin-ext-wdth-normal-C5E2Gvlv.woff2 +0 -0
  41. package/public/assets/instrument-sans-latin-wdth-normal-BVScPF0l.woff2 +0 -0
  42. package/public/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  43. package/public/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  44. package/public/assets/{parser-worker-DmeCnLWU.js → parser-worker-CA5RxqKc.js} +8 -8
  45. package/public/assets/react-vendor-DfpuPQZf.js +9 -0
  46. package/public/assets/{select-DIYN6D3y.js → select-C8edjYTq.js} +1 -1
  47. package/public/assets/{usePagedRows-BxvdFnPk.js → usePagedRows-CsuUFeYx.js} +1 -1
  48. package/public/assets/useRecentFiles-BvkKo3JN.js +11 -0
  49. package/public/assets/{useSortMode-B8SRfAoG.js → useSortMode-joX71BZN.js} +1 -1
  50. package/public/docs/404.html +3 -7
  51. package/public/docs/assets/{app.D2GqAkhy.js → app.CnWL6bKV.js} +1 -1
  52. package/public/docs/assets/chunks/@localSearchIndexroot.tE5pz54z.js +1 -0
  53. package/public/docs/assets/chunks/{VPLocalSearchBox.BS4cSmtW.js → VPLocalSearchBox.C0IBKeRQ.js} +1 -1
  54. package/public/docs/assets/chunks/{theme.BuxVpb0H.js → theme.BH-2HuRf.js} +2 -2
  55. package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.js} +1 -1
  56. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.js +1 -0
  57. package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.wj6Et6UD.js → contributor-guide_architecture_detector-contract.md.N4hhenrA.js} +1 -1
  58. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.js +1 -0
  59. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.DfvbqEUu.js +1 -0
  60. package/public/docs/assets/{contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.lean.js → contributor-guide_architecture_impact-estimation.md.DfvbqEUu.lean.js} +1 -1
  61. package/public/docs/assets/{contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.js → contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.js} +1 -1
  62. package/public/docs/assets/{contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.lean.js → contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.lean.js} +1 -1
  63. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.js +1 -0
  64. package/public/docs/assets/{contributor-guide_architecture_state-and-history_run-comparison-internals.md.uuAKpY0Y.lean.js → contributor-guide_architecture_state-and-history_run-comparison-internals.md.CZA0JLtV.lean.js} +1 -1
  65. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.js +1 -0
  66. package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.js → contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.js} +1 -1
  67. package/public/docs/assets/{contributor-guide_development-setup.md.D9bu4j_c.js → contributor-guide_development-setup.md.DQdzera7.js} +1 -1
  68. package/public/docs/assets/instrument-sans-latin-ext-wdth-normal.C5E2Gvlv.woff2 +0 -0
  69. package/public/docs/assets/instrument-sans-latin-wdth-normal.BVScPF0l.woff2 +0 -0
  70. package/public/docs/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  71. package/public/docs/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  72. package/public/docs/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  73. package/public/docs/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  74. package/public/docs/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  75. package/public/docs/assets/style.CyK9Ss73.css +1 -0
  76. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.BEepbDDL.js +24 -0
  77. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.BEepbDDL.lean.js +1 -0
  78. package/public/docs/assets/{user-guide_mcp-tools_compare-runs.md.CPowlb2q.js → user-guide_mcp-tools_compare-runs.md.Cc_gLoix.js} +3 -2
  79. package/public/docs/assets/{user-guide_mcp-tools_compare-runs.md.CPowlb2q.lean.js → user-guide_mcp-tools_compare-runs.md.Cc_gLoix.lean.js} +1 -1
  80. package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.Dq5Azk8G.js → user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.js} +2 -2
  81. package/public/docs/assets/{user-guide_mcp-tools_evaluate-budgets.md.Dq5Azk8G.lean.js → user-guide_mcp-tools_evaluate-budgets.md.BB0nDY3Z.lean.js} +1 -1
  82. package/public/docs/assets/{user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.js → user-guide_mcp-tools_get-run-summary.md.EhaIsele.js} +1 -1
  83. package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.js +1 -0
  84. package/public/docs/assets/user-guide_run-comparison.md.BUALkohi.lean.js +1 -0
  85. package/public/docs/assets/user-guide_run-comparison_how-stages-are-matched.md.DSXGu2tA.js +1 -0
  86. package/public/docs/assets/user-guide_run-comparison_how-stages-are-matched.md.DSXGu2tA.lean.js +1 -0
  87. package/public/docs/assets/user-guide_understanding-findings.md.3C7KCEZO.js +1 -0
  88. package/public/docs/assets/user-guide_understanding-findings.md.3C7KCEZO.lean.js +1 -0
  89. package/public/docs/contributor-guide/architecture/board-widgets/caching-and-memory.html +6 -10
  90. package/public/docs/contributor-guide/architecture/board-widgets/plan-advisor.html +4 -8
  91. package/public/docs/contributor-guide/architecture/board-widgets/visual-system-and-templating.html +6 -10
  92. package/public/docs/contributor-guide/architecture/board-widgets.html +4 -8
  93. package/public/docs/contributor-guide/architecture/detector-contract/plan-attribution.html +4 -8
  94. package/public/docs/contributor-guide/architecture/detector-contract/threshold-tables.html +6 -10
  95. package/public/docs/contributor-guide/architecture/detector-contract.html +6 -10
  96. package/public/docs/contributor-guide/architecture/drill-down/plan-graph.html +4 -8
  97. package/public/docs/contributor-guide/architecture/drill-down.html +4 -8
  98. package/public/docs/contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.html +6 -10
  99. package/public/docs/contributor-guide/architecture/impact-estimation/stage-union-rollup.html +4 -8
  100. package/public/docs/contributor-guide/architecture/impact-estimation.html +6 -10
  101. package/public/docs/contributor-guide/architecture/index.html +4 -8
  102. package/public/docs/contributor-guide/architecture/overview.html +4 -8
  103. package/public/docs/contributor-guide/architecture/state-and-history/history-server-intake.html +4 -8
  104. package/public/docs/contributor-guide/architecture/state-and-history/run-comparison-internals.html +6 -10
  105. package/public/docs/contributor-guide/architecture/state-and-history.html +4 -8
  106. package/public/docs/contributor-guide/architecture/widget-rendering/findings-tab.html +4 -8
  107. package/public/docs/contributor-guide/architecture/widget-rendering/investigation-routing.html +4 -8
  108. package/public/docs/contributor-guide/architecture/widget-rendering/list-sort-mode.html +4 -8
  109. package/public/docs/contributor-guide/architecture/widget-rendering/reference-section.html +4 -8
  110. package/public/docs/contributor-guide/architecture/widget-rendering/row-expand-contract.html +4 -8
  111. package/public/docs/contributor-guide/architecture/widget-rendering.html +4 -8
  112. package/public/docs/contributor-guide/architecture/worker-protocol/cli-and-mcp.html +6 -10
  113. package/public/docs/contributor-guide/architecture/worker-protocol/core-and-event-validation.html +4 -8
  114. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-availability.html +4 -8
  115. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-report.html +6 -10
  116. package/public/docs/contributor-guide/architecture/worker-protocol.html +4 -8
  117. package/public/docs/contributor-guide/contributing.html +4 -8
  118. package/public/docs/contributor-guide/development-setup.html +6 -10
  119. package/public/docs/contributor-guide/testing.html +4 -8
  120. package/public/docs/favicon.svg +2 -2
  121. package/public/docs/hashmap.json +1 -1
  122. package/public/docs/index.html +4 -8
  123. package/public/docs/tuning-reference/anti-patterns.html +4 -8
  124. package/public/docs/tuning-reference/aqe.html +4 -8
  125. package/public/docs/tuning-reference/bottleneck-broadcast-sizing.html +4 -8
  126. package/public/docs/tuning-reference/bottleneck-cold-start.html +4 -8
  127. package/public/docs/tuning-reference/bottleneck-duplicate-plan-subtree.html +4 -8
  128. package/public/docs/tuning-reference/bottleneck-failures.html +4 -8
  129. package/public/docs/tuning-reference/bottleneck-gc.html +4 -8
  130. package/public/docs/tuning-reference/bottleneck-job-failure-rate.html +4 -8
  131. package/public/docs/tuning-reference/bottleneck-memory-utilization.html +4 -8
  132. package/public/docs/tuning-reference/bottleneck-retry-waste.html +4 -8
  133. package/public/docs/tuning-reference/bottleneck-shuffle.html +4 -8
  134. package/public/docs/tuning-reference/bottleneck-skew.html +4 -8
  135. package/public/docs/tuning-reference/bottleneck-slow-host.html +4 -8
  136. package/public/docs/tuning-reference/bottleneck-small-files.html +4 -8
  137. package/public/docs/tuning-reference/bottleneck-spill.html +4 -8
  138. package/public/docs/tuning-reference/bottleneck-straggler.html +4 -8
  139. package/public/docs/tuning-reference/bottleneck-tiny-tasks.html +4 -8
  140. package/public/docs/tuning-reference/bottleneck-utilization.html +4 -8
  141. package/public/docs/tuning-reference/caching.html +4 -8
  142. package/public/docs/tuning-reference/cluster-config.html +4 -8
  143. package/public/docs/tuning-reference/config.html +4 -8
  144. package/public/docs/tuning-reference/data-formats.html +4 -8
  145. package/public/docs/tuning-reference/index.html +5 -9
  146. package/public/docs/tuning-reference/intro.html +4 -8
  147. package/public/docs/tuning-reference/joins.html +4 -8
  148. package/public/docs/tuning-reference/memory-model.html +4 -8
  149. package/public/docs/tuning-reference/metrics.html +4 -8
  150. package/public/docs/tuning-reference/partitioning.html +4 -8
  151. package/public/docs/tuning-reference/pyspark.html +4 -8
  152. package/public/docs/tuning-reference/shuffle.html +4 -8
  153. package/public/docs/tuning-reference/spark-architecture.html +4 -8
  154. package/public/docs/tuning-reference/table-formats.html +4 -8
  155. package/public/docs/user-guide/alternative-log-retrieval/managed-platforms.html +4 -8
  156. package/public/docs/user-guide/alternative-log-retrieval/ssh-bastion.html +4 -8
  157. package/public/docs/user-guide/alternative-log-retrieval.html +4 -8
  158. package/public/docs/user-guide/getting-started/ci-and-automation.html +11 -13
  159. package/public/docs/user-guide/getting-started/reading-the-dashboard.html +4 -8
  160. package/public/docs/user-guide/getting-started/regression-metric-keys.html +4 -8
  161. package/public/docs/user-guide/getting-started/tuning-thresholds.html +4 -8
  162. package/public/docs/user-guide/getting-started.html +4 -8
  163. package/public/docs/user-guide/mcp-tools/compare-runs.html +8 -11
  164. package/public/docs/user-guide/mcp-tools/diagnose-run.html +4 -8
  165. package/public/docs/user-guide/mcp-tools/evaluate-budgets.html +6 -10
  166. package/public/docs/user-guide/mcp-tools/get-finding-documentation.html +4 -8
  167. package/public/docs/user-guide/mcp-tools/get-finding-evidence.html +4 -8
  168. package/public/docs/user-guide/mcp-tools/get-reference-doc.html +4 -8
  169. package/public/docs/user-guide/mcp-tools/get-run-summary.html +6 -10
  170. package/public/docs/user-guide/mcp-tools/list-runs.html +4 -8
  171. package/public/docs/user-guide/mcp-tools.html +4 -8
  172. package/public/docs/user-guide/run-comparison/how-stages-are-matched.html +6 -10
  173. package/public/docs/user-guide/run-comparison.html +6 -10
  174. package/public/docs/user-guide/understanding-findings.html +6 -10
  175. package/public/export-template.html +46 -46
  176. package/public/favicon.svg +2 -2
  177. package/public/index.html +4 -7
  178. package/vendor-core/allocation.js +105 -20
  179. package/vendor-core/api-merge-writes.js +1 -0
  180. package/vendor-core/cli/budgets.js +62 -2
  181. package/vendor-core/cli/regression-budgets.js +35 -0
  182. package/vendor-core/comparison-output.js +2 -1
  183. package/vendor-core/core-source-hash.txt +1 -1
  184. package/vendor-core/delta-log-paths.js +10 -1
  185. package/vendor-core/detectors.js +290 -92
  186. package/vendor-core/docs-content/detection/chrn.md +3 -1
  187. package/vendor-core/docs-content/detection/cold.md +2 -1
  188. package/vendor-core/docs-content/detection/mem.md +1 -1
  189. package/vendor-core/docs-content/detection/plan.md +9 -2
  190. package/vendor-core/docs-content/detection/shfl.md +8 -2
  191. package/vendor-core/docs-content/detection/skew.md +12 -5
  192. package/vendor-core/docs-content/detection/slow.md +6 -2
  193. package/vendor-core/docs-content/detection/spill.md +4 -1
  194. package/vendor-core/docs-content/detection/strag.md +5 -3
  195. package/vendor-core/docs-content/detection/tiny.md +3 -2
  196. package/vendor-core/docs-content/detection/util.md +6 -2
  197. package/vendor-core/efficiency-model.js +22 -14
  198. package/vendor-core/evidence-report.js +11 -11
  199. package/vendor-core/finding-presentation.js +52 -19
  200. package/vendor-core/finding-types.js +71 -16
  201. package/vendor-core/impact-model.js +2 -2
  202. package/vendor-core/mcp-server-factory.js +6 -2
  203. package/vendor-core/mcp-tools.js +10 -1
  204. package/vendor-core/median.js +8 -0
  205. package/vendor-core/remediation.js +10 -5
  206. package/vendor-core/run-comparison.js +2 -0
  207. package/vendor-core/run-output.js +14 -3
  208. package/vendor-core/sequence-alignment.js +83 -0
  209. package/vendor-core/stage-alignment.js +387 -63
  210. package/vendor-core/stage-quantiles.js +3 -1
  211. package/vendor-core/stage-structure.js +83 -0
  212. package/vendor-core/types.js +12 -2
  213. package/vendor-core/wall-clock.js +8 -0
  214. package/vendor-core/wasted-core-hours.js +12 -11
  215. package/vendor-core/write-targets.js +32 -0
  216. package/public/assets/AutoscalingChurn-5hDGIXi4.js +0 -1
  217. package/public/assets/CacheUtilization-CT2SCnJJ.js +0 -1
  218. package/public/assets/CachingOpportunity-D_O_WkbH.js +0 -1
  219. package/public/assets/ConfigAudit-BoplryCH.js +0 -1
  220. package/public/assets/CoreUsageArea-csgwNxkl.js +0 -1
  221. package/public/assets/Dashboard-BgKodJl2.js +0 -2
  222. package/public/assets/DisclosureContext-B0siMVdg.js +0 -1
  223. package/public/assets/ExecutorUtilization-Dp52QGWo.js +0 -1
  224. package/public/assets/MemoryUtilization-D8IUDHJI.js +0 -1
  225. package/public/assets/PartitionSizing-BwdFL-mw.js +0 -1
  226. package/public/assets/PlanGraphRoute-pqnHTt4i.js +0 -1
  227. package/public/assets/RetryWaste-BTbeHkJc.js +0 -1
  228. package/public/assets/Skew-DSNXRG9V.js +0 -1
  229. package/public/assets/SlowHost-B7Lwu2EU.js +0 -1
  230. package/public/assets/Spill-B4DVKHXx.js +0 -1
  231. package/public/assets/StageFindingGroup-D9naK109.js +0 -1
  232. package/public/assets/StageShape-eO5le8iG.js +0 -1
  233. package/public/assets/Straggler-D5Fnok_y.js +0 -1
  234. package/public/assets/TaskFailures-BWnC-K_G.js +0 -1
  235. package/public/assets/index-BcqTLKFp.css +0 -2
  236. package/public/assets/index-DCeWge12.js +0 -13
  237. package/public/assets/react-vendor-DEdfrRTJ.js +0 -9
  238. package/public/assets/useRecentFiles-C3Ov47U7.js +0 -11
  239. package/public/docs/assets/chunks/@localSearchIndexroot.D4OdkQjy.js +0 -1
  240. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.js +0 -1
  241. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.fkPBXsU2.js +0 -1
  242. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.js +0 -1
  243. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.uuAKpY0Y.js +0 -1
  244. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.Dk08VS7n.js +0 -1
  245. package/public/docs/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
  246. package/public/docs/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
  247. package/public/docs/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
  248. package/public/docs/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
  249. package/public/docs/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
  250. package/public/docs/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
  251. package/public/docs/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
  252. package/public/docs/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
  253. package/public/docs/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
  254. package/public/docs/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
  255. package/public/docs/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
  256. package/public/docs/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
  257. package/public/docs/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
  258. package/public/docs/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
  259. package/public/docs/assets/style.tmDhD3c8.css +0 -1
  260. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.DpnfKz3x.js +0 -22
  261. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.DpnfKz3x.lean.js +0 -1
  262. package/public/docs/assets/user-guide_run-comparison.md.B6aNtDYF.js +0 -1
  263. package/public/docs/assets/user-guide_run-comparison.md.B6aNtDYF.lean.js +0 -1
  264. package/public/docs/assets/user-guide_run-comparison_how-stages-are-matched.md.XBzQ1oEU.js +0 -1
  265. package/public/docs/assets/user-guide_run-comparison_how-stages-are-matched.md.XBzQ1oEU.lean.js +0 -1
  266. package/public/docs/assets/user-guide_understanding-findings.md.Dqt2H_C1.js +0 -1
  267. package/public/docs/assets/user-guide_understanding-findings.md.Dqt2H_C1.lean.js +0 -1
  268. /package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.lean.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.lean.js} +0 -0
  269. /package/public/docs/assets/{contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.lean.js → contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.lean.js} +0 -0
  270. /package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.wj6Et6UD.lean.js → contributor-guide_architecture_detector-contract.md.N4hhenrA.lean.js} +0 -0
  271. /package/public/docs/assets/{contributor-guide_architecture_detector-contract_threshold-tables.md.fkPBXsU2.lean.js → contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.lean.js} +0 -0
  272. /package/public/docs/assets/{contributor-guide_architecture_worker-protocol_cli-and-mcp.md.Dk08VS7n.lean.js → contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.lean.js} +0 -0
  273. /package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.lean.js → contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.lean.js} +0 -0
  274. /package/public/docs/assets/{contributor-guide_development-setup.md.D9bu4j_c.lean.js → contributor-guide_development-setup.md.DQdzera7.lean.js} +0 -0
  275. /package/public/docs/assets/{user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.lean.js → user-guide_mcp-tools_get-run-summary.md.EhaIsele.lean.js} +0 -0
@@ -1,6 +1,8 @@
1
- import { pathBasename, formatBytes, IMPACT_BAND_ORDER } from './format-utils.js';
1
+ import { pathBasename, formatBytes, IMPACT_BAND_ORDER, MS_PER_CORE_HOUR } from './format-utils.js';
2
+ import { medianOfSorted } from './median.js';
2
3
  import { shareLabel } from './finding-presentation.js';
3
4
  import { scanRelationId } from './plan-summary.js';
5
+ import { allocatedCoreMs, computeAllocation } from './allocation.js';
4
6
  import { computePeakConcurrentCores, computePeakConcurrentExecutorCount } from './core-count.js';
5
7
  import { walkPlanTree } from './plan-tree-walk.js';
6
8
  import { computeCoreLocalityRatio } from './core-locality-ratio.js';
@@ -18,10 +20,10 @@ import { totalExecutorCpuMs } from './run-totals.js';
18
20
  import { DUPLICATE_SUBTREE_DIFFERING_NOTE, duplicateSubtreeDetail, SLOW_HOST_DIMENSION_LABEL } from './finding-generic-recommendation.js';
19
21
  import { stageIdsForSqlExec } from './sql-stages.js';
20
22
  import { cyrb53 } from './string-hash.js';
21
- import { decreaseConf, increaseConf, setConf } from './remediation.js';
23
+ import { codeFix, decreaseConf, increaseConf, setConf } from './remediation.js';
22
24
  import { MAX_FAILURE_GROUPS, describeTaskFailure, } from './task-failure.js';
23
25
 
24
-
26
+
25
27
 
26
28
  const MB = 1024 * 1024;
27
29
  const GB = 1024 * MB;
@@ -543,7 +545,7 @@ function maxMedianRatio(
543
545
  ) {
544
546
  if (samples.length < 3) return null;
545
547
  const vals = samples.map(s => s.value).sort((a, b) => a - b);
546
- const median = vals[Math.floor(vals.length / 2)];
548
+ const median = medianOfSorted(vals);
547
549
  if (median <= 0) return null;
548
550
  const top = samples.reduce((a, b) => (b.value > a.value ? b : a));
549
551
  return { key: top.key, ratio: top.value / median, value: top.value };
@@ -655,14 +657,36 @@ function tailClaimFloorMs(claim , stageId , ctx )
655
657
  return tailClaimImpact(claim, stageId, ctx.impact).wallClock?.high ?? claim.wasteMs;
656
658
  }
657
659
 
658
- // A setting is only a fix when the run's logged conf doesn't already have it: a run that set it
659
- // needs another remedy. Only explicitly logged properties count; Spark's unlogged version
660
- // defaults are not modeled. Booleans compare case-insensitively, as Spark parses them.
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;
675
+ }
676
+
677
+ // The run's effective value of a property: the logged one, else versionDefault's.
678
+ function effectiveConf(app , key ) {
679
+ return (app?.config?.[key] ?? versionDefault(app, key))?.trim();
680
+ }
681
+
682
+ // A setting is only a fix when the run's effective conf doesn't already have it: a run that set it,
683
+ // or whose Spark version defaults to it, needs another remedy. Booleans compare case-insensitively,
684
+ // as Spark parses them.
661
685
  function loggedAs(app , key , suggested ) {
662
- const logged = app?.config?.[key]?.trim();
686
+ const effective = effectiveConf(app, key);
663
687
  return typeof suggested === 'boolean'
664
- ? logged?.toLowerCase() === String(suggested)
665
- : logged === suggested;
688
+ ? effective?.toLowerCase() === String(suggested)
689
+ : effective === suggested;
666
690
  }
667
691
 
668
692
  function setConfUnlessLogged(app , key , suggested ) {
@@ -675,18 +699,69 @@ function switchFix(on , key , suggested , recomm
675
699
  return on ? { text: alreadyOn, remediation: [] } : { text: recommend, remediation: [setConf(key, suggested)] };
676
700
  }
677
701
 
702
+ const SKEW_KEY_REMEDY = 'salt the key or repartition on a better key';
703
+
704
+ // What a stage reads, by the dominant side: a stage that reads a shuffle and mostly input files is
705
+ // an input stage for any fix that sizes a shuffle's reduce side.
706
+ function stageReads(stage ) {
707
+ if (stage.shuffleReadBytes > 0 && stage.shuffleReadBytes >= stage.inputBytes) return 'shuffle';
708
+ return stage.inputBytes > 0 ? 'input' : 'other';
709
+ }
710
+
711
+ // Whether the run's Spark predates 3.0, which has no AQE skew-join handling to suggest.
712
+ function predatesAqeSkewJoin(app ) {
713
+ const major = /^(\d+)\./.exec(app?.sparkVersion ?? '');
714
+ return major != null && Number(major[1]) < 3;
715
+ }
716
+
717
+ // What a skewed stage reads, which decides whether AQE skew-join handling can act on it. It splits
718
+ // skewed partitions on the shuffle-read side of a sort-merge or shuffled-hash join, so only a stage
719
+ // 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);
722
+ // 'other' is any stage the plan cannot tie to a join (an aggregation's shuffle, no plan, no input),
723
+ // and any stage of a run on Spark before 3.0 (skewFix).
724
+ function skewOrigin(stage , ctx , shuffleEvidence ) {
725
+ if (shuffleEvidence || (stage.shuffleReadBytes > 0 && stage.shuffleReadBytes >= stage.inputBytes)) {
726
+ let hasJoin = false;
727
+ 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; });
729
+ return hasJoin ? 'shuffleJoin' : 'other';
730
+ }
731
+ return stage.inputBytes > 0 ? 'inputScan' : 'other';
732
+ }
733
+ const SKEW_JOIN_NODES = new Set(['SortMergeJoin', 'ShuffledHashJoin']);
734
+
735
+ // The skew finding's fix for the stage's origin. Join-driven skew gets AQE skew-join handling,
736
+ // unless the run's effective conf already has it; uneven input gets the file-size remedy.
737
+ function skewFix(stage , ctx , shuffleEvidence = false) {
738
+ const origin = skewOrigin(stage, ctx, shuffleEvidence);
739
+ if (origin === 'inputScan') {
740
+ return {
741
+ origin,
742
+ text: 'the stage reads uneven input files: compact small files or split large ones (lower spark.sql.files.maxPartitionBytes)',
743
+ remediation: [decreaseConf('spark.sql.files.maxPartitionBytes')],
744
+ };
745
+ }
746
+ if (origin === 'other') return { origin, text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
747
+ // Before Spark 3.0 there is no AQE skew-join handling, so the stage gets the generic advice.
748
+ if (predatesAqeSkewJoin(ctx.app)) return { origin: 'other', text: SKEW_KEY_REMEDY, remediation: [codeFix(SKEW_KEY_REMEDY)] };
749
+ return { origin, ...skewJoinFix(ctx.app) };
750
+ }
751
+
678
752
  function skewJoinFix(app ) {
679
753
  const key = 'spark.sql.adaptive.skewJoin.enabled';
680
- const remedy = 'salt the key or repartition on a better key';
681
754
  if (loggedAs(app, 'spark.sql.adaptive.enabled', false)) {
682
755
  return {
683
- text: `AQE is off, so enable it (spark.sql.adaptive.enabled) for skew-join handling to apply; otherwise ${remedy}`,
756
+ text: `AQE is off, so enable it (spark.sql.adaptive.enabled) for skew-join handling to apply; otherwise ${SKEW_KEY_REMEDY}`,
684
757
  remediation: [setConf('spark.sql.adaptive.enabled', true), ...setConfUnlessLogged(app, key, true)],
685
758
  };
686
759
  }
687
- return switchFix(loggedAs(app, key, true), key, true,
688
- `for join-driven skew, enable AQE skew-join handling (${key}); otherwise ${remedy}`,
689
- `AQE skew-join handling is already on, so ${remedy}`);
760
+ const fix = switchFix(loggedAs(app, key, true), key, true,
761
+ `for join-driven skew, enable AQE skew-join handling (${key}); otherwise ${SKEW_KEY_REMEDY}`,
762
+ `AQE skew-join handling is already on, so ${SKEW_KEY_REMEDY}`);
763
+ // With the switch already on, only the key is left to fix: a change to the job, not a property.
764
+ return fix.remediation.length === 0 ? { ...fix, remediation: [codeFix(SKEW_KEY_REMEDY)] } : fix;
690
765
  }
691
766
 
692
767
  // The resources flag is read from the same property as the logged conf.
@@ -695,31 +770,92 @@ function dynamicAllocationFix(app , recommend , alreadyOn
695
770
  return switchFix(app.resources?.dynamicAllocationEnabled === true || loggedAs(app, key, true), key, true, recommend, alreadyOn);
696
771
  }
697
772
 
698
- // The run's logged spark.sql.shuffle.partitions as a count, or null when unlogged or not a count.
699
- function loggedShufflePartitions(app ) {
700
- const logged = app?.config?.['spark.sql.shuffle.partitions']?.trim();
701
- return logged != null && /^\d+$/.test(logged) ? Number(logged) : null;
773
+ // Idle capacity is cured by a smaller cluster, which the run's dynamic allocation decides how to
774
+ // size. With it on: the cap on how many executors it scales to, and the floor it holds when that
775
+ // is logged above 0 (both lowered together). With it off or unset: the alternatives of a fixed
776
+ // executor count or switching it on, which dynamicAllocationFix already names, so the
777
+ // recommendation reads "either ... or" and a consumer applies one entry. Not executorIdleTimeout:
778
+ // autoscalingChurn recommends raising it, so lowering it here would contradict that finding.
779
+ function idleCapacityFix(app , recommend , alreadyOnLead ) {
780
+ const fix = dynamicAllocationFix(app, recommend, alreadyOnLead);
781
+ // dynamicAllocationFix's remediation is empty exactly when dynamic allocation is already on.
782
+ 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;
784
+ return holdsFloor
785
+ ? { text: `${alreadyOnLead} by lowering spark.dynamicAllocation.maxExecutors and spark.dynamicAllocation.minExecutors`,
786
+ remediation: [decreaseConf('spark.dynamicAllocation.maxExecutors'), decreaseConf('spark.dynamicAllocation.minExecutors')] }
787
+ : { text: `${alreadyOnLead} by lowering spark.dynamicAllocation.maxExecutors`, remediation: [decreaseConf('spark.dynamicAllocation.maxExecutors')] };
788
+ }
789
+
790
+ // The run's effective spark.sql.shuffle.partitions (logged, else Spark's 200) as a count, or null
791
+ // when the logged value is not a count.
792
+ function effectiveShufflePartitions(app ) {
793
+ const value = effectiveConf(app, 'spark.sql.shuffle.partitions');
794
+ return value != null && /^\d+$/.test(value) ? Number(value) : null;
795
+ }
796
+
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);
806
+ const needed = Math.ceil(stage.shuffleReadBytes / IDEAL_BYTES_PER_PARTITION_TASK);
807
+ const tasks = Math.max(1, stage.taskCount);
808
+ let partitions = 'raise';
809
+ 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 };
702
814
  }
703
815
 
704
- // lowShuffleParallelism's fix. The partition count that brings each shuffle partition down to the
705
- // ideal size, as the estimate models it, is per stage; the property is job-wide. A logged value at
706
- // or above it means the property is not what limits that stage (a repartition(n) or an RDD
707
- // shuffle is), so the text points at the stage's own partitioning and no property is suggested.
708
- // Unlogged, the count is not a safe value: null.
709
- function lowShuffleParallelismFix(app , needed ) {
710
- const logged = loggedShufflePartitions(app);
711
- if (logged != null && logged >= needed) {
816
+ const ADVISORY_PARTITION_SIZE_KEY = 'spark.sql.adaptive.advisoryPartitionSizeInBytes';
817
+
818
+ // lowShuffleParallelism's fix. Unlike the shuffle finding it fires on stages whose tasks are larger
819
+ // 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);
822
+ if (partitions === 'aqeCoalesced') {
823
+ return {
824
+ partitions,
825
+ text: `AQE coalesced the shuffle into ${stage.taskCount} tasks (from ${count} configured partitions): lower ${ADVISORY_PARTITION_SIZE_KEY} so each partition is smaller`,
826
+ remediation: [decreaseConf(ADVISORY_PARTITION_SIZE_KEY)],
827
+ };
828
+ }
829
+ if (partitions === 'ownPartitioning') {
712
830
  return {
713
- text: `spark.sql.shuffle.partitions is already ${logged}, so raise this stage's own partition count (its repartition(n) or RDD parallelism) so each partition is smaller`,
831
+ 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`,
714
833
  remediation: [],
715
834
  };
716
835
  }
717
836
  return {
837
+ partitions,
718
838
  text: 'raise spark.sql.shuffle.partitions so each partition is smaller',
719
- remediation: [increaseConf('spark.sql.shuffle.partitions', logged == null ? null : needed)],
839
+ remediation: [increaseConf('spark.sql.shuffle.partitions', count == null ? null : needed)],
720
840
  };
721
841
  }
722
842
 
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);
849
+ }
850
+
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');
855
+ if (raw != null && /^-\d+$/.test(raw)) return -1;
856
+ return parseSparkBytes(raw);
857
+ }
858
+
723
859
  // No dynamic-allocation property has an effect on a run whose logged conf turns it off.
724
860
  function dynamicAllocationOff(app ) {
725
861
  return app?.resources?.dynamicAllocationEnabled === false || app?.config?.['spark.dynamicAllocation.enabled']?.trim().toLowerCase() === 'false';
@@ -1061,9 +1197,9 @@ export const DETECTORS = [
1061
1197
  const floorWasteMs = tailClaimFloorMs(skewTailClaim(stage, metric === 'P95/median'), stage.id, ctx);
1062
1198
  if (!meetsRuntimeFloor(floorWasteMs, appDurationMs(ctx.app), thresholds.floorPctWarn)) return null;
1063
1199
  const value = Math.round(ratio * 10) / 10;
1064
- const fix = skewJoinFix(ctx.app);
1200
+ const fix = skewFix(stage, ctx);
1065
1201
  return {
1066
- type: 'skew', stageId: stage.id,
1202
+ type: 'skew', stageId: stage.id, origin: fix.origin,
1067
1203
  impactBand: 'warning',
1068
1204
  metric, value,
1069
1205
  confidence: skewConfidence(ratio, thresholds.ratioWarn),
@@ -1188,12 +1324,21 @@ export const DETECTORS = [
1188
1324
  const bytes = stage.shuffleReadBytes;
1189
1325
  if (bytes <= thresholds.minBytes) return null;
1190
1326
  if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
1327
+ const { partitions, count } = shufflePartitionCase(stage, ctx.app);
1328
+ const perTask = formatBytes(bytes / Math.max(1, stage.taskCount));
1329
+ const fix = partitions === 'sufficient'
1330
+ ? { 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
+ : partitions === 'aqeCoalesced'
1332
+ ? { 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
+ : 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')] };
1191
1336
  return {
1192
1337
  type: 'shuffle', stageId: stage.id,
1193
- impactBand: 'info',
1338
+ impactBand: 'info', partitions,
1194
1339
  metric: 'shuffleReadBytes', value: bytes,
1195
- recommendation: `${formatBytes(bytes)} shuffled in this stage: consider increasing spark.sql.shuffle.partitions or adding a broadcast join.`,
1196
- remediation: [increaseConf('spark.sql.shuffle.partitions')],
1340
+ recommendation: `${formatBytes(bytes)} shuffled in this stage: ${fix.text}.`,
1341
+ remediation: fix.remediation,
1197
1342
  };
1198
1343
  },
1199
1344
  estimate(finding, ctx) {
@@ -1225,19 +1370,19 @@ export const DETECTORS = [
1225
1370
  const ratioText = p50 > 0
1226
1371
  ? `${Math.round(max / p50 * 10) / 10}× the median (${formatBytes(p50)})`
1227
1372
  : 'far larger than the median, which is effectively empty';
1228
- const fix = skewJoinFix(ctx.app);
1373
+ const fix = skewFix(stage, ctx, true);
1229
1374
  out.push({
1230
1375
  type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
1231
- rule: 'shufflePartitionSkew', metric: 'shuffleReadMax', value: max,
1376
+ rule: 'shufflePartitionSkew', origin: fix.origin, metric: 'shuffleReadMax', value: max,
1232
1377
  recommendation: `The largest shuffle partition (${formatBytes(max)}) is ${ratioText}: ${fix.text}.`,
1233
1378
  remediation: fix.remediation,
1234
1379
  });
1235
1380
  }
1236
1381
  if (total >= thresholds.lowParTotalBytes && taskCount <= thresholds.lowParMaxTasks) {
1237
- const fix = lowShuffleParallelismFix(ctx.app, Math.ceil(total / IDEAL_BYTES_PER_PARTITION_TASK));
1382
+ const fix = lowShuffleParallelismFix(stage, ctx.app);
1238
1383
  out.push({
1239
1384
  type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
1240
- rule: 'lowShuffleParallelism', metric: 'taskCount', value: taskCount,
1385
+ rule: 'lowShuffleParallelism', partitions: fix.partitions, metric: 'taskCount', value: taskCount,
1241
1386
  recommendation: `${Math.round(total / GB * 10) / 10} GB of shuffle spread over only ${taskCount} tasks: ${fix.text}.`,
1242
1387
  remediation: fix.remediation,
1243
1388
  });
@@ -1260,6 +1405,7 @@ export const DETECTORS = [
1260
1405
  const stage = ctx.stages.get(finding.stageId);
1261
1406
  if (!stage) return null;
1262
1407
  let wasteMs = 0;
1408
+ let longestTaskAfterFixMs = 0;
1263
1409
  if (finding.rule === 'maxPartitionTooBig') {
1264
1410
  wasteMs = ((stage.shuffleReadMax ?? 0) / SHUFFLE_THROUGHPUT_BPS) * 1000;
1265
1411
  } else if (finding.rule === 'shufflePartitionSkew') {
@@ -1275,11 +1421,21 @@ export const DETECTORS = [
1275
1421
  // work, not the scheduling cost of tasks you'd add (adding tasks incurs overhead, recovers
1276
1422
  // nothing). Model the achievable duration at target parallelism by scaling down proportionally.
1277
1423
  wasteMs = stageDurationMs * (1 - taskCount / targetTaskCount);
1424
+ // The fix splits the long tasks, so the longest task is the quantity it shortens: the
1425
+ // occupancy clip must not floor the claim at it (TAIL_CLAIM). An even split leaves the
1426
+ // longest task at taskCount / targetTaskCount of today's: a modeled figure.
1427
+ const longestTaskMs = stage.taskDurationMax ?? 0;
1428
+ longestTaskAfterFixMs = longestTaskMs * taskCount / targetTaskCount;
1429
+ // Splitting only shortens tasks: the stage still takes the time outside its longest task
1430
+ // plus the longest task the split leaves, so the claim is at most the longest task's own
1431
+ // reduction. Without a task-duration max (older snapshots) the stage-wide scaling stands.
1432
+ if (longestTaskMs > 0) wasteMs = Math.min(wasteMs, longestTaskMs - longestTaskAfterFixMs);
1278
1433
  }
1279
1434
  } else {
1280
1435
  return null;
1281
1436
  }
1282
- return singleStageImpact(wasteMs, finding.stageId, ctx, 'modeled', { value: wasteMs, unit: 'ms' });
1437
+ return singleStageImpact(wasteMs, finding.stageId, ctx, 'modeled', { value: wasteMs, unit: 'ms' },
1438
+ finding.rule === 'lowShuffleParallelism' ? { ...TAIL_CLAIM, longestTaskAfterFixMs } : undefined);
1283
1439
  },
1284
1440
  }),
1285
1441
  defineStageDetector({
@@ -1296,8 +1452,11 @@ export const DETECTORS = [
1296
1452
  const classified = cls === 'skew' || cls === 'volume';
1297
1453
  const mag = computeSpillMagnitude(stage, thresholds);
1298
1454
  const impactBand = 'warning';
1455
+ // shuffle.partitions sizes a shuffle's reduce side: a stage that reads no shuffle gets memory only.
1456
+ const reads = stageReads(stage);
1457
+ const partitionAdvice = reads === 'shuffle';
1299
1458
  return {
1300
- type: 'spill', stageId: stage.id, impactBand,
1459
+ type: 'spill', stageId: stage.id, impactBand, reads,
1301
1460
  spillMagnitude: mag?.magnitude,
1302
1461
  metric: 'memoryBytesSpilled', value: stage.memoryBytesSpilled,
1303
1462
  confidence: classified ? 'medium' : 'low',
@@ -1306,8 +1465,10 @@ export const DETECTORS = [
1306
1465
  : 'Spill cause could not be classified: inspect per-task spill metrics in the Spark UI before acting.',
1307
1466
  recommendation: cls === 'skew'
1308
1467
  ? `${formatBytes(stage.memoryBytesSpilled)} spilled, skew-driven: fix task skew first; adding memory will not help.`
1309
- : `${formatBytes(stage.memoryBytesSpilled)} spilled: raise spark.sql.shuffle.partitions or increase executor memory.`,
1310
- remediation: cls === 'skew' ? [] : [increaseConf('spark.sql.shuffle.partitions'), increaseConf('spark.executor.memory')],
1468
+ : partitionAdvice
1469
+ ? `${formatBytes(stage.memoryBytesSpilled)} spilled: raise spark.sql.shuffle.partitions or increase executor memory.`
1470
+ : `${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')],
1311
1472
  };
1312
1473
  },
1313
1474
  estimate(finding, ctx) {
@@ -1419,7 +1580,7 @@ export const DETECTORS = [
1419
1580
  if (hosts.length >= thresholds.minHosts) {
1420
1581
  const means = hosts.map(h => ({ host: h.host, taskCount: h.taskCount, mean: h.totalDuration / h.taskCount }));
1421
1582
  const sorted = [...means].map(h => h.mean).sort((a, b) => a - b);
1422
- const overallMedian = sorted[Math.floor(sorted.length / 2)];
1583
+ const overallMedian = medianOfSorted(sorted);
1423
1584
  if (overallMedian > 0) {
1424
1585
  for (const h of means) {
1425
1586
  const ratio = h.mean / overallMedian;
@@ -1532,11 +1693,21 @@ export const DETECTORS = [
1532
1693
  const impactBand = durationMinutes >= t.infoMin ? 'info' : null;
1533
1694
  if (!impactBand) return null;
1534
1695
  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.
1698
+ 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.`
1701
+ : reads === 'input'
1702
+ ? `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
+ : `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.`;
1535
1704
  return {
1536
1705
  type: 'stageSlowness', stageId: stage.id, impactBand,
1537
- metric: 'stageDurationMinutes', value,
1538
- recommendation: `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.`,
1539
- remediation: [increaseConf('spark.sql.shuffle.partitions'), increaseConf('spark.default.parallelism')],
1706
+ metric: 'stageDurationMinutes', value, reads,
1707
+ recommendation,
1708
+ remediation: reads === 'shuffle'
1709
+ ? [increaseConf('spark.sql.shuffle.partitions'), increaseConf('spark.default.parallelism')]
1710
+ : reads === 'input' ? [decreaseConf('spark.sql.files.maxPartitionBytes')] : [],
1540
1711
  };
1541
1712
  },
1542
1713
  estimate(finding, ctx) {
@@ -1664,8 +1835,10 @@ export const DETECTORS = [
1664
1835
  const detail = useSpeculativeMetric
1665
1836
  ? `${value} speculative attempt${value === 1 ? '' : 's'} discarded`
1666
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);
1667
1840
  return {
1668
- type: 'straggler', stageId: stage.id, impactBand,
1841
+ type: 'straggler', stageId: stage.id, impactBand, origin: fix.origin,
1669
1842
  metric: useSpeculativeMetric ? 'speculativeTasks' : 'stragglerShare',
1670
1843
  value,
1671
1844
  unit: useSpeculativeMetric ? 'count' : 'pct',
@@ -1675,7 +1848,8 @@ export const DETECTORS = [
1675
1848
  ? stragglerConfidence(speculativeShare, thresholds.warnPct, thresholds.critPct)
1676
1849
  : stragglerConfidence(stragglerShare, thresholds.shareWarn, thresholds.critPct),
1677
1850
  validationRequired: `Warning needs at least ${shareLabel(thresholds.floorPctWarn)} of run time at stake, critical ${shareLabel(thresholds.floorPctCrit)}.`,
1678
- recommendation: `${detail}: rule out a GC pause or a slow shuffle fetch before assuming a hardware issue; if a skewed key is the real cause, that's a candidate for AQE's skew-join handling.`,
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,
1679
1853
  };
1680
1854
  },
1681
1855
  estimate(finding, ctx) {
@@ -1755,14 +1929,15 @@ export const DETECTORS = [
1755
1929
  if (stageBelowRuntimeFloor(stage, ctx, thresholds.stageFloorPct)) return null;
1756
1930
  if (stage.taskDurationP50 > thresholds.maxP50 || stage.taskDurationP95 > thresholds.maxP95) return null;
1757
1931
  const coalesceTo = Math.max(1, Math.round(stage.taskCount / 10));
1758
- const fix = stage.shuffleReadBytes > 0
1932
+ const reads = stageReads(stage);
1933
+ const fix = reads === 'shuffle'
1759
1934
  ? `lower spark.sql.shuffle.partitions or .coalesce(${coalesceTo})`
1760
1935
  : `.coalesce(${coalesceTo})`;
1761
1936
  return {
1762
- type: 'tinyTask', stageId: stage.id, impactBand: 'info',
1937
+ type: 'tinyTask', stageId: stage.id, impactBand: 'info', reads,
1763
1938
  metric: 'taskDurationP50', value: Math.round(stage.taskDurationP50),
1764
1939
  recommendation: `Many small tasks (${stage.taskCount}, P50 ${Math.round(stage.taskDurationP50)}ms): scheduler overhead may dominate. Try ${fix}.`,
1765
- remediation: stage.shuffleReadBytes > 0 ? [decreaseConf('spark.sql.shuffle.partitions')] : [],
1940
+ remediation: reads === 'shuffle' ? [decreaseConf('spark.sql.shuffle.partitions')] : [],
1766
1941
  };
1767
1942
  },
1768
1943
  estimate(finding, ctx) {
@@ -1839,6 +2014,7 @@ export const DETECTORS = [
1839
2014
  const value = Math.round(gapSeconds);
1840
2015
  return {
1841
2016
  type: 'coldStart', stageId: null, impactBand: 'warning',
2017
+ dynamicAllocation: dynamicAllocationOff(ctx.app) ? 'off' : 'on',
1842
2018
  metric: 'startupGapSeconds', value,
1843
2019
  recommendation: `The first stage waited ${value}s for an executor to become available: keep a warm pool of idle executors, or if using dynamic allocation, raise the minimum/initial executor count so it doesn't scale up from zero.`,
1844
2020
  remediation: dynamicAllocationOff(ctx.app) ? [] : [increaseConf('spark.dynamicAllocation.minExecutors'), increaseConf('spark.dynamicAllocation.initialExecutors')],
@@ -1864,12 +2040,15 @@ export const DETECTORS = [
1864
2040
  if (!app || executorsAdded.length === 0 || app.startTime == null || app.endTime == null) return null;
1865
2041
  const appDuration = app.endTime - app.startTime;
1866
2042
  if (appDuration <= 0) return null;
1867
- // computePeakConcurrentCores (not executorsAdded.length/computeTotalCores): real concurrent
1868
- // capacity, not a cumulative sum that double-counts a churned-through executor against its
1869
- // replacement's (spot preemption, dynamicAllocation replacement).
2043
+ // Capacity is what the run was allocated (cores x time alive, the figure behind
2044
+ // metrics.allocation.coreHours), not peak concurrent cores x the whole run: under dynamic
2045
+ // allocation or late-joining executors that is more than was ever held, and the idle figure
2046
+ // would exceed the allocation. Null without executor cores.
2047
+ const capacityCoreMs = allocatedCoreMs({ app, stages: ctx.stages, executors: { added: executorsAdded, removed: executorsRemoved } });
2048
+ if (capacityCoreMs == null) return null;
2049
+ // Peak concurrent cores (not computeTotalCores, a cumulative sum that double-counts a
2050
+ // churned-through executor against its replacement): the cluster size the finding reports.
1870
2051
  const totalCores = computePeakConcurrentCores(app, executorsAdded, executorsRemoved);
1871
- if (totalCores <= 0) return null;
1872
- const capacityCoreMs = totalCores * appDuration;
1873
2052
  // Busy core-time (from the whole-run core-time-series, same signal memoryUtilization's
1874
2053
  // idleCores variant already uses), not executor lifetime: an executor that exists for the
1875
2054
  // whole run but sits fully idle must not score as 100% used. Missing runAggregates (older
@@ -1880,15 +2059,12 @@ export const DETECTORS = [
1880
2059
  if (utilization >= thresholds.minUtil) return null;
1881
2060
 
1882
2061
  // CPU-time-based utilization (sparkMeasure): metric only, no threshold.
1883
- let cpuUtilizationPct = null;
1884
- if (totalCores > 0) {
1885
- // Null when no stage recorded CPU time (older Spark), the same rule as the CLI metrics block.
1886
- const cpuMs = totalExecutorCpuMs(ctx.stages.values());
1887
- cpuUtilizationPct = cpuMs == null ? null : Math.round((cpuMs / (appDuration * totalCores)) * 100);
1888
- }
2062
+ // Null when no stage recorded CPU time (older Spark), the same rule as the CLI metrics block.
2063
+ const cpuMs = totalExecutorCpuMs(ctx.stages.values());
2064
+ const cpuUtilizationPct = cpuMs == null ? null : Math.round((cpuMs / capacityCoreMs) * 100);
1889
2065
 
1890
2066
  const value = Math.round(utilization * 100);
1891
- const fix = dynamicAllocationFix(app, 'consider reducing cluster size or enabling dynamic allocation',
2067
+ const fix = idleCapacityFix(app, 'consider either reducing cluster size (spark.executor.instances) or enabling dynamic allocation',
1892
2068
  'dynamic allocation is already on, so consider reducing cluster size');
1893
2069
  return {
1894
2070
  type: 'utilization', stageId: null, impactBand: 'info',
@@ -1896,6 +2072,7 @@ export const DETECTORS = [
1896
2072
  utilizationFraction: utilization,
1897
2073
  appDurationMs: appDuration,
1898
2074
  totalCores,
2075
+ allocatedCoreMs: capacityCoreMs,
1899
2076
  cpuUtilizationPct,
1900
2077
  recommendation: `Average executor utilization was only ${value}%: ${fix.text}.`,
1901
2078
  remediation: fix.remediation,
@@ -1903,13 +2080,17 @@ export const DETECTORS = [
1903
2080
  },
1904
2081
  estimate(finding) {
1905
2082
  const fraction = finding.utilizationFraction ;
1906
- const appDurationMs = finding.appDurationMs ;
1907
- const totalCores = finding.totalCores ;
1908
- if (fraction == null || appDurationMs == null || totalCores == null) {
2083
+ const allocatedMs = finding.allocatedCoreMs ;
2084
+ if (fraction == null || allocatedMs == null) {
1909
2085
  return costOnly('measured');
1910
2086
  }
1911
- const idleCoreHours = (1 - fraction) * appDurationMs * totalCores / 3.6e6;
1912
- return costOnly('measured', { value: idleCoreHours, unit: 'coreHours', idle: true });
2087
+ // The same allocated-minus-busy figure as `rawWaste`, in core-milliseconds, next to (never in)
2088
+ // coreTimeMs: that field is busy task time a fix removes, this is capacity no task used.
2089
+ const idleMs = (1 - fraction) * allocatedMs;
2090
+ return {
2091
+ ...costOnly('measured', { value: idleMs / MS_PER_CORE_HOUR, unit: 'coreHours', idle: true }),
2092
+ idleCoreTimeMs: { low: idleMs, high: idleMs },
2093
+ };
1913
2094
  },
1914
2095
  }),
1915
2096
  defineAppDetector({
@@ -1934,25 +2115,26 @@ export const DETECTORS = [
1934
2115
  // cumulative sum or count double-counts a churned-through executor against its replacement's
1935
2116
  // (spot preemption, dynamicAllocation replacement), inflating idle-rate and waste-model figures.
1936
2117
  const peakExecutors = computePeakConcurrentExecutorCount(executorsAdded, executorsRemoved);
1937
- const totalCores = computePeakConcurrentCores(app, executorsAdded, executorsRemoved);
1938
- // Hoisted above 1a (also 1b/1c's input) so the idle-cores finding carries the allocated
1939
- // memory its MB-seconds estimate needs.
1940
2118
  const allocatedMB = app.resources?.executor?.memoryMB ?? null;
1941
2119
 
1942
2120
  // ── 1a idle-cores rate ────────────────────────────────────────────────
1943
2121
  const busyCoreMs = runAggregates?.busyCoreMs;
1944
- if (busyCoreMs != null && totalCores > 0) {
1945
- const capacityCoreMs = totalCores * appDurationMs;
1946
- const idleRate = capacityCoreMs > 0 ? 1 - (busyCoreMs / capacityCoreMs) : 0;
2122
+ // Allocated core-time, as for `utilization`, so the two findings of one idle condition agree.
2123
+ const allocationInput = { app, stages, executors: { added: executorsAdded, removed: executorsRemoved } };
2124
+ const capacityCoreMs = allocatedCoreMs(allocationInput);
2125
+ if (busyCoreMs != null && capacityCoreMs != null) {
2126
+ const idleRate = 1 - (busyCoreMs / capacityCoreMs);
1947
2127
  if (idleRate > thresholds.idleCoreWarn) {
1948
2128
  const value = Math.round(idleRate * 100);
1949
- const fix = dynamicAllocationFix(app, 'reduce cluster size or enable dynamic allocation',
2129
+ const { memoryGbHours } = computeAllocation(allocationInput);
2130
+ const fix = idleCapacityFix(app, 'either reduce cluster size (spark.executor.instances) or enable dynamic allocation',
1950
2131
  'dynamic allocation is already on, so reduce cluster size');
1951
2132
  out.push({
1952
2133
  type: 'memoryUtilization', variant: 'idleCores', stageId: null,
1953
2134
  impactBand: 'warning', metric: 'idleCoreRate', value,
1954
- // Raw (unrounded) rate plus sizing inputs for the impact estimator: `value` is rounded pct.
1955
- idleRateFraction: idleRate, allocatedMB, peakExecutors, appDurationMs,
2135
+ // Raw (unrounded) rate plus the allocated memory-time for the impact estimator: `value` is rounded pct.
2136
+ idleRateFraction: idleRate,
2137
+ allocatedMBSeconds: memoryGbHours != null ? memoryGbHours * 1024 * 3600 : null,
1956
2138
  recommendation: `${value}% of available core-time ran no task: ${fix.text}.`,
1957
2139
  remediation: fix.remediation,
1958
2140
  });
@@ -2038,13 +2220,11 @@ export const DETECTORS = [
2038
2220
  return costOnly('measured', { value: finding.value, unit: 'mbSeconds' });
2039
2221
  }
2040
2222
  if (finding.variant === 'idleCores') {
2041
- // Idle core-time priced as memory held but unused: the same MB-seconds unit as wasteModel, so comparable.
2223
+ // Idle core-time priced as memory held but unused: the allocated memory-seconds times the idle rate.
2042
2224
  const idleRateFraction = finding.idleRateFraction ;
2043
- const allocatedMB = finding.allocatedMB ;
2044
- const peakExecutors = finding.peakExecutors ;
2045
- const appDurationMs = finding.appDurationMs ;
2046
- if (idleRateFraction != null && allocatedMB != null && peakExecutors != null && appDurationMs != null) {
2047
- const wastedMBSeconds = idleRateFraction * allocatedMB * peakExecutors * (appDurationMs / 1000);
2225
+ const allocatedMBSeconds = finding.allocatedMBSeconds ;
2226
+ if (idleRateFraction != null && allocatedMBSeconds != null) {
2227
+ const wastedMBSeconds = idleRateFraction * allocatedMBSeconds;
2048
2228
  return costOnly('modeled', { value: wastedMBSeconds, unit: 'mbSeconds' });
2049
2229
  }
2050
2230
  return costOnly('modeled');
@@ -2196,14 +2376,19 @@ export const DETECTORS = [
2196
2376
  if (!impactBand) return null;
2197
2377
 
2198
2378
  const pct = Math.round(shortLivedPct * 100);
2379
+ const daOff = dynamicAllocationOff(app);
2199
2380
  return {
2200
2381
  type: 'autoscalingChurn', stageId: null, impactBand,
2201
2382
  metric: 'shortLivedExecutorPct', value: pct,
2202
2383
  // Raw count behind the percentage, for the impact estimator's startup-overhead figure.
2203
2384
  shortLivedExecutorCount: shortLivedCount,
2204
2385
  confidence: autoscalingChurnConfidence(shortLivedPct, thresholds.warningPct, thresholds.criticalPct),
2205
- recommendation: `${pct}% of executors ran for under 2 minutes before being removed: this looks like wasteful re-provisioning rather than normal scale-down; consider raising spark.dynamicAllocation.executorIdleTimeout or widening the minExecutors/maxExecutors bounds to reduce flapping.`,
2206
- remediation: [
2386
+ // No dynamic-allocation property has an effect when the run's conf turns it off (as for coldStart).
2387
+ dynamicAllocation: daOff ? 'off' : 'on',
2388
+ recommendation: daOff
2389
+ ? `${pct}% of executors ran for under 2 minutes before being removed: dynamic allocation is off, so the churn comes from executors lost or preempted, not from autoscaling bounds; check the cluster manager's preemption and executor-loss logs.`
2390
+ : `${pct}% of executors ran for under 2 minutes before being removed: this looks like wasteful re-provisioning rather than normal scale-down; consider raising spark.dynamicAllocation.executorIdleTimeout or widening the minExecutors/maxExecutors bounds to reduce flapping.`,
2391
+ remediation: daOff ? [] : [
2207
2392
  increaseConf('spark.dynamicAllocation.executorIdleTimeout'),
2208
2393
  decreaseConf('spark.dynamicAllocation.minExecutors'),
2209
2394
  increaseConf('spark.dynamicAllocation.maxExecutors'),
@@ -2705,14 +2890,21 @@ export const DETECTORS = [
2705
2890
  || (smaller < broadcastTiers[3] && larger > comparisonTiers[2]);
2706
2891
  if (fires) {
2707
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.';
2708
2898
  out.push({
2709
2899
  type: 'underBroadcast', executionId: sqlExec.id, stageIds: unionStageIds(contributors, fallbackStageIds),
2710
2900
  // resolvePlanTree always sets id; safe downstream of it.
2711
2901
  planNodeIds: contributors.map((n) => n.id ).filter(Boolean),
2712
2902
  impactBand: 'info', metric: 'smallerSideBytes', value: smaller,
2713
- largerSideBytes: larger,
2714
- recommendation: `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. Consider a broadcast() hint or raising spark.sql.autoBroadcastJoinThreshold.`,
2715
- remediation: [increaseConf('spark.sql.autoBroadcastJoinThreshold')],
2903
+ largerSideBytes: larger, broadcastThreshold,
2904
+ 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')],
2716
2908
  });
2717
2909
  }
2718
2910
  }
@@ -2724,17 +2916,23 @@ export const DETECTORS = [
2724
2916
  // (node.stageIds always empty in real data); its child carries the executor-side
2725
2917
  // metrics, so only the child unions in.
2726
2918
  const child = (node.children ?? [])[0];
2727
- const autoBroadcastOff = ctx.app?.config?.['spark.sql.autoBroadcastJoinThreshold']?.trim() === '-1';
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);
2923
+ const broadcastThreshold = autoBroadcastOff ? 'disabled' : hintForced ? 'notLimiting' : 'limits';
2728
2924
  out.push({
2729
2925
  type: 'overBroadcast', executionId: sqlExec.id,
2730
2926
  stageIds: unionStageIds(child ? [child] : [], fallbackStageIds),
2731
2927
  // resolvePlanTree always sets id; safe downstream of it.
2732
2928
  planNodeIds: [node.id ].filter(Boolean),
2733
- impactBand: 'warning', metric: 'broadcastBytes', value: m.value,
2929
+ impactBand: 'warning', metric: 'broadcastBytes', value: m.value, broadcastThreshold,
2734
2930
  recommendation: autoBroadcastOff
2735
2931
  ? `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: automatic broadcast is already disabled, so remove the broadcast() hint that forced it.`
2736
- : `This broadcast (${formatBytes(m.value)}) exceeds the ${binaryThresholdLabel(overBroadcastBytes)} threshold: check for a misapplied broadcast hint or a misconfigured spark.sql.autoBroadcastJoinThreshold.`,
2737
- remediation: autoBroadcastOff ? [] : [decreaseConf('spark.sql.autoBroadcastJoinThreshold')],
2932
+ : 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')],
2738
2936
  });
2739
2937
  }
2740
2938
  }