From 04ccefc7a6a1b082a7acedf7c0f07e7d732bbf27 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:43:11 -0400 Subject: [PATCH 1/7] Add "Powered by Performance Studio" line on landing page Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/Pages/Index.razor | 1 + src/PlanViewer.Web/wwwroot/css/app.css | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 6aae734..3e9be24 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -6,6 +6,7 @@

Free SQL Server Query Plan Analysis

Paste or upload a .sqlplan file. Your plan XML never leaves your browser.

+

Powered by the same analysis engine in the Performance Studio app.

@if (errorMessage != null) { diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 95b0cb4..88fc17a 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -168,9 +168,25 @@ main { font-family: 'Montserrat', sans-serif; font-size: 1.05rem; font-weight: 600; + margin-bottom: 0.4rem; +} + +.powered-by { + color: var(--text-secondary); + font-size: 0.9rem; margin-bottom: 1.5rem; } +.powered-by a { + color: var(--accent); + text-decoration: none; + font-weight: 600; +} + +.powered-by a:hover { + text-decoration: underline; +} + /* === Input Area === */ .input-area { text-align: left; From 6c6c1f0e31035f6b3d755e8c9480c485a3fa22f6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:58:12 -0400 Subject: [PATCH 2/7] Add Darling Data favicon to web app Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/favicon.png | Bin 0 -> 39737 bytes src/PlanViewer.Web/wwwroot/index.html | 1 + 2 files changed, 1 insertion(+) create mode 100644 src/PlanViewer.Web/wwwroot/favicon.png diff --git a/src/PlanViewer.Web/wwwroot/favicon.png b/src/PlanViewer.Web/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..9c6db66abf67f052aaa496cf675178c8712ea1a6 GIT binary patch literal 39737 zcmXtfbyQT}7w;WfnlD|FQW7H|AT83J64H%ygT&AwB_$v&B_Jh;bcfR2Fobl2cnb2;ngD10pQ&5$^Tb77-yHz( zdmjHFsn7AL!GB_UD5%I_{>8v1Bm3{I*L?*5&;tt6ueE)$4wilWmu);*A4Yw1j@@(} z>o>S+HiQ_0(XifOF|43yu}RP~=ZEEUDcc0`wzHq_*S!(p3cf?h*RtLHcaf>NQy2aa zA+`Q#?g_&aLM#RPn&efX=ROZeZs^hQu^ZCpg6ewaF;vSGT! zM`@alKpEaBa|iK5FQ29DMN$t+0YOF6Y_M*At0PTw_UquVpLeK^GxsiRbg5dFrLp z{@c(D;=#D&dYRv>ZZ}=2W%nt*{bG)yI%*cHg~5y-l%7<6JpY2Hf-g;fOR)PS z6KLy_UUS>LrCk_Am0o2Fi9@M=Vy0*T?Hw=dCi}{rvJV*&`gc$u)N-S1O5Leom4vSp z#g~L4nPu6_^nH0Pw92gS-P}TXhtKoc050~D?u$u)&w-*t7UG~D!bP5puODSe_!?oC zlJHb^G=k2;2kvFdgT8sIAji_9uK=O1UMITD&JRuF8=tPeG{B|tDK|4d+FBry@BJgr z*^Ty$Lg7*h0OUo~<3odWb9y^%0wcabSqcKjwF`mtI485FBge=}C`%KDvc2tUhmQJ( zjN}o>Ojno{JuYE9E(>ibA)W%lPCA^3=6ZS*T7Brt)*pPSr4ae7r%oOMRr=nreS%W) z)js_OPDrO`hu1nXT;Kmhf-SfkSUUkZK=L&|QFF`~yW8(qflYUU##14hd%peVrfeuH4& zrQ!OLLjtHc^?El2OlP#c)ptidKD@zeSb4VKjEeqjE-%WZL1LFM+}6OA4i^4Up_YF& zo6v#wY<`E;AOf7Y6Y=wV#NA42#0`u2gUY7RU}|PKJGog1qjKXXk0>dAvBMIgSlsnh zEs5T-$_n*(g2O+u663;!fGwkapvT|gs4h5FHD;x*@P)zb`9`4$M+ZrN624m0XGz63 zzTy#Htzn^R_h_9b8nz~~_g^pMA!DmEOUt!AO?w!;Th?LYA|Azf^)v}*Dhf$$gwuts zB+BYiqr?PXkwqmC&7?b-!6cMCNWgVvg;TrWRqN25$me0ilJ>j!ZeHa^diz_csSY1H zWX+1$j_+ANg-DCZ?RCs&R&+^J-`S?_G(Fz-<^DV8pZo0*b+XzNiG@7UcA~@OwQbvio&S#Ux(I~kz2Kj*qp+Dii5!K0dlr(cSinC=esu~z z=cwC$<2%uBGK8-QF#k2$zx1Zu3`{YVC^bZ?6lVL>NiY`#FudnF+Bh~hMVvnh7^^?R zYKq62mL1qTSnx#fxTZX$)Z8_z3O9E|Jd^I#2(qfAwe}+YkMG?Tn-Gyp+fm4Mqdp(h)VyF+SmW&3YWc~ z_s0<YF=Pbb+fufA^oiG;eDl7lYCZBsXw5cG9cuIswFT9v;r3JFOg%oKmL z@kqC_9|qNhKBVQmKITiH=ODG5;^|@51hoH@`T4<%I*=-j56%6=?G6XTjFby%Of=M8 zoh48ER^z$U<8cm^=wJ*i$tbHd6-_qqMTN#(-FO6Kf@(aSIglYqO7V>!s5$K+q13RD z3Dexz+i)Q~cEH+pc;UXv%mkJrNjF=H+cnM*_O>*H>tw`ZYlCn(%J&IakLAOId~F%ddJ4L5_m}) z0a96sB*0|#hk)af*LUcw$r2JY)MZ&4tnz2%jFbbe5KW=8u4JLQU5#|;CyjPOEoSr% zlJ6~k@b{ZSu(d+>W9TMINGDV37DLjt4PVYbY+d75H;$=k{_Df|{(G5E`{45Z#x@=Q z<*~FaQwRiZPqiaiB&dU@=D)y|TYjCnvIhQ(MrNs=-(H)jUO8-^5f|R`hpZLs%wKEa zdbgu(-i(2I7sU?p47c*;At&5;q1DLl3ZA?{v%kcu6~nc5-7~a0uusgxYGn!l5VVim z{#fd0ghVMQB;$n=K`bG~3eLKxi&H&|EbXx-tUWvsNg~Fg-;mm8>d{zjj1#z$!Ki>W z$8Ivj+}etgC7!O@aK55T8CTCD14}w$-as|Z%zL)Rsyk#XTrSk`b&noUj;R%!W;yKFaMPDG1P{Yu)m6IZ>-!rEIo@I^RLvi^fc;+edj# zGv;|s)1$ePtvXPk0^H)9sC~Q~bU59~#=nBLg(;rk=G;#6xL)t(DuOyZ5=;WHHf)U; zIiK83!G$vJnw3j~T&mTI5!AnXz9mw6P3(8n6qb{AYPq6Z)kzI#oX|>@oqf3TMt9as z^5%f?LlkwA1Ua7E>S>AxmLXFn@A_F4d;Xk|C;`b51=8Pk0}TyZlyA)?Riq`v-Cvac z$oNP6CU2ueL$$`TBU${i!5g4K3MwGDy)J1wi?@w?B}c_S&73|37ttq*YRltfiNov)Jsq42QOshB`L|3D2%iiJFU} zORu6WNBXTX*E2#+XYf+D>u}vUzo50D#Fg^8yR5%c^*Pjb7P@GE)m=-|?@lW{b) zcDi%EXe!@*Axd#GPqLmR4*^U!v;E*@0mm@m`Mc@0Il`_)h{D}VfgBGA{f69ei zZD``^3O-%%yH!e!O#c3Gx;Sy<6QVn|cikgyEOb)HOsB%krLZ*|hV}dU>4yaTjfc2* zr+aO06-xG+kS12KG}P_S=3_sjVG{x8Cgc9_Rm^$Tvy=FjquZ- zHezuCCQQe6e*@yamnV8{u-~3uTwJ@xn@s*1f?|B?S@}Ryu_lbI*4~6E5qSG7mTd5M zaZ6$Me_xA+7Enw#X_wPVwfEjR)jR4435VHwsQ(8eor~58$ofu7BpUh2od#Uj%cMYT zGjDkIcOwAHms5ogFWwafkTZqz9tEt+I4sl?R2F=?w5l+PdY_q! z-cja>5HGDmcph3UguJG_7$xa%Ze&gRsd*~foJG!h6Q2?~qWEL9fhs3{_N2dLuRkVy zOfiW(=q)z;Xy>3G!I~WPuho+dtO<6tdN{TQQ*9aGvkh+_TjR_^NGvNqcq`j+I;}mA zh7I&3nCn{;68Wpxo^MFQQ{$0!2z*{Af9?1vmbe|t#5g3DD9K%k}Xz5RmNWfixzmdlZ1w zZ(K1+-e~Gyxc*4oTk!9?s2qz@t>F2lsqsv2OKeQMHn_>&-0ziYX1^DFl?C~vYta?W zCzC2X1teiV#?_*P-|wQVer&&0upT*^MTRCjBC@|hai2a{Jj=Dbi^{4w#gf?P@F+4G z8f{8X^Bm7``{YHE@-=!M$mk=V{&Xsa8%uW5q4n;xs}l9Z2$Nk+;P#1z8rM7E`z@Tu zNYybLxo5plnZk~=z@<}KWtTGx9OwnDd|E1f|+HOGuXvdDm@w4_&guWS8Cr_^0Gv@sA8P>7`$}828C! z9(RnNrUY6ByNK@MXjrk2%CKXdcC(svM5Hz4M={^vB<6j74!pfLJ*L~&-m2;(8~=SE zx;RRsHcf5qV_>G**ZVaqQpT6SeFA@?5`!V<;n!!4c;>a31r3s`)8nJV{(I_r)8jP= zC<=ZYm_WEKbA=YLKWWppxhWz+8;>7S;PUnv^0d%7V@(7b=bW4D*NtAv`B;Y6 zs{Kn>5*{e45gMzsicdTSaJSxg=<~Nxmq=C?WY##n!Poz%bzEP(Bah` zbz#LwyOvA8DsFdP$9+#0WS{AY{KVQ+xD|aJ8FpABC8m;yrryL+0pDgvM%F(oE)(XC zaqP;1(}Vf|pPdZy7ou-dip8b)^NP8Fw@Z!pD#EGA{Ed=P0!)NK;FL2*>*-{CSGbWK z=drSx*-B{DbdPcn%{Z}wB7(b55po2tMt}4K@o(h^WhEG>G$+Y`=QBro1zL@s3#Y;c z_qQvj8$OP0eWd3T4TaCcE9+2YTkK7WuVf}t7z@S77YS(kKxTg}fyqDeX7$StK+n_W z>VhLxUrF(1e9mQ%1$jyYYd&86(P(Elkt(|~8^u}RU|GmR6?!ZTomnqw52Q?Jtw!xL z071Gmv27EIX>YcOMDaw%F-oDb>-T5L?N;IRRO|nu)A33;f_37#* zio)I3&IW@w_Th0n-W#l=EwMFj^aX(oUz8_Ffak{x7wm$Ir|k}U5qJY!X?JhB0SLKw zk?Up%=a#i0_Sd*@+(8+3LYcwY1*uX4?kDRxq1WSm6+6^4GQykro`F>1=|)s-fS#-F z6>F9YsDq?Btb$v*m;km}BMwPndtD3_-i=7CB)@0Tcpxe^QJjkK2y{F;F8W{-(=!wWAA(s%6gbbqyO73 za6F2)E%IUQh3LuDTZiRkRJiA-Gpe)Ma6%yK0vW_8b!V5SuD zjD`V$_*D`-I3cmOG8S%iA=^SefwO_4*HbYR6kPO775?G23V1re_v<4ioVv^aN#BPo zTaRzM$r7k+a%AT}xGP)p@NnFo4)lds&GAsF54dY^V*0F>lAlfuE{`cHoRNPGf2j;P zx%}y;wV-qLDx=_2e3bUY$2nMypWqMsyRE2ks98ZCW;jnpbelijv=+ODl_8Js* z59b&ZT~hJ%YRP|Fr?VrmSnVO$>>7V&;jVDD3?S!?m>k^0M zM+XnyPKYdtCy@y(v;(hdXiVl23%-+4RH49|;{KmO1k36I&VtKL3n$}qt%qqTY4wHRjaM!$ zLgSeLTRbTfeuE%xO=L$i@N=^3fKdsdH_1@m($@Hl>h3ow3CsN}{vSmq4TADF;zD0Y zUnUTmC-qeL0fblkW6kK&D}4n!2_xL*tU>8e zYlM6AI+BOI8E)Kw`M1IpniEo$781|vly62qd5QV3dMp>* zb0M9g_Y{Ox8NzIzfd#7Pvq!fdXI~vJ4>#>P`?n(T&TAXqyFNver$gG&GYT1F2ePaL z=~8hKe8w5)gOtjpc3FIgN8Yh+myQ>1kVr+neS3wnx*+29VB%D{G1{HDwe*j?>WcU+ zw_W%CmK7ldj{Z?DcF2Cef&_MO9>oEB?AU1jL1-oU{od8*P5fuH`GitKES_Az|I()T z*GZEDCx}{}r`1`wW`4g?3->ZCbi6q`?+ zK4Iu_(3wT)@Z2@YatJi$t3;49#o#RXe>Yf&XxaY#0hvMAsaCRD6x8M7TPV~=z+Mkm z+iZ=LXj2zBe8M2j^z9v_`fS7~_k3adP8K>ftNjPgOGptjJ3PGl1?Ox``o{{4O3|+H z?CBiJ>(Z9#u)XHD^CE#T-}fdHHBT7Agp7@_gLJ$-+ckW5tE4U7T=+DVMLURg!iLcu zeY$;5Zyv@53X!f+t2#56F-$}u=GQ=I+jRXO*vVF=0>?8@4(cy?fR%q0HZ*J($mcwp zvIU_ke76L#GJVr}jdiq2ivjw_62TyHctt*g^7_TnOM#)QcVCYKts8%_@6Lr0N@W=h z;{^F{XDzl|`Z_xlGr>0Q?{s+_<>U-z5UlQEbfsBRW(|hiq#=>=rOv-@M!E%gG>Qr> zh^4Szc)kRUVWsiBVqx^|my2cIXEiOIB1QMy{m-5qvtd7&VL(u(B2y{^yL&Bv;@dsG zo&R<+`ORWdyb=~e&(PF&K=1T7)0f+9n08Lp*YW&fiu*Lim>6L#y#O;2{=|RhHXWw{ zh3DA4psO;YfU6tcmLRKJDtLCs9*>>*g%t3x+|B)9bYAK3xfH2s>8hYhRsOji)x!aG z^?Of{8;#|n5s6&a!^F3ncig0Hr=`z@zu0idfqPR<7Xl5J@!4s%5`Iej3pC9SN{PKi z?h}t38w$!8{zE5OEEZQylFv`&Ke@2;Jhx5AkA62D+*Vsx1skZCg70bz(BcaQcISfI z&M)D<<+O`QmKYuRJ38bNK)0JE>&kgsRC8C-4vTJQ*lhqOPRUN=JPI0qP(OSV2Qtep z2|4i05lUln#`kVhn3pZ>>v+xD8M#D&!XAv7Hi1woYN6~sYTdsx z&8rc7NCg=y!dh`N+V!`Jlp$NG;1>)x0D;kVmsZYmZ9ee=RK;((Wuzb+SaE$}ha~7u z>=QrJ{m99EAa6Dueh66GoT>S74=i*tWqSV;zh5dxn(cEP!=V3T%9QRjHV{9ho9(~i z{5SJqAhCD7CGsVC{hQ&gg24Eh(G3Z%as5j5P675F!wZPN5QsU0MKW2gZTQarxq`c<*v zxhB66_o)88Ms_WpX85mTs&4o=sPBQhzq33We(I7g} z2Trg!z?6BGSDPIri#h8J_qvBE2#Yg>5ysg^C;|Uo@$2C%pC&&1R9YBIbvmR)ZphsU z9M2{A<@!9`gi7^Y#awl$?w&#$aZr-RS+= zW`Qt{=6>O5(YZ-t4H^#eG1@L0Wi?9ta8o!yAg%@h=gng?z8Y<6dggpZEwM$eA1d`IIGwrAXv;x4ZoSw`qo(@kk=)PRT3`c0WHkSARU_`iBnC7RI3_ahi8W#8oj9 z7lHGwUv7#4(wABe)J)@4gTI@Ejj!bFD-j8-$m9D1yMu$>H`Z=vHW4HB3{cVQt0Mwnf8HH_bl_tQnr~jeSAmz=B zeJc~}BUWXBE3&U*uEhe*o+Kd|kEumP{j#S$S29Wpi^1TxJ`SKXBA2?&+BWIrzX$WR zq1q(Nx>dX3eni}&H|v#JstDAeGd@$|hd()sPIFq@AmEd#OXnj4oJ|mx*ibsb_zF!x`4_cL>E5T-WtEa=z&-oXn5AIM8VFLUR=fj(I zOyh5UpB|~5u)_(u60IIa-4IlH)wl~A3@RsG`2pT9Ct1l~yfVnAk!?Vn4pXqFtcu*G z(8xHe+7Opa_M;)_H z)FJ~M4xNLNDSa{f%=P84W6lGWD(-Fk$*GO^`fx-Dvg#+{LT&-l7NLlc{0Z-Uk{r;8 z^|uPaS_XJi+0oX`T>UVL>tnDMcV4?nrhkPAz&)$4eglD*2hUGh`4aAeeQt9p;Gcdz z8sks5GhE5Ow%3T=b4Q9PvGgC&i2KpOCNB2_HFhaW&Zlz?q($^$Z*AHFgX?0xK}8Gm zA4*jjKr>7aR~?X5e04mvOS9LCW=i?H0XuDUuui)fUbnq|$|X`=TS8D;dvJ>hqz(Wx zD-7EYpi)dt{OQ){`>EjYfD_wmzg0M%>Sa(2$ot|q5nOJZlGihdh5sectlmW7&vKp? z7Y5H)JMW6#Vdd@UnQ>EbA&zvW{QZHdDm&9IyA;M4me~7dv?U74t8OE7CV1+6$L~NW zTC-7qC1DczsDeZ0{iJ|98!X!GA39CACmth|tZG-WfN8S>yoCm>&B4Esx*7jc&2Enl zkhhZ@|7B0}g-{C1)gO{a>FoN$u`EXk_#FJb+;w-!V=(qXOs?Q+2l~{{tUtx;9`$rf zLW|-lr@LK1TsH{D8B~{0O{-9{ebYYtMDH{A)y2-{;=N@B6xr_%=5?V4oSm-*E0L}P zYM~i)2aShY!3(mWmPlo(1B6qVd#~=DDdk^br;RB58tIdOcYi)Yotsz=t%GevNF>+% zG~E%gr+7Tt3p219cpMtwYwcHxx9@%LZ2LcisJh@7jUxFxTrxzQz?+k- z(u#Xj*KG;Hrz@AbbW{~)J|Jy9XfJnD2R%7(Dz#Aa#`HJ`5JFWlO%II*8^2fCa{Ozk zAN09zUp;xJSB!X1obkxQ9~OQ9mageX>Y1`om)$J>VsVX?IYYUPw0GX-KHstA)b97? z7hY@AQ0hcItuXR<+~bsmm-L0nNU?Z7l5sFS)3v%`_>FB+qi6LO(b>+NZn+v?gm~dy zY4#1BTqOZiY&l?2MehBh?qj+BhYmtPVq&hU*(X82dAW}iyJxY7C2r`v@BPg8Ra3gL zJ{AiWyk39=uWV z((ga2z*q=7F`j+uviKRT3i43sB|4z-mwViaBT|?X64qx9rtJbI-E|#DFvc!oo+Hp= zY%vO8)SvNbUs~TFB<^wj1%A{17#cNVCFXzau(8a+M8D z`=9c3=%codFp6Wyd%Ui22I_Dv4db321}gDCXh>gQl>1rNT@nC=B+7Q*!-DE(=tUp- z1$Ng!%`__f0O=WXjS-pVs?3KOiP7<$UAnG81is2m6P!44BSf;j~Qk`8%pi22Uz!qm17D*)e{6>J~+npXaQ4aaIg*s0{q-vy7j7R5{ zTr-H=i#BK@tGh;l(M2!f*}*sC|0&}Xd#?t$9p+DA3<@=*J$Snj^~L`d?oN9kJQulA z(0wwJ56~BNYCyMA2NC5Ik4pH$YJlp?o0lyZLO)`|UlCC17W_Gv09J2|Fil52S>D(I z9zwtVe(lzIq6o@2ouzlx_h(iHE8&Rq`4|5)Iv3iwH>llnuB%x7Hd~pO)@B3)Q<{2+XnTi1sn~qj~TwA<1 z?A6Z&z?P#G>7Mg`Zy6~aX8x*Y&<{U*ajRV+0dp%<2TSE~AdyTS_tZB`097yR%4Uk= zSE5gRUt2e-kdFtJ6Dim^XR7Y3^HlzEBm~_>j;j)4jiG5^7C7Q~JJW8!-qP}mJpYBx zp2ctAy8{41l|%}3SxkouoS-PmY&%Rxl@){uky)W$S9}iBw#Pqb-g+|2(smAlNAV2> zq2$Nes{?w2>A4rH0yop`IpsCT-}JYUvTHjWB!ILjaWElX4k8rHiwXG((qFxcc?J|= z;kY@eh~Unk60J?&M-u8($b-FC9n;-9A0)wH<_jc}^Y1~>fbpYJR(C;0yZJfBd#9EQ z=c~?#GRFs0hyx>aCztj%1dI$(yIS-<(@v-~`ryddGmb|au*!n*Nr|ww_bNyxr`Vpb zPj~}5zW-YmgCy8(+8D(Y3f?HGs|4PuJr6g+L=ehOFWeK*A3+Gi*)XR+$Uh#S>Al;N zAeOF{Sid8&b7E;}wn!q-0zrac8R@Y&CJO*4N_pjJ9AGduwj8{%dGFd(?Np};R+v{$ zR^_LQk*v_#h{YZgT5z%9%02X;8cDf<*L^zB;g*Lg(hs*0>TKxSD+m}bV-VZlx+F0G zH01xZ?kNE%cvG;$VuHkk_~BpZ`oaFzM5=9P{t|wKH2=zs0YWVB?y4br7D-B^xJigW zVAz8QfxaVuydcn%PVZ5`O=Z@C)*aq##z^OtJAw+*Te-e52~iBAL$GTSl>^%$RgerE zMyRDbePErQc+T&Z}qkBwpaA>h? zFjQ)9>8jubY9-6xyR8*q$2Pox$%2CYbN)B7!GE~6aq{6eRH6-!7W)kQb3|DUW=F`H zeRyoamnJ~{ql=FV^AXadAQe{=pG`{6@u?9=k&uYhlhAKn@@bU6eXw)htXi)?u=1Q; z>bwX>a3`n~HEa%Zq#uXz$;{xZgN8FGIWz4-^70fP=%9!xCOBg{NJdO5|aEXj{Zxm-yepTmsHui z^lDwfSCCE-@a={j!vg5Kdj`7+qCtPObgZK8)o*bj?!(BjkE>-{kJcfJ{VZ#e z+=64Y8-sp&H$wI^1l*WeN~Z1A`;f1%(h7obBg(Oyl1)%7xh(4RDtG_E?xb-yi&9_e zz1^?@6_r6%;imBpfV|oEAgTU=wo3T&@F~F9p%+u5slEvIngSShm_U1RpBfk~q_UDe zT#Mmg1GF03E_48@3)WA%SFe$~qlYZ2kCnS=lTPr@&!hEO+nY)mn ztd{RxhJojk4o3m5ifAJ{b~F}Lgma`{%>Igj$U)MltdyLM+Uw%go`c-$LGR#kZXO}5 z4yEDQ^|2%Y;)#yZe<4@lQ2NQ;tEzZ?Ne*WHNtiM{VE{;PmO{&Z4^NB}#H_6W7NcR_{_3gX zY=s;=rJCCO0WsR+T`Z))-20CO^qQtQ#!nf6`X7NS--m$=O2yKKOf%Yfwjj4wrO$1h z0rxoKD~BL2-~pEP;<58d%if)n=3|xTc}J%6bt`6i@dz{{w&E4OD9Y)DhJzXmY6` zA;IRz&T$Udv#|7#dgrMO~r1Lkc5l`L|FRMhy-`^J&0hJ_#WHVql zaTy5!0ed~QIT$(OIwK%i+FPwRqk*0&6qrU{_$B`T?ssl`douzU!6JS%%IY?qzIe-C zrC1fmY_5IDJ3ze!vqNA*{%Z)NK{%(acF5CccY_Tr7*4SI+|z#{;?0n#I12IlP?_L* zx)8$sV}Is~R!?s*SZA9>wZc+=Z*B(wzg;CDTWaZfVe}tAngtbtQzLTyXi=iNAaIm3>|4&CLF`nMP}ZXgPVyJx$$53E$ultuuVNHLoQnri z4`^hdUbu<0!cAZw3?^`3-6bH|I)q)-wQ!Uwm;-kg8TP=Ij+%2t2Z$WQe!`%Kat!o8 za4je+i>-%{rWl5Do$em~&s>b2131}^-f#H)#e%A4kE6p0FZx{*O*!YJfdST*T*;4l zb&E3blQIhdVs?1YZj5IXT2zS5WxuPcCck4Z;9dT|bNt;34f*AS0LuqGl-y6Yn*F1I z?FtE51Ri@ui92T$KtVl<(xCF;8DZ$!d(52jMCrRz-k^K7LV(cxbIcV zfXIUI_EEmaB_p?5fC+Xmp?=J7=x-UmEf^FvzTlIa(Nqof2^w`u9?fwD=pc}NV!xK5 z<$|c#4631&4vZAUI-iNn3rSM;xil+(MbTUtIdBclL2r$h+wG^95ynB9)TE0gaplH7 zccC!|;yYYv)K`_j++D^mnewBB``AlO@_9QF;PgL0T>v*cIj!{z3Li~H;^lJF3Z~1! z&Bi3~p7RuWhzblQ;3d`eY%RYjYiCe)tY3*Du_bKFDsh1JBSs})wKVLUY-;1 z?5GOZYACUi0^Nh${x9IICGQZsR>Y|rBv8JOX70IgYYbsGyKiXky!$HTZX{k~0}Rq! zFZ#3b3I?9OW@MXf?l_&GLm!vxu34KX^Au}mS!H0nyCxUp^sl!o@#$qRC5BhuIFb-; zC>)lQT*;%wzVwBdW{fAQbdPh?9DS=d3{thKY}~1*8^8;ein=ueofeiHCQ&?YP?TnT zZqv-4`T+DimGaq(mTGIGQIkpo@73(*PtCGD8rGMxwE-YV=ilV6IAJi9EWP*$!}hz3 zcrw^;LcE9xSby4p%sjaALa-RnI#{H%DP9XL5eN{a?6(@*EIw$zI;xtI9G#io#}9Pe zY%-?&#$N*#zVI{Ty2m@ud~C|t#RwtcV~ll6b!jI0gTIRc{!X%|qb-hqnP6LOpfhmW zC*V=x1mV;-(|TwTO4nN8TprqCQ`)QfyY0GSJn+DxzpU>&-@$*jU3~;EyC>ZTP*%z5 z+H85=HS7Cjlu8g1eawG7KX>ajUd}ieTetm{FE@G-!b%nuu4+X)Jf6!!{)jVB_>>zd z$WhIJ%@58~8Fxr~hkti_o?;0VjaA!x$8A@?yTI29Hr@juOpe-R{i|{x@A9>QK$4+O zMFbV-k%Me|ZxNdeRQGCp+J_x{eRupW$Ce%}SO`0Sk3Fy=5fDi`iJzJ!XYg2ulZm|x zF?jP)WF)+KJyFiGT{2YI5n`I+J4Vva;#4C{jY4S9CqK{J6!T!HZ_T;$KJ={?qQySk z5iNV*_VZyyGzJ*FwK`_BFL7zmqlB|Ojw}kZBmZ?2@q9O!Mcn9I(I#~&hrYVU?Z~pq zRBo((<5$WqLBY3S`{Mls_&((Y*}3qUyq{B7Er6y)6MT7>z=fDV{ZY>nW=lmAjwOdd zNGg{vdhF@M?JYuCYjtrOYG3tk_I#yqgSZAP^tQLeL@;HviXm7G}?TDXHpA4*b9QQ`!^uFJbp}Y6! z$HMMR6|g4Xa+dg9ie;(fu9Oy&83m=Af>UNJ?vtnu8+d7(z8LK=ptNP(K|JRM5neoP ziXj_D^8628O`CVSwTO1ixKA`@{&9b5;_f`!{d|_rA2v}ljA669r}}yJi<{ZNAb{I9 zZXVS~AwH^XiPyBin+~6k7~}8T?5pR;aBF#&w)ElZN1+9gRMdNO3-HNJ2bvWd&$-jA z(sH|Xz;T4m)j{seaGm*w5d%QQifK(H z$T;&~CNMB+`SKN9S(pqosVDW|s~Aw=d7{Vf1(Vy|L1|~cg}bj>41XAOU=l%?c{393 zilj*N1_0O;95QSXWK%r|fYH9je_YfjK6$QJvY#ov3ko)t`ElQ^H>t7E#(}k8T=yj{ zQxC#{iMR0n+RQ#ffDVjjY_mveeQZ$N7{x*#T`Yme#fsg%zw5(m%`Tc&2v}%!pQmd=PGYFj zj@H?3!&Ns&Zfnr!f5f(d-L|Zd1~;A#EAk;Zl9#HG4m0ra#5V2O!?RsIF!eE3cID3@ zI+@VjPl3U|_z{4Z2ov%48Lci~rd#_voLieO=6;4KW4gGVDs9%plrc&?=?N*hs}~)t zj3Ml?lG_Wq+|3s&i%IxkgTo#>2QCvwtmI9Gx4p#0&KEzwW4-fGu_`6uN^Aa26jT1q zCAxST&|JodCAC_Js!3iRPj>)>U$AhR7F-{e@X_%2aR7R56wM4C?wdQY33@PaHA9)G zXoK*S#&!?n=1&DG^u152-Uc3bWEH-#XVKyY1Wei+u*TYjcWZf2*<>i#sd3Yoc33Kb z^+|wAm-F2ev;@L3$L2(sqIkbQF$!;G-gN<~Zo_hL^e1LmOi8v!RB;sKn6$QSPb$dG za29)R(k9=QxOhOBJ-3E~PKSfY`zLDWHh1@ZytsUvsGpy!a4|$SO8+F9lg7eG2&ywauv~aB zyJ*FP{gi6L&Y7+=7!-e4!}0oVxOi_-6)=L?l^Dzy$O#b=B@_5f)Re(D^OY&BbDzf` zOfGj3Urtax=UE6le)k`*HasI#IOJqeA(DSlZ_swsN8azL;-=pQ)Q|uP!AFNi zEYR>(i$eN!t0b<6Cw}L@6AZi?U{Z*jjVfM#m-8CgEjx}W8OCSjtrFHI6{Tsp$|i;C zF_m2dpPz0U zZCcB&CJDBcp1u8-FsjYP1eS^8Q_eoQdt7@+w=2Yy)0BlAlZ?1ZZHVgR`T(MO?PsVQ z+wOR_Vernka=@LJgAad02VD(Kl!#B~^i+K%(REe@eBAbBt*loD36;PaL#MFyR0h#0PM8&7H@hodN>Dm zZL-jg{7DJ9tc~i!Kw4a-Io-XTSR3joqdRyg%sjsBuN~%oiOa}VaXN4dlNB0^xq|Ir zd~Z7v4hnW_G;EHYz?D!Wev+#C>Vw4Fo-x;gt0%u5SE9*S?giyFR0{6y9}+86^$3>*o|{YB#v zfd^YkUM#e43)1Hg-IS5Eg@F{vshrzi<318WZweq}TVnhdB*3}}zOGRd$vf|>pbYA& z!Tg`quZe`S5nJIVhABXSk<9Vorw7pM-4^|XTbGm)+Jg$j{WtHB`1i2kV?iC56(i8?|((}*+;l8bA20w3e?sH-PiqDba zyJJ+Hxb0SyU|ZkH!PY`mEH+cg{kIP2=Q_l7T#WFSK=AFdW=_np#2Ie=!JgfaJ^=lF z3Q@Nlw3zjV-4tUc$J+{jam5Hq={ll=(OIeh^xLtq@#*EQ}$5_Gv0JQi4dcjR~4!_X}o+U8t=7C+8D9F9cv~+(J8yCNB(su9Y0I$cR zYk2rz>8?+L(_8q4m~^8(eBDHj)k#wH!F$Tb?2{V_Z)L~;&GY}#H;O_#^hl&)#ydkd2OqC#RltG&vSHTRLpBK38pGsSD4k> zh_pLsi51Iw>u=L@wvbWgiiy5-h{`5w-u z2_XU~cC#AsS8F5QD-Cw5r$1FRHjS?nCH!kIC+X^5A9_#fq7y}7g^JYE)6jNZ8&MkA z3&n@hF8=dPJ)ef#pWW6X6Rki(SrDIb`hW9 zO%Jz)OkW(Muo?~VTQLdNGvU`5fgZ@vF@x@_dyxM+{Kgd0^*<8mKVIQL`84CZz{eS2 zyYgeF)QYzPtN6(m?iN3N%#{{sB#wx`Mc-sa0qsGuD7OIz8sjxk!fcon$>#I=XrxY~jO4*AXZE zMmYdIv7@e@BtBaZ$BFQ*pAe#-LVE{-%c6`3e)UV#N9JcFKaqYt-UvnDLv8NbzW0;W z#^)XK|I9!l*016?8Kw??%C>4@uc`AiRE`R)|A|y8|VZ=4PMxlTo zZ&}9iS^5XJNujk8F#^DAinYJJM(I;ol6n?mC@b-=!^QJRwpGs--JR}lw#&r6Mn~CG z{TGckQ}HfRr2Wq7ZtaxSR73!`AmWR1;twr0Zo1eJk}#=YK6OE-FRo3{TeC>WB%?{mNeadxG-+`i!&> zyy>#<)zfLO3!XYJ99uA!R*UT)mpI7ZM~6vk4R!9y4VTxPbs)O(z7lerasUPLz(HUg5_S4iITv8la2`jqOpS!>PIz_%wxT zS>qmA_}&H^hqZT{jIV=2_9@T4_^&dS>R$s-_G@}5YkCSx(%{XT|G=XyDQfCy+ug!B z7=ZfYlF3)y)EPkAC%oi?bqq-S&X;(ZY%PtGt86rVr4$mvaNIh*z1zw~*s1!c3cRFJ zpR_n7q;lVd22)MqhPmymh{xhk$(~3}XspganeEwho~#@8ih;1Y?GF!+UqV85*2E(R zcAfL?lS004vE2=5dT=Wmrl7(i>W6fBBjMfmt4-sb79-A|;q4byWnwvQ916h6wG1B} z`OOfFD%$#>ygG7(5~CHX6|5nChRBvD2JFm*%lBqenP5}olSn%eE~#d*PwxI7nyxx5 zs^{z9rMr=(L0Y7wJC%?I0cluDy1QE>1!)A7MoOe>0ck1eE&(a&j(x9RfA90`AN$9h zduC3~oH^%nB}Z;nB#reC=vCxmc^Ka-o?V`=QJNq`Xj&1{09Jj#s?LJNx&}0diFFG_ z3c^tz-#rf#YU7P?+b%lp6du+S6OOksh)V3qL?vXxqoU}gTck>fHsvcsytxh&ZTC27 zpmFHmUwVH|x)zFX__g`C=%7U^ULDIID&Pq1H5w2jjx?`@neErLZjO5*k`Y7PM9*!M zTm1U%idW#V^^CTk7;%y&R;p#^n1|Tip2>#EpH}RJ28Yim(ymfi47?}|FVTv6xp^(u zzLTp6SvykD{&DQo&?2&_ITueLCc&Yi(lHdlwpKeF@Ig)c+H&Q49$Q)%pO z^k)t_t`gofEY4^in5oERyD<`B+Rrxxe z=W=((IPx*8H>%m7+QyX%Q)^)YJ7if0U78I=wk2Qq?Z&zLE%9NC)5P%HZND) zgs9eU`}$6KtZ(B)oq9as84&L7?0n&4gz>`$2iYq>xkAmKyvMX$AE#M9|JGTW^D+WT zNd)k(alc`xGZuvBzY)6p-czme8P5}K|2bRq9YJHI7thecdGu#`X5cZr%1XCb7%?)960x-#7F+`^?1iHr%W7L}=E(ZC#EYgolN!jVWG=zPG7A zH}{g0ejA4V9V#b*%t|_u{XIdg-RachFNad@G!5HY*BpaNyoTFjkjb-Q^3(DH1pIpP zLH>8#U9lIB{JnR~Fg}%x6wN^k4rQK0_(ZSY6cy*@xb2OOR)GvE^L`xi#1>x@z%_ZkqzRU_w+$_|NNFm&Lm|j~f0X zqk`~${7}0W_zl_U+lvolZ8;71tu6E2nP9{8o6PE`*a_k{tbgtXb!%L@(HM#Fyu&zh zlLp`YGI)uy#DOO zy6tAipx>z@27!u0NTdSuYNmY`2aUItEl1@h5;#UPrMUnbCjT>b0;LExOyuGbrM2v) zH%~v3MD?-}cylSqML!GU&YE}hUs0;7R)zooYt9PQR%Fz227!%pq@WwU zFSeQN2oeUH*)D}d^PSB%M%Ynz$-JxxpOQ35B&6{DqN;z=p6y=+s=;FKP{sa9Dxgoh zT8u9i?Bsu7_xs@Bd3HVadm^moHAm{T6&nQ^Dnee4dkaV*XsyPSf`yEsYZ7`YJ8B{ zBGrMV`Q-BuvPx<_3xVD&&B8=>^s?^O%=WSJ4vGxJk*(?o=N81# z32|*Ndee0&CWcsagHl1|biD+L286nNbKX4WfAg)U2IlNuKbxNV4KzVyV-&XWYBHxN zr7P6$^TngjGUp{`5regoVOayO{FGrD7iwR~(;lXXmF3u>Gq_CeHeJbKtl>mFvfXFQ z`QZ=tm25D{vV1ZPcaJL=QECF!W%OnJ#~eeJVX+tCta zXzhpv^uCzj``sKLM=4->FkhF>=ku&cEf^Rg%xFG6O!WtpD7ZRg`Yz1uV7o$`%5!uD$)0W4%g zggNl|mK$r+Wt7f9T%w{?v(qeO?(<%on@@5mq-d*=b0Y_{QTziEbFMd=$zb!G|Ly8w z`-&Iqs3HX3NtM~jr(*GpT;LsL^ON;`14hhCD+EE;i;YjW>nDXDXd}*M1 zFK0>}(3^Ufj33{`_ZSsGSDW$R?mar0ubcg5hHUjC^Xu6Wo&w0@{b|qThq1J+ z#hdcTvRos(7q^(L4>x;_r$Z*4PPDh;_6w2H_b3YF3a=K)6S?`br{`iPM6!~&>((7Z zZ1F461 zITK-Gj*xRoSYrB$%xl=d>A;OMYC%?*yPIjVgh9iGAP=tENYg<+=+$no$wZxeY!vIF zW=p90q@j_Gm*|r4hKKu=q#=tE(5+=DJPDWiBzmy=*Q41a(36yzS-bTqv$3*`)!VS` zYcH6;DKjY=X?o_e>hz^G#>9)O!)YSGYN#LqRkjU)VHC=gf8N;t$OpfeQ>Zp$1yUh#6NngTvp*AF>r-R&1AwP>hmEWUigYKp?Zy3H@ecJpoTR(I9>O?9DjS)Tuf7XG4|rs zCb8Y#y_aK_&vM;s-YL&W3_}~7eiS6OA^0z}TvQ-NIyez4veHarn-6Ex(0)lALw17M z_}f!dZ4#+Snjd;pHARnDU$J~3wi5$@<$2E8m6ZhRTpu)wnenM5T$Kre=jQ8eD++UQ zf&Qb)b2c9C`~7G~4E#Lr&&IdHH_1tGW2W@E{)o_DKPzLu!Bg zZF`%_x3Ws>pLY$(<~MRwvpcAz<1?|53R&y%`8{c+``()qFy@n^iz6ndq^MUTWX^^K z3`);+?|@OfuUP@c|Oijl^!s6wY^^wy6VU2>s*AwoNh0FuguHcWwEnFtlLW& z{D8+YW4A>RbQ_}td_sjNNqemW!TVdfaDHk-!4cOj&VaJ&Kc%>@XpB5q7A23C$jr?? z9k2~6K8^n>dlb=iYgO#5XMkg@&B78wG$s^PYUgc#Zs+}L&Z8$hlR_dRC5(jdy+iW2 zz-MC3$pH}$Toy=snoNH1R;pDtA_?1K^bQq+K_h5RUsLKW2CQ9Qy$s47dp9DqJ@z6r zdc2TGWg1;m@ohdkHWi`WkRx-FfIQx8vG>-U`IJv9i^y zk*!2ZPGS$o%PR7ayj`7rK>y?4Rt78I>V&Ko&^rDeKbXyJtQ5Sxrply7-u|Hy#;aVS zlM>4#lo~=N_y-;qq=(CeVMb0|d`yE1eCUSCuKJi3aV#Y0<2mPiHnsxGES98ZTd2X3 zXE-0sex+b?!3yA02`kzgaNT!y`c_I>{ zHBB#)x??X1VIXwGn<5Y5qy;s8v%6Q6)C+o>VgFF~=Jjv(EAIe!&nLxWOn&UV6q!zJ z=c{ZyzCP+j#O?O+mMI!fxrFKyVlw27bAE%*DNj5J`EZPnkhxW+r${-Haqbr~0ud*v*yQd@5j8%3CbnLW@{mUq8{&;klh1`_COd&Ea8iRop7b zrokjw8YI#f($bI^VYL`Yp9dqMAfH&1I5MhK7Trs(XGcl9`q2*_qwu{IRAB7Otoe8f z!V5^#u`}d9-Ocr1+1mTEHQmzogYG?Ax3HT*{^?tOZ04DypK8Wx+Vu<7CN->c=3`&|$^%K}CIB>&hB>}qVvVUCPpMM^7Hte&Em|7Oh2{12Q&E;MbXvgdSEJZd}F56^DqOXoC)LDKLRTv%Q#NlMJJyBY@&c_CV$%9x~`p3OaO zzBl{+n7$wrx`0v`zO)#>7_Z{@KJvr+)yxNo--Am6iLsBXUtyFsFSxO{zdzQc$q4*Y z=HvPDcKsnAXY1Q6w@|M0c_Dd>C`w-NH81n$&u!!eUijuh+g&zez7P^X8;S}PQjQwG zMEx8B*`|^v$m{5!%@T0@{<2(mBZ6$kbSD0wfy>zcs^MPBTPfOVqCV4H5jmDT@l$)M zc`w6ztOFP110G~Bdw`;;61)kZ4u9?B!|l#8hRmFgcgW%us#=Tp1&Vr~zv+0-P}bDp zKh?ZO{7m#H+PYzNb6E_hVtj?zPfkP z_cLZ}+UyCWmoQ@UdR;Uc&D!nmH@T7HF~v{L@-r^>(~AOXe{PgEdChGU=P=)OE~2vH zAa>5_B12?^%%-C+idUneUo&$GpF<73Pc)pLBBM&CJM%JxPx6jHf9{-p^emyx`fI1*QCVh z4MM7lnTo6szoF&FJ!C=WUF1Rl)tlvx`lF~g4ZJuNemb5OB}<1 z5k(;%7r_*yMY;k@hzv#lc6_$J-jpT6;#GnNp03cR#CH#ha-CUkzag`4Y5RFIZ9XSj zdPB)Fteii7N^iN!z`xC{$8q4aaPbtGZ1HWI(@#{vMeVmPlkOlQETv?f_w73auiS^0 z8DEola{-&jc?uBLD+tvDyorUgH-=a3duWE%DmwjLweiI`u}tr7SHiF?YkOn(LV->l;ZN^!mV)w#&sz;jh-ULgABQ2y z*FYxkJ(8gyHAjl&3aL67sA{XHeeFJnpZ8Q(n#+&8wG3db<1YvB8BjFBpXW?epZu_F zT>Zr`K2K6C6`Y9XvGD2n+AFs&hYk-H4lnOyoP9{-%8B2g7j5KyoI(2VWExY*>;)0E zVy&4q5}0f((oftqthRo+%1YiiwtvNz%-QH-Dcq+n2?2udDp>H)vZ6-%aZx3mY~6a7 zG3`Uuv7GPCb#{)UJl1z>)w+C!@K%U1P)INcemehY+`y~*G<4HB(c!ZO72Gr8`0T9p zbvVQ^-J>OuGbMBcs+zGBn^MSNKb8Yg!r=4b;7lb9>N`|=PM=c2aFuFnr6Q zI0_Ambf*n^+%@}KFS_(m+fEtY#w-Tkevo^ITKTGj%7)|pS187BioUNAMtUC8&-~r5 z$J51|NX?*~=d$w>3JPJ@S+JiOq~gLQm*RGMdPDi4@z!1X-;^ZB=D5?qIaPd7lu?YM5*97DM*ZsdY0I z(}b0eiePT%cndWb)rauD=mbr7z4`jB$>E=cw#nh}l?1_sF_l_J&h*eE|6nGq4Gao< zyS!MW@64mYA8f;j4mp1nfeIz^2K8`SlcvE8EI+%NT%Sx13& zTt%gCrIT~(>I8MFaXogU=H!c|klW5i^ zbNXPRzTHL1jP=g*Q%~fJfe>^KA|g~tDH2R&Th;Xx24Ck*bT#;3t!h^j3spQSZ!uf| z*IPO|)I^!?OBz0bjKVm-TTa#OYcJ4;R0-7emWgNcXp{*x`K`w?7udJu&!e-mC1vxF zDNodlve`3rs%S-71euh<1PnobH{?w3RQ=|u`hnM&JHdy>ZY)XY$e{s!{rCu3PBRab zb@C&RwT(lQnDS8x%(QG@995w&#K{Kl8JmTd7$9ob&tpuK3o7U;33k|;)&cUOtpL;p=ZXePfxP`^(D zA9*mt*~B^uUC+{+5#oyrn|O~y&%a%zdhi?Fd0M>6$BQYmVh=lm;Bl;Nb4MbrqPxHX zc*ORuScxc{30!rRQj)HvVl49LI#|H`-77)cEfj@zdt!%l%U+{$DXf_^_{gQF|n-KDsZ2+ z<3hU?OYnWAOpfBz-*lR0;-_V*d;uTR%mjA|BeVQ zYSch?Y=Z1`!R1l=&~|J^8EL6Spl+qaCMUB$$G;jy=sq(3QOSI5|BcXniBIFMEX!)Z zyOy=6+hrtKjM8j7e5aezwBu2&XRZ}`2h18TQD;T^J}K5jzD5hyR8T$;Gi@*^WaL}L zzPj7S7v9Zq2CdfLac**%)aatHiQWm74lQ{HYAdo)2vIB~_(Mt(o&;2d;aJEp$Lz%` z+%+c-14{uWqx-k0#+`GFzBTL58Pmi(If_gPC6s$XK6JioiG?hNwF|)ffd^j!b8K5Qo z`lrs*kgJ}gk_Rhz1iID0-~Tk2Bw9@aFGl+RYXLsuRnmHk7HML|ZaCp@3XuT&n>Wu# zB1zqToGuz3DYAV&Q1O_T%u1}$WNPQ>&Er8c?#=3jLSaW@_`86vLVU9c z*6K7E({_qL>1_rZ_M5(vTcS=Sx$4XXQ|7Gv0`J4VNQrc_kKBI)yZG#ea*0DQtgfu@ zW?AyyWxHxB<*cGP-=w@xh2)v`^F%&8dZx^$#A!jRLVBsVKEe1IHS5`7pIdo&VK`KM z>!p1QN3)+3Lf!D*op?Q2pMQQl8g&1`-&)IV|EqGYfmYMRXHg{Ip8_8jKUVBAn}%MyIsN)is8Xyb0H4y!`u41+P>{nl>=1grH(fJD>J&d4z;2j%RmUm;=Jz! zu`4SH5XbBOt!PyUFPv2cP%qWu-k>52Jd<%hKU)oTxM~q*j%1 zP5@>J;4wZmeUNPT(Y?+m-2VBc!#n->@&slIAwa1emJ|V|O%m&CKwTI~nqa-30lzPn zocCw~od(Vu^uYUcD712-PhWV+d3tfxgCudSsqAS;*U;@=`Y54x^6GTOT~j<@DZ zNlE~45A}n&yb>9V+%V_)B5FAZGb)IKY zp$P}!c`7MkSV$I7AUk&+#JL+LA3D_n)REYI_j449(>O_m~I3rVqFrPpyK*{D+gU8{Z(~@UwnujC}+M3&O3NDMZFc>pr^H;lEsQlNH zbKxwNjY#nubh)FtetNh&H_zV&4S`4)Ec&*eTgqf=B&FnkhL&t-OEEFwu;7)KdMNt< zK%N|il(9f6Cy0)nGEePQ^1%(ZVj|q9w@&t*$&5gJlhOWrCKb4`msfZmVTLht?%eft z!=-yzaHBc`J85Wh*LeYUpk?M3-`HE68M21aSk%C)K{18puG!r5!el0GF+f86yk{vbcCS1H=+qxD<9q4#~=@inuy3whqaPr5yCazMS>fQ4GF0u{Ar zZ#A>=N-CiM$V1W@sSB$4I&>3!wshHaeIzmRDp6TFFCkyEG zuIkeO)TP$33r|NVpKt805*`r%&QJm1Z>ZTXTx_EZ_ ziAN#@KWR2|)LI#IJgxj7exZ>>7BD*+C5e=hW$$6ylbu;wrJ}$P^9kd+_1)(*st8KS z+t*YcXXE|br_rBkEDe(HVaf=*4g##oj%^(GIn2UDK7K}J+^@zA01#_eZ7?4aW)oBa z7HoSwQf;^F;NN2dN(gm5C_9YD9gK0H@0JX&uDLOcJzrR(#@>AFdR&mY>Bw@l~QSu2xS+JQ! zjY9uxN1r;N4%4|f!e!q|4{CsA`FO8hTl)$lq7uznlRQ@$sh#5}2&UrAqd0r(nsba9 zdqyYeFjj9RRD%$ujSrEOV{>zgAknuxE?HT)NW&$6o&w0S^W%FrPTH7|+EUa-Zv4up z)s9G})_)EONr1@`12verWy5_r$Un;;&+y!8*Xlwj?`YJYWpHv5svCvOOLau`a)Wgi zy|Lt1lhzVpyb@>h;bzhBOE&2v0>c*WbKh0@l?47*f`GcZO^wHq$OH8gCNm+-->JOy zF~Of#SQuD)MrK#;&jwzh>-<<3)%DCWF`0vL%m|BuiK%whQ0F>Fs6r8Sdb}1dc|-W5 z=;$dN+KoyUfKp!BgtjxSGtcQA%}4)`pNRX(c3OfE%jPRzIz{=yYXD4|4Tb#q-e*XF z0ey{Tx?jRZ=I^=H{-?P1Mb7n*quuZz=f*Ut_*RlqG%?oPYVIdGJ!`#3HdeujKcNRK zU@hw7MeoyLJ>NwaXQMPC6#So4QG#t0*K2C9f!$#w*1}aNRea~3AfY;BkvqTdJvu26 zdxlm`LR`GR=P05zSp&XWi@l`+Q3GpfQW!X56ePQsg@$vz$&9p^4@u4|3k#2xmpFX< zC}AUpz3TgB?iGsiM7RMfSij>D!SfI#YZ6+s`-_VWHJw6!aZJ4f6+#N#=3ZApuOnXN zxC^_z+ZB?3ZVRs{=5cwIcHIZMpOr7W0(iRWFR?O?g078^&{=nwGO533QA1NzUZRFm zq4dVwqlk@mE3)K9Q#!VjyKRqxZX(Qi*sP{_G5a<=r)0zt{GbO+YVnu4$08|U+G8$- zuJ{%M%XaSW9jEUMA5IP~1ujf)&OxH#Yu}w@x$l=`e!Zxs>s2Mey%oxcPJwPtPEsL7 z^ky^73W!XvDWA0S$*+ON3$5HP;q4rBdKCYas*ADR(8}YMa$)iTsBG8d?#&rK>u=Td zJjvP*vG7KLmIM!~i;-KKz{XA^{saf`!$0tbjux%nJAZErGrf;R@KkoDv{{#y9_|oU z6d5%sIeI2h!mqULhhc8BnBfz`Lkz?bD)&P+(Ul!WjHG9Aw>HSj>UQe#_r z4#|Ih+QoXK9w^k@J#?FZNqC+WA7?Qy{S?GXqIOpGK*yL{u#pV;=;L;rk>%KRo5~fn z?E?nK(BS*V=CCI96Jt^19Ibd69zyl8xBd@Py z@NNb{e^`_)Zte0~QWeJsB`W;tZU{fiM_0HI-D5f|5;>?$d<`2aeca%13s}IB7I63M z>_^T!^q>=B99%q5lXU%T_!U|G`-US0c`Bt}U7gB4f&UZ|f}y(|7#gI6TIsJgXGI*7sOCS%JzfIXyV46Z2 zx&G!o2$=xLNz?G$1AJdhW&fq}lJyX03-G|L(~tp1Wd;T z<{$xIO=w|HQI1af52Ao0E<0^8EA$*k0lfmc1a&12G(ExKog#-mm6oeEO%iJyIaa!e zVC}EFIh`16QR66`sndKq#sT~^erFnYyZKS8J~Z|Vl@~|0*w&X6%<;!h0t~^kl=QX~ zvwH@A$cxG_^>fXuU8$Cg3hQBT*`a~>+4$ZDHtT#bQK{1vYV-?W^O7HQ*|# zVquL>9t32(%l1CO+SDqnQ!7lt-vPN-bi(!yXn2 ztjrS1|AhXk=Urgi^2&C07`3hATLN{Q)AwW*_JhkoYG@$qwa2HZf1p-7{EB3foJ~<^ zs5c@6;~ilv4>T3GTLAvtL$sd1j1DPY+>xi*cWb>^62bKG;T}dT!JFuIkQQNo74)Qy zG8p;14VM6bqKkfyQBqQLu59SBY-Pa~!&V2RY!$jpG3TGFlQ)}vN*ubxJ3G9Q6Lq>Q z=~KDBatvqFB5FM08nJHivNyi;uS3_iEN$9)iCjf8ZBV19wyNVO2*B(|1>nsE+Aj6+ zc2V^UN^IQS!Dl^>!B_Wx?-7!RqW(dMxomY$(O_rjaat({-h-&pMR6bVzP3OpyBbsX zpvdX^VmBsADohrk=BxfEo4IkX{HQF}la$1i+EVaPDPJQAee;P;(&@sMN2oyn`tZaU z^T0E0`i*OfI`*okY>)^?|6dY#AVCcJg04#OGE3|N{bGX;DiRe7=LjMVB`F9s$Gnyn zfCYg4zuF<8zP;5?yu?aL!o|XoMEI=?-~QAL*^B(lCV&kx9Ly||a+k2M*Y8A;Ry{ml zI((+7{tJ{jNsJ-n!E#=Boo_TlFPtP<$|dvej)-{6sQ#mw;F4?xb94?XYi zW=csW=J=JGwvr4FKk>ikK5$2*vGYLSP=di55I=?W&xWpsQ2U=!%}~fU?7y?-uSsKf zB|$@xd6W$7@go6YnnbRxkl^>~F?DL~ceOnKRv!`WuM!Sj!F0fZ0)p=ozkVD=;5@;~ zm*cCn#3>|!YL(K_e*4GCC+?Q^uBYw4ESE9i@EC}ccp8#9&NCePZqsaD085#d&j|MB z-#echHaf8~q5CwwMB>dOh;HUY?T3b81Sk;3JCOk-aDHwm2>?Qpl*SJ|Dvr#X$~^xOo}jeE1zvc9U{Vm{6++;#AbJVWnT8Iq@g)uk0MSjRzSU;)o@Vnj z=7&=%9{Cz00R*I9nfy45{wG9@*n=nAr*3E#$RScCu7?6ox!4G|c$qRDbd``||9iu$ z?Xw$Wv$$r8U-HULsD=)3&u`G%8ILd|1$|63SM?_^%I`ZUhHnaQLJDqS!S z9$_N}_G@mPm~c$gA`(;pYz;4ycNNfHoYT4CJ+mRsqvtByI}hp=?vkPD;!1Hu0~9p= zQ(s$mn>q7<+nHqYvY)!N2G1hb8R6qzh z@*cxcZ(9_AhDlDMGrdfHnxBFKiv|JSKy$FgQbtwB7$O3kBc%mpv_~<6fjq|6&gJ(4 zDwju$j04kgZWpt&bjj~cvvdFk7C2g zh>#sID&mU~8NGyCk>IcM+s{M!3jMjdhq1#Y~cA{uHs~6#Y)R#rU76F3oqrQ z*SRF1ghk?cC^cZm$cpYy_#(LXcy%yZe?2FeJ%v@ zKgEbix6*6XhQNQHb+9n*UftnW?j50d(m%COYJV7oMw$2jPnJmi%qmJntWGo*N!psURz9;OcGQbhpF*_+zwvvN*K5pqw9fX4ii!<`@%*+lYS$ytNc^Pu1m9-M!qebY0w%Pa!u zN$YWLJ#z0y`H-)T76^PIejWlqObdue@;~{rMz9rn)8z1;wV2(jA&`UL_qWyfxRM);)UijQJDPIx#us-x=XmPZw8)Oy zmk)03h^&4V|5`FJ~ex-yAE13kA{9VXyiJjFRdZvD;(gfX(#(MqB%Mmg&#D9-3 z#m1_T3HhsJ)K4RKBH#3E3QYqs13`q#{b<~~tvqBiD!;rOWD}{MzPw%0P*PHOt{D8+ zyyX<99fYZrXl~paM#u$le2;d;uJJ)YudfT!347WEd8@yL5A8G>MlETK1+&z|sLQyU!~2jT&Iyo)y{uyi$P}3^JTp!@rEP zK%}|d&;l^im^KLTPkcfu)e)*cYw#_x>5%DCx|-W%?i=n=;W)%|6?=rFuBMjD18`7h zXVLye5sCsKRY4a8BS0k%X3*Q0)l~f_836~|6JXw_&0j0;J3PdIL`um2LbLhZm24@3 z*Y~``(<^^u=z+v#NzUxjlf>66rQvE6bbmd9|C>hjVLKfxargI_;2?$9EfseYVQ}Mrp5Kod0}m2=Wup~-O}_1t3PRNYyot(p9cjemAkC;j79RB z`bNZ@Ji`Mho%^V<{>66G`#tPAPgKwBLux}zsh1miD~IM@oWUB~w+|ufQ$e16FT4Xp zhdM5UzV|=suJh|vPx|~90S!pc&mmG1#w3gnxHo~}TuZI?EL}Hq-S8^0MRd*>m+Y&e z719;z^WK>JV=tc8Hnn{Pr7TQu!hm)9&M6oOrhJ6e(m;RN%WOlc@PJ%xjUHU-_D}m? zBaW1ukl^TdceBX|_mMGOjAK_PNYOO3_-U^&nn?i7Gn~PWbEmWPSlPjnD$}by`HpF5 z6*IUy%S{g5 z_V4R@y`O__iowyHW!|*^qp7Z&yDwnNKdy5V*vShj=&@L4=E6*#rH1`B@1a z1Y!4F?RNI0w78(0G1qc)XKi^`wF1}R9}voM8yPvm`y)}fyyy5NIuiRG< zA2;koNV=03I&O^+IhX(TIZuf=4sva5J*-gvw2X*5!S<*q(Zs*Z83h*#j=WLi<@if1WIj_2zXA_;U1Wt2hR;Dlai7(Q)sYU$+4-9QWvmV z_tC<$z;2Q|NX%O=4f=O~sTS=_QAQPov7MXTwsg1{yMQ*zPoJt}vNB1Lb#APSs2@4D z?RZD##XsFFscv|CXmCo>+G*^(RPvJ*nt}o8rJY}4C?op|-26spMKA4*rSh%8q4>pp z7DacZe=xYnykj)|OL>73A1^^l4 zQ!F~HXVBQz{(czqjfIKdl;oo4m-18DH3<+!wwI3dXn~^}>i#)${tzF?+%!F^n*v>D zMH#4#c?H42cLRTO%z#X9#4G+mmV#7n!X*cxNaoK+OP4%S8#rqAqoxbhE6#flhBJf~ zXw|n(D>^Pq9no(@ELIP>L;6L+aJANZg@Ld<5itK zG8LR1pfxqk3^5vgcqHvuQkH=L;O@jY`;seH1G*Qv5tvFyAmZaQRTICS982gOZEGuV z^k1lWQk%eKnuW{YZ^ZfA?VnP8e^n;QSmdj`GCuRxWLXFm?KW`|W=E>n#y40l}-Zy8z|;1(Cjs*n~a-eAy>_+8HYvnFk=-$+#N> z`Q6KJv=F9L&yF5*9^Kc7Wn2AKR4O9+%^*D<-t&>tAcO7Mr1Rq}71`(l+x}$98I>#9 zHt$ol4mRfdDfm8`pt4lV%AdQPwCRMM<4qa@P<47wtL(lC7SEdxoya=n-q5z41X*kl zAdRQ91og%gj|hJBhnh(s8*hF=w6RtD_1@_HXB<>1EhZ;hS@G9i5%%G2#SacSu)Y2P zNfN9V$RF=Q%%_76->K2HY`(Io<33MVpYOm~q!vj=2u~k^8*k9>r!RTsu4k6E!$gXx zZ=7Cbc(})d%|L;*H7>>XA?9@*`xm&g_jiZza>D~BCuPArc)MF(8^RAv#{3<8&hhjv zKx^JuAL`jTi*Py|3EMBL5)BC&J@iVu{3sAU%NSeZ5>0*R5-mN8$IOhjpJ9=mnW?nz zs*P_5q zhO91Ck{}A|LUgd=NeF;?&jj#hf-m-C+FtbpOC76%|Mcutc2Z^+=@Y#Vk7K2mkvSIQAMgMVL_s#Mr@r$D# zny~*CT?~E#dS2IhlxxvRHV(PxN&UID`b+l(uj>RiS5%yXrI7WJ;bE7g+*&#|$n%vK zGYcl?hlc-=6>vA zYZ|VPy~bx9!}u6VguuOAbW= zNm-<1Dwil8pL)`Hedj|oJ42!|&2ajiG>RP#$(&?v3Vf9k{z$m6bx~1QJ6k*Aup7}rB3fR(aRS1xn;~(SRn-uRR{u@^F=<26d z*h*lL==IPVT?nQfb=KY6U}DOdJb(WnH*~7MZ2?skN}byiAC*!gCw$o5&@>W{pGLr$SO!_hF$$bQoQn=)aOkuMqh!d?xPT0{m0kXT=>LOf;eA{Jr%Pa zvT@1&j*AR*m>RH)aBj!^BuQk9r|82PkPl1lKz=N9>1M~{zdDtw@IXNHq9^3h)r~vV z$G^+7V-S>V^b724S>Jnfk&yG+?NP}ftF{9PnO{$%r_?hQ4;I0S&i;I(iln=7y$iC~ zC9Pe37jKdH(Z8av3sDpEKEmqDJw>-T#uc_35p{11wfHt^bL{1KLKcr*&adjIA2!b5 zr_XZTBB89e@S3W1-*3VLj}w5xzC+Qho48=PRZdIeDmJCG&*m@PvtjRS z=6xn$`DheTLmZ{gd@YNi_ZcVlM%Mlz^<(9P^I!ep*xEu@`-RnhNm;#hSyAKFQfqZ9 zQu2{v+d452GH!#(BKUtLU5P(b?fV`>F~&Nw?+g`@C1EU+tdp!sL$W4mQbzWDZIZD> zC|+bqV(f&hA@h0>2196M%f6fJ{LXxTf517P`?>DxzMkuTKIhy|nL+c|FGRQKEap`9 ztfc<1CB1M*^M6E$s}J$nAcBH_+8$-L?>q-V7C*dxi03+EkWdR1aUrtj1?||vskdB$ zrpUkrQe4CKvi257qC+9_{|h`eWcnMU{?DV@e*X4R$F+rdLc@R#Yq7OX^}0ZLFvxI98KSqcFRSU zx<_kvP)s`$)|vgN)=g6hI&QN6)3Y^>Z(Go!zam}T3uFm-_PXJRt=PGG1+3;f_|`_> zTtEXLk(K9ILw@VUKkCb3w1oMXuftoDR zN>h!>bbSRm&u=C0t^L&b=|GOMbRRUY{(it>;C%UO%zDt%!u~!Q8YYzv4)<&yTJ7p! zc8Rfbat7x_Q|zDRZbB;%5eeWTTlE!%`^mdIM#Zk)&+T!QT#WaKS(2qaYr7A_@h0fE~Cfb1reizf#Gm52Bx>tJ|8NFDeVGTr$b9}c=y}nS+McQAdQPJ%DB z=5T6g#|=d9=&UQOv80WWnSF5D!Dz1!jQujV7$D7_%tkweLog4%-|acmTtR~Xnl(Oz zyOPD5R4}>upaWSRr@>M`r@wiAx`yHemD#Jx4 zZ`ofKw;8WQXGX>6=ie%sk}0znc_cij2E>86gJAysBO9l$8pg9j+0o(S)+L(k)Zr>2 z?Nku-g5NWPt<;4NcS$@CPC>wJE-u3yGmASNn2n8oM0Ph|4+wqee5V-_bx1KP2nEDJ-?!SmjUGg{G9Wi-!9gs; zQXtxUtZbjhn`KdkPcx@Oa?}V2*@A*1A$>zMj-=IUC{&;Olq= z^7*GOc~b-oB^JXw71KVl5&e2uvm;SO7zQTwh+m@So_+shp^fDrZP^C8=!hy@VHrl(Qzf(+bFve**YCHc`I}e;z}1qFHrp*ysNDwQ=uLs>&;id zbu!Kbfh4mfs~t95j=siKB`lQEsL)eo?i4=hde3oM&d-_BNMRH<9KgQyal~gCr~x;O zSec5nx5)}x2rilu4^PMr6jgo*#E20AIjb11UCjaBvPOFgw92-lh;Qflu>ud}*rvYv z&-S)9+1m(_REBdb68)Z)X`x-kxWpE=rc6dY(>%yj+h_k?Hh-4nqq#uycDDEgUA0iU zIG(B4o~b?A3frx_f6#Zt#l^KmEnFGsuRibGJWbEd7yC8vX?~R2%8!C1MaOp_FZs%y zS&+B>xk_UJ=j_pbf5YGNSH4o8&GFjNbDz^3OjKb_hLNb8qyJr6qr3!YC|-uY8QAb8 zV@}luF(KO3Ih`I?l74n&#b2HQDEL}XN@qAn{hz`K?7X2i1QywNY%|1>0G+OXC7XY# zxrLQh5q2Vxp3PrY*UfB_X201AzK0)YeH+;;?MCE5zXOEs>$NSqbR+be3c6-rzBhmv z^kJ4m+QP=L3n9!|utLue-Jy>c8jsn2^XnDSK+KN4%7Y6DLMLU?*T~<`4-N99hV(xa z9{m0rNUgMMP2^o>Xy}r(-A+kPM9Njqk{nmOm=I$-HaDNc5;TIy;2cCBq(eg z?m17-G$u8!G8ZX;9O|N5V~Y}Bc^w1H_eyzYd83c zwG27l9)8FdI}sqRy(6D8hy!lIxef=K*>^7L&yRvpvHoKse2k(6P zr~?*E7!&fFN?!v8z%pQm`W?;n2g1O$EiSoex8W9#hjn!bxc`QDOxm+D%qGw z@zETL2ph)tDU_(xyvF`y6>V$3uMK_v*L7fW*)1I}EfxGm`uM}k$2E*pXVvBJ&j6;u zsg*UU$idnvd)~PQD>(dLs#D(PMZT4z<+c>0$Ap6^u+mH&1edQmNMHf z5-a|Nf?+ItnRH}<#Y>&5`Wx+e-~IAE=Ork_oUGO@NRTBM4$8FWl@Jitxn`+nzHF#4 znOLtGU0@$GB4e^oir|249+;g>aE8)J$FeWOk55P$NA~|BvfsDmFn1NcM>&Tx-3wd2 zZbuz7izw*eIvG>XdIH>i8wAbLGb+x1^d6AFGz#Nm*y#6n%4v~0uWN)=Rwjl4+DCwJKyJM9p0qat z#UF@cneKiT6A(1GW_#-_6-Rm~N_L9RUgQ+JJY>9D6(`XoL@<>>u$&oJVwUJ_h{{7i z%AX3LOC7Bu4>nF*uN?0SdosVMADN1MMdAsO9E@TJ_a+*08JbfSJ4X~dT{?h)&YbBu zj(Y~V%iN1<2(6#cpH3PZCiz?OA<2)q2nYGjqofcU+}m@sWH|#|nsb{SHwzZonL4uI z`{-wc-wVBeXN=mlo~ zR=xS4_S1~fD+FB%6E+04QoHRe!fZDTP(Tw-3@O&;MR_A9pvC9Two9bD+-#s3M(ggf z+9uoA-u!lA#ak<&ScGHDomh+Q^(_T;E#)N3;>ADT{SYbReW49-Sb2;j;J2F7fMiN9 zp93%z?r~=AGLq+>e30VGyQ;1nL|0)rN`h&$qdjpdLKYb;d!rL`l!=mb=MsP%n{;c1 z0N?(}5e6%4KXq%x=H@%*NtH*+BnNUA+oMErDvOtHH?I1CF}tV9IqET{Ukr6v6BmkJ>{slA@C-@4YQr zQO#iA<3Crl&i%!gdjws3gA+`Y*793UlNK4W5ixU?BdEChH4VDQJ&4p<;bOF7W-I!XaEryz7 zT#~-_AENLPt)u6SEucq3FWzedw1uUF`#;0Itc(#k(|G77<(7*+QgNtX-6>zw%zPB@ zNREEoGm~mjhE2|;t>~*K&a`LuUzV4CIRur!cx2d>n#74?F2Yo$rLZMb{%$)Jm^V13 zGu}E#vf(23>vu1hNz)@MaK1CTRDh@yZ+$e^seQ1JOkpO{we_snf;pqp0ZT2Bx7oT^ zZhP2T&Gwleurg!%N%m|q#rb|cfx!6{D<3_^L-L#1tvRj-y-P2+QaYtg1O&qpqBT=J zqZuGi2ql+Cghps8_dw-)RjA^5KCv|S_@g>Q!`nufB(e534|eS{%DGv(PRH+ z!%T?%=zZ)~k(IRaDF+8Xw1CZC#f{3Z^#FK&&k6352SMnT=FOMXBPnt#oGjFit(|c1 z8*bA7ZmqkOTtuob6)GxtjvuWqK?1fnzwzVLdZKm?*eKi6`ayU276f@szP_Ja!7i(F zWcgeQAGj1O6!H1U09|sc@knb7KK|s|Y2H}vLl?55iUv@A8fJi{#0;E~s`-M~0?;nM z=a@gW{O(;3z?&h0R%X4HomXME2}|rjNqST3Vg3-my_}1{V?i2vp2wPzxuLtyLWD-v zJ2>pw+UFfayOea_{#Z|({c4hTba}w|Q1rp|w?637=(9-0zdX_2Hxi-%$3nA!xsiyu zIcucL(?^^-_1Zu;F|$tu*MHiWmSU`CE8(W)O&vIiD!Bggw9m|WZa|)iIFI?HoW>uy zdECVow}mh{)QFNz_vl9yTXz8*bQ7aFtNB^RfjTNSI$uhyLDRn`yYdQ0QDtoM-Q~+3 zTg~56_q0>7<{lA%N=m=UX3vN1KAQ+9t$Tnx5A~2;W4&ULp2yy1Or_biL9$uMTPV-%$c1nG$ntA*c}_oI zrN{{X;wz4KXy6k z2k%z5q&8t3ssZ;`t11Dc^l`x}N~xfN?~GVMdX#5Co~X;61i&(hO}BM9jlj18p@-Nq zn1@>B^r_m)=GYPtl}B6w{j?gN`ligGujaYC<(u2uU4=psLX7YSV{q`>@nRGbj$ac1 ztd7LW;#dQ~o$=(|7v?&wT30n+zMJP#l@P1WG=Gjx~qQe3P~3D$*K{`5P|`8y%8jMj!+djzBi!gJF(uI=$FCJIb*ji&w<~ z?I9)vF!kwygN^f80B69L;2&HOC%FdQZ0qDCkTslo&%^w^r{`NM)E4;1QvMO$fTmwcz5>=Y_}Rci3v-6t9hBB}{TbHDprj}G_K|GM&D%=7}X zpC)ypnf!!5WFn_+arzfW0^`zr6ne6MuTepm3;se=u%%l>=l#X|#{#nrg z0koV?O5(^)tjrG@1X$MTqT7^+bXo8+Xs<)Dxa?Xu^|C>qUSqT8JB~SiHu)m44`Fq^ z8bCu%KTz8nM$=iAwV#7yi?|e|%VjWxjtP?N)nq({E@c_x?)OZ`-*~yWn zBJD9Kc;~#gC`-b93_HDZ>zBWvXsv%_dzb!TgRZ9n>ozewR4=MXpp+)M_@DKNbsz0l zKhgtztQ$63UqWJ+6_b2N@3THWmv17h(2=G9!UWM_?U;l{K1g=`=i;fiF%a*S$Q4bR~}u z$b~$v2xhgp38^^Y8D<;{ty&Z(E~R%}wpCHm++3vp#u7%vH$!M(?RtcPL++2#^8g5& z@Vdl*=5mzd;JEZ98gT^-7345KSH#@@uJ+3#9aq>FmZ;Yek0^K@~WZ jv&=mS))6A#Iz_Hyf;{oB7lGf(Kp>R9sb1Ml$H@N!!mCNb literal 0 HcmV?d00001 diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 543dff4..04ba38d 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -4,6 +4,7 @@ Free SQL Server Query Plan Analysis — Darling Data + From 4453034c5ee19adc1fc32351b9ba478dc912a6a1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:03:01 -0400 Subject: [PATCH 3/7] Add Open Graph and Twitter Card meta tags for social sharing Uses the Darling Data barbell logo as hero image when shared on social media. Also adds meta description for SEO. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/index.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 04ba38d..432cfe7 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -4,6 +4,16 @@ Free SQL Server Query Plan Analysis — Darling Data + + + + + + + + + + From ba2beeb03c7861942d23702eb27bb6c2f9b4562d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:09:49 -0400 Subject: [PATCH 4/7] Clarify OG description: in-browser, nothing to install Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 432cfe7..ebfd4ee 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -6,13 +6,13 @@ Free SQL Server Query Plan Analysis — Darling Data - + - + From 68ff836d372a3b7c73b27530c6fa7e0096a21daf Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:26:43 -0400 Subject: [PATCH 5/7] Fix Rule 3 severity: CouldNotGenerateValidParallelPlan is actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reason means something in the query blocks parallelism (scalar UDFs, table variable inserts, etc.) — that's worth a Warning, not Info. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index d89458d..af6a5a3 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -141,14 +141,16 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) _ => stmt.NonParallelPlanReason }; - // Only warn (not info) when the user explicitly forced serial execution - var isExplicit = stmt.NonParallelPlanReason is "MaxDOPSetToOne" or "QueryHintNoParallelSet"; + // Warn when the user forced serial or something in the query blocks parallelism. + // Info only for passive reasons (cost below threshold, edition limitation). + var isActionable = stmt.NonParallelPlanReason is "MaxDOPSetToOne" + or "QueryHintNoParallelSet" or "CouldNotGenerateValidParallelPlan"; stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Serial Plan", Message = $"Query running serially: {reason}.", - Severity = isExplicit ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info + Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info }); } From 56150212e7f8f9ada43aef13bbc488e65339ed09 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:31:31 -0400 Subject: [PATCH 6/7] Expand Rule 3 to cover all NonParallelPlanReason values Adds human-readable messages for all 25 known reasons. Severity: - Warning: actionable reasons (UDFs, cursors, table variables, remote queries, trace flags, hints, DML OUTPUT, writeback variables) - Info: passive/environmental (cost below threshold, edition limits, memory-optimized tables, upgrade mode, index build edge cases) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 58 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index af6a5a3..c2b2fe6 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -133,18 +133,66 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) { var reason = stmt.NonParallelPlanReason switch { + // User/config forced serial "MaxDOPSetToOne" => "MAXDOP is set to 1", + "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "ParallelismDisabledByTraceFlag" => "Parallelism disabled by trace flag", + + // Passive — optimizer chose serial, nothing wrong "EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)", + + // Edition/environment limitations "NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism", + "NoParallelCreateIndexInNonEnterpriseEdition" => "Parallel index creation requires Enterprise edition", + "NoParallelPlansDuringUpgrade" => "Parallel plans disabled during upgrade", + "NoParallelForPDWCompilation" => "Parallel plans not supported for PDW compilation", + "NoParallelForCloudDBReplication" => "Parallel plans not supported during cloud DB replication", + + // Query constructs that block parallelism (actionable) "CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints", - "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "TSQLUserDefinedFunctionsNotParallelizable" => "T-SQL scalar UDF prevents parallelism. Rewrite as an inline table-valued function, or on SQL Server 2019+ check if the UDF is eligible for automatic inlining", + "CLRUserDefinedFunctionRequiresDataAccess" => "CLR UDF with data access prevents parallelism", + "NonParallelizableIntrinsicFunction" => "Non-parallelizable intrinsic function in the query", + "TableVariableTransactionsDoNotSupportParallelNestedTransaction" => "Table variable transaction prevents parallelism. Consider using a #temp table instead", + "UpdatingWritebackVariable" => "Updating a writeback variable prevents parallelism", + "DMLQueryReturnsOutputToClient" => "DML with OUTPUT clause returning results to client prevents parallelism", + "MixedSerialAndParallelOnlineIndexBuildNotSupported" => "Mixed serial/parallel online index build not supported", + "NoRangesResumableCreate" => "Resumable index create cannot use parallelism for this operation", + + // Cursor limitations + "NoParallelCursorFetchByBookmark" => "Cursor fetch by bookmark cannot use parallelism", + "NoParallelDynamicCursor" => "Dynamic cursors cannot use parallelism", + "NoParallelFastForwardCursor" => "Fast-forward cursors cannot use parallelism", + + // Memory-optimized / natively compiled + "NoParallelForMemoryOptimizedTables" => "Memory-optimized tables do not support parallel plans", + "NoParallelForDmlOnMemoryOptimizedTable" => "DML on memory-optimized tables cannot use parallelism", + "NoParallelForNativelyCompiledModule" => "Natively compiled modules do not support parallelism", + + // Remote queries + "NoParallelWithRemoteQuery" => "Remote queries cannot use parallelism", + "NoRemoteParallelismForMatrix" => "Remote parallelism not available for this query shape", + _ => stmt.NonParallelPlanReason }; - // Warn when the user forced serial or something in the query blocks parallelism. - // Info only for passive reasons (cost below threshold, edition limitation). - var isActionable = stmt.NonParallelPlanReason is "MaxDOPSetToOne" - or "QueryHintNoParallelSet" or "CouldNotGenerateValidParallelPlan"; + // Actionable: user forced serial, or something in the query blocks parallelism + // that could potentially be rewritten. Info: passive (cost too low) or + // environmental (edition, upgrade, cursor type, memory-optimized). + var isActionable = stmt.NonParallelPlanReason is + "MaxDOPSetToOne" or "QueryHintNoParallelSet" or "ParallelismDisabledByTraceFlag" + or "CouldNotGenerateValidParallelPlan" + or "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "NonParallelizableIntrinsicFunction" + or "TableVariableTransactionsDoNotSupportParallelNestedTransaction" + or "UpdatingWritebackVariable" + or "DMLQueryReturnsOutputToClient" + or "NoParallelCursorFetchByBookmark" + or "NoParallelDynamicCursor" + or "NoParallelFastForwardCursor" + or "NoParallelWithRemoteQuery" + or "NoRemoteParallelismForMatrix"; stmt.PlanWarnings.Add(new PlanWarning { From ce6cc37636def39bc1078d327d59f82aa3d4b3a1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 13 May 2026 05:03:32 +0200 Subject: [PATCH 7/7] Split PlanAnalyzer.cs into partial classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only refactor; no behavior changes. PlanAnalyzer.cs (2,238 lines) split into 5 partials: Statement (439) - AnalyzeStatement (statement-level rules) Node (777) - AnalyzeNodeTree + AnalyzeNode (operator rules) Detection (248) - predicate/pattern helpers (HasNotIn, IsAntiSemi, IsRowstoreScan, IsProbeOnly, DetectNonSargable, IsOrExpansionChain, HasAdaptiveJoin, ...) Timing (345) - operator CPU/elapsed helpers + ScanImpact + memory Helpers (275) - severity overrides, wait labels, format, truncate Main file now 115 lines — class declaration, regex constants, static dictionaries (RuleWarningTypes/WarningTypeToRule), static ctor, the public Analyze() entry point, and the ScanImpact record. PlanViewer.Web.csproj updated to link the new partial files. Build clean: 0 errors, 0 warnings on PlanViewer.sln. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/PlanAnalyzer.Detection.cs | 270 +++ .../Services/PlanAnalyzer.Helpers.cs | 298 +++ .../Services/PlanAnalyzer.Node.cs | 789 ++++++ .../Services/PlanAnalyzer.Statement.cs | 450 ++++ .../Services/PlanAnalyzer.Timing.cs | 369 +++ src/PlanViewer.Core/Services/PlanAnalyzer.cs | 2129 +---------------- src/PlanViewer.Web/PlanViewer.Web.csproj | 5 + 7 files changed, 2184 insertions(+), 2126 deletions(-) create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs create mode 100644 src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs new file mode 100644 index 0000000..6b79bf4 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static bool HasBatchModeNode(PlanNode node) + { + var mode = node.ActualExecutionMode ?? node.ExecutionMode; + if (string.Equals(mode, "Batch", StringComparison.OrdinalIgnoreCase)) + return true; + foreach (var child in node.Children) + { + if (HasBatchModeNode(child)) + return true; + } + return false; + } + + private static void CheckForTableVariables(PlanNode node, bool isModification, + ref bool hasTableVar, ref bool modifiesTableVar) + { + if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) + { + hasTableVar = true; + // The modification target is typically an Insert/Update/Delete operator on a table variable + if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase))) + { + modifiesTableVar = true; + } + } + foreach (var child in node.Children) + CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar); + } + + /// + /// Detects the NOT IN with nullable column pattern: statement has NOT IN, + /// and a nearby Nested Loops Anti Semi Join has an IS NULL residual predicate. + /// Checks ancestors and their children (siblings of ancestors) since the IS NULL + /// predicate may be on a sibling Anti Semi Join rather than a direct parent. + /// + private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) + { + // Check statement text for NOT IN + if (string.IsNullOrEmpty(stmt.StatementText) || + !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) + return false; + + // Walk up the tree checking ancestors and their children + var parent = spoolNode.Parent; + while (parent != null) + { + if (IsAntiSemiJoinWithIsNull(parent)) + return true; + + // Check siblings: the IS NULL predicate may be on a sibling Anti Semi Join + // (e.g. outer NL Anti Semi Join has two children: inner NL Anti Semi Join + Row Count Spool) + foreach (var sibling in parent.Children) + { + if (sibling != spoolNode && IsAntiSemiJoinWithIsNull(sibling)) + return true; + } + + parent = parent.Parent; + } + + return false; + } + + private static bool IsAntiSemiJoinWithIsNull(PlanNode node) => + node.PhysicalOp == "Nested Loops" && + node.LogicalOp.Contains("Anti Semi", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(node.Predicate) && + node.Predicate.Contains("IS NULL", StringComparison.OrdinalIgnoreCase); + + /// + /// Returns true for rowstore scan operators (Index Scan, Clustered Index Scan, + /// Table Scan). Excludes columnstore scans, spools, and constant scans. + /// + private static bool IsRowstoreScan(PlanNode node) + { + return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Columnstore", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns true when the predicate contains ONLY PROBE() bitmap filter(s) + /// with no real residual predicate. PROBE alone is a bitmap filter pushed + /// down from a hash join — not interesting by itself. If a real predicate + /// exists alongside PROBE (e.g. "[col]=(1) AND PROBE(...)"), returns false. + /// + private static bool IsProbeOnly(string predicate) + { + // Strip all PROBE(...) expressions — PROBE args can contain nested parens + var stripped = Regex.Replace(predicate, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", + RegexOptions.IgnoreCase).Trim(); + + // Remove leftover AND/OR connectors and whitespace + stripped = Regex.Replace(stripped, @"\b(AND|OR)\b", "", RegexOptions.IgnoreCase).Trim(); + + // If nothing meaningful remains, it was PROBE-only + return stripped.Length == 0; + } + + /// + /// Strips PROBE(...) bitmap filter expressions from a predicate for display, + /// leaving only the real residual predicate columns. + /// + private static string StripProbeExpressions(string predicate) + { + var stripped = Regex.Replace(predicate, @"\s*AND\s+PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", + RegexOptions.IgnoreCase); + stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*AND\s+", "", + RegexOptions.IgnoreCase); + stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", + RegexOptions.IgnoreCase); + return stripped.Trim(); + } + + /// + /// Returns true for any scan operator including columnstore. + /// Excludes spools and constant scans. + /// + private static bool IsScanOperator(PlanNode node) + { + return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Detects non-SARGable patterns in scan predicates. + /// Returns a description of the issue, or null if the predicate is fine. + /// + private static string? DetectNonSargablePredicate(PlanNode node) + { + if (string.IsNullOrEmpty(node.Predicate)) + return null; + + // Only check rowstore scan operators — columnstore is designed to be scanned + if (!IsRowstoreScan(node)) + return null; + + var predicate = node.Predicate; + + // CASE expression in predicate — check first because CASE bodies + // often contain CONVERT_IMPLICIT that isn't the root cause + if (CaseInPredicateRegex.IsMatch(predicate)) + return "CASE expression in predicate"; + + // CONVERT_IMPLICIT — most common non-SARGable pattern + if (predicate.Contains("CONVERT_IMPLICIT", StringComparison.OrdinalIgnoreCase)) + return "Implicit conversion (CONVERT_IMPLICIT)"; + + // ISNULL / COALESCE wrapping column + if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) + return "ISNULL/COALESCE wrapping column"; + + // Common function calls on columns — but only if the function wraps a column, + // not a parameter/variable. Split on comparison operators to check which side + // the function is on. Predicate format: [db].[schema].[table].[col]>func(...) + var funcMatch = FunctionInPredicateRegex.Match(predicate); + if (funcMatch.Success) + { + var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); + if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch)) + return $"Function call ({funcName}) on column"; + } + + // Leading wildcard LIKE + if (LeadingWildcardLikeRegex.IsMatch(predicate)) + return "Leading wildcard LIKE pattern"; + + return null; + } + + /// + /// Checks whether a function call in a predicate is on the column side of the comparison. + /// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var]) + /// If the function is only on the parameter/literal side, it's still SARGable. + /// + private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch) + { + // Find the comparison operator that splits the predicate into left/right sides. + // Operators in ScalarString: >=, <=, <>, >, <, = + var compMatch = Regex.Match(predicate, @"(?])([<>=!]{1,2})(?![<>=])"); + if (!compMatch.Success) + return true; // No comparison found — can't determine side, assume worst case + + var compPos = compMatch.Index; + var funcPos = funcMatch.Index; + + // Determine which side the function is on + var funcSide = funcPos < compPos ? "left" : "right"; + + // Check if that side also contains a column reference [...].[...].[...] + string side = funcSide == "left" + ? predicate[..compPos] + : predicate[(compPos + compMatch.Length)..]; + + // Column references are multi-part bracket-qualified: [schema].[table].[column] + // Variables are [@var] or [@var] — single bracket pair with @ prefix. + // Match [identifier].[identifier] (at least two dotted parts) to distinguish columns. + return Regex.IsMatch(side, @"\[[^\]@]+\]\.\["); + } + + /// + /// Verifies the OR expansion chain walking up from a Concatenation node: + /// Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation + /// + private static bool IsOrExpansionChain(PlanNode concatenationNode) + { + // Walk up, skipping Compute Scalar + var parent = concatenationNode.Parent; + while (parent != null && parent.PhysicalOp == "Compute Scalar") + parent = parent.Parent; + + // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort") + if (parent == null || parent.LogicalOp != "Top N Sort") + return false; + + // Walk up to Merge Interval + parent = parent.Parent; + if (parent == null || parent.PhysicalOp != "Merge Interval") + return false; + + // Walk up to Nested Loops + parent = parent.Parent; + if (parent == null || parent.PhysicalOp != "Nested Loops") + return false; + + // If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN + // subquery pattern (Merge Interval optimizing range lookups), not an OR expansion + var nlParent = parent.Parent; + if (nlParent != null && nlParent.LogicalOp != null && + nlParent.LogicalOp.Contains("Semi")) + return false; + + return true; + } + + /// + /// Finds Sort and Hash Match operators in the tree that consume memory. + /// + /// + /// Returns true if the plan contains an adaptive join that executed as a Nested Loop. + /// Indicates a memory grant was sized for the hash alternative but never needed. + /// + private static bool HasAdaptiveJoinChoseNestedLoop(PlanNode node) + { + if (node.IsAdaptive && node.ActualJoinType != null + && node.ActualJoinType.Contains("Nested", StringComparison.OrdinalIgnoreCase)) + return true; + + foreach (var child in node.Children) + if (HasAdaptiveJoinChoseNestedLoop(child)) + return true; + + return false; + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs new file mode 100644 index 0000000..6bf7585 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void MarkLegacyWarnings(PlanStatement stmt) + { + foreach (var w in stmt.PlanWarnings) + { + if (LegacyWarningTypes.Contains(w.WarningType)) + w.IsLegacy = true; + } + if (stmt.RootNode != null) + MarkLegacyWarningsOnTree(stmt.RootNode); + } + + private static void MarkLegacyWarningsOnTree(PlanNode node) + { + foreach (var w in node.Warnings) + { + if (LegacyWarningTypes.Contains(w.WarningType)) + w.IsLegacy = true; + } + foreach (var child in node.Children) + MarkLegacyWarningsOnTree(child); + } + + private static void ApplySeverityOverrides(ParsedPlan plan, AnalyzerConfig cfg) + { + foreach (var batch in plan.Batches) + { + foreach (var stmt in batch.Statements) + { + foreach (var w in stmt.PlanWarnings) + TryOverrideSeverity(w, cfg); + + if (stmt.RootNode != null) + ApplyOverridesToTree(stmt.RootNode, cfg); + } + } + } + + private static void ApplyOverridesToTree(PlanNode node, AnalyzerConfig cfg) + { + foreach (var w in node.Warnings) + TryOverrideSeverity(w, cfg); + foreach (var child in node.Children) + ApplyOverridesToTree(child, cfg); + } + + private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg) + { + // Find the rule number for this warning type (partial match for flexibility) + int? ruleNumber = null; + foreach (var (rule, type) in RuleWarningTypes) + { + if (warning.WarningType.Contains(type, StringComparison.OrdinalIgnoreCase) || + type.Contains(warning.WarningType, StringComparison.OrdinalIgnoreCase)) + { + ruleNumber = rule; + break; + } + } + + if (ruleNumber == null) return; + + var overrideSeverity = cfg.GetSeverityOverride(ruleNumber.Value); + if (overrideSeverity == null) return; + + if (Enum.TryParse(overrideSeverity, ignoreCase: true, out var severity)) + warning.Severity = severity; + } + + /// Determines whether a row estimate mismatch actually caused observable harm. + /// Returns a description of the harm, or null if the bad estimate is benign. + /// + /// False-positive suppression (from reviewer feedback): + /// - Root node (no parent) — nothing above to be harmed by the bad estimate + /// - Sort that didn't spill — the estimate was wrong but no harm done + /// + /// Real harm: + /// - The node itself has a spill warning (bad estimate → bad memory grant) + /// - The node is a join (wrong join type or excessive inner side work) + /// - A parent join may have chosen the wrong strategy based on bad row count + /// - A parent Sort/Hash spilled (downstream estimate caused bad grant) + /// + /// + /// Returns a short label describing what a wait type means (e.g., "I/O — reading from disk"). + /// Public for use by UI components that annotate wait stats inline. + /// + public static string GetWaitLabel(string waitType) + { + var wt = waitType.ToUpperInvariant(); + return wt switch + { + _ when wt.StartsWith("PAGEIOLATCH") => "I/O — reading data from disk", + _ when wt.Contains("IO_COMPLETION") => "I/O — spills to TempDB or eager writes", + _ when wt == "SOS_SCHEDULER_YIELD" => "CPU — scheduler yielding", + _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "parallelism — thread skew", + _ when wt.StartsWith("CXSYNC") => "parallelism — exchange synchronization", + _ when wt == "HTBUILD" => "hash — building hash table", + _ when wt == "HTDELETE" => "hash — cleaning up hash table", + _ when wt == "HTREPARTITION" => "hash — repartitioning", + _ when wt.StartsWith("HT") => "hash operation", + _ when wt == "BPSORT" => "batch sort", + _ when wt == "BMPBUILD" => "bitmap filter build", + _ when wt.Contains("MEMORY_ALLOCATION_EXT") => "memory allocation", + _ when wt.StartsWith("PAGELATCH") => "page latch — in-memory contention", + _ when wt.StartsWith("LATCH_") => "latch contention", + _ when wt.StartsWith("LCK_") => "lock contention", + _ when wt == "LOGBUFFER" => "transaction log writes", + _ when wt == "ASYNC_NETWORK_IO" => "network — client not consuming results", + _ when wt == "SOS_PHYS_PAGE_CACHE" => "physical page cache contention", + _ => "" + }; + } + + /// + /// Returns true if the statement has significant I/O waits (PAGEIOLATCH_*, IO_COMPLETION). + /// Used for severity elevation decisions where I/O specifically indicates disk access. + /// Thresholds: I/O waits >= 20% of total wait time AND >= 100ms absolute. + /// + private static bool HasSignificantIoWaits(List waits) + { + if (waits.Count == 0) + return false; + + var totalMs = waits.Sum(w => w.WaitTimeMs); + if (totalMs == 0) + return false; + + long ioMs = 0; + foreach (var w in waits) + { + var wt = w.WaitType.ToUpperInvariant(); + if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION")) + ioMs += w.WaitTimeMs; + } + + var pct = (double)ioMs / totalMs * 100; + return ioMs >= 100 && pct >= 20; + } + + private static bool AllocatesResources(PlanNode node) + { + // Operators that get memory grants or allocate structures based on row estimates. + // Hash Match (hash table), Sort (sort buffer), Spool (worktable). + var op = node.PhysicalOp; + return op.StartsWith("Hash", StringComparison.OrdinalIgnoreCase) + || op.StartsWith("Sort", StringComparison.OrdinalIgnoreCase) + || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); + } + + private static string? AssessEstimateHarm(PlanNode node, double ratio) + { + // Root node: no parent to harm. + // The synthetic statement root (SELECT/INSERT/etc.) has NodeId == -1. + if (node.Parent == null || node.Parent.NodeId == -1) + return null; + + // The node itself has a spill — bad estimate caused bad memory grant + if (HasSpillWarning(node)) + { + return ratio >= 10.0 + ? "The underestimate likely caused an insufficient memory grant, leading to a spill to TempDB." + : "The overestimate may have caused an excessive memory grant, wasting workspace memory."; + } + + // Sort/Hash that did NOT spill — estimate was wrong but no observable harm + if ((node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || + node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) && + !HasSpillWarning(node)) + { + return null; + } + + // The node is a join — bad estimate means wrong join type or excessive work + // Adaptive joins (2017+) switch strategy at runtime, so the estimate didn't lock in a bad choice. + if (node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && !node.IsAdaptive) + { + return ratio >= 10.0 + ? "The underestimate may have caused the optimizer to make poor choices." + : "The overestimate may have caused the optimizer to make poor choices."; + } + + // Walk up to check if a parent was harmed by this bad estimate + var ancestor = node.Parent; + while (ancestor != null) + { + // Transparent operators — skip through + if (ancestor.PhysicalOp == "Parallelism" || + ancestor.PhysicalOp == "Compute Scalar" || + ancestor.PhysicalOp == "Segment" || + ancestor.PhysicalOp == "Sequence Project" || + ancestor.PhysicalOp == "Top" || + ancestor.PhysicalOp == "Filter") + { + ancestor = ancestor.Parent; + continue; + } + + // Parent join — bad row count from below caused wrong join choice + // Adaptive joins handle this at runtime, so skip them. + if (ancestor.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase)) + { + if (ancestor.IsAdaptive) + return null; // Adaptive join self-corrects — no harm + + return ratio >= 10.0 + ? $"The underestimate may have caused the optimizer to make poor choices." + : $"The overestimate may have caused the optimizer to make poor choices."; + } + + // Parent Sort/Hash that spilled — downstream bad estimate caused the spill + if (HasSpillWarning(ancestor)) + { + return ratio >= 10.0 + ? $"The underestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) spilling to TempDB." + : $"The overestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) receiving an excessive memory grant."; + } + + // Parent Sort/Hash with no spill — benign + if (ancestor.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || + ancestor.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Any other operator — stop walking + break; + } + + // Default: the estimate is off but we can't identify specific harm + return null; + } + + /// + /// Checks if a node has any spill-related warnings (Sort/Hash/Exchange spills). + /// + private static bool HasSpillWarning(PlanNode node) + { + return node.Warnings.Any(w => w.SpillDetails != null); + } + + /// + /// Formats a node reference for use in warning messages. Includes object name + /// for data access operators where it helps identify which table is involved. + /// + private static string FormatNodeRef(PlanNode node) + { + if (!string.IsNullOrEmpty(node.ObjectName)) + { + var objRef = !string.IsNullOrEmpty(node.DatabaseName) + ? $"{node.DatabaseName}.{node.ObjectName}" + : node.ObjectName; + return $"{node.PhysicalOp} on {objRef} (Node {node.NodeId})"; + } + + return $"{node.PhysicalOp} (Node {node.NodeId})"; + } + + /// + /// Identifies the specific cause of a row goal from the statement text. + /// Returns a specific cause when detectable, or a generic list as fallback. + /// + private static string IdentifyRowGoalCause(string stmtText) + { + if (string.IsNullOrEmpty(stmtText)) + return "TOP, EXISTS, IN, or FAST hint"; + + var text = stmtText.ToUpperInvariant(); + var causes = new List(4); + + if (Regex.IsMatch(text, @"\bTOP\b")) + causes.Add("TOP"); + if (Regex.IsMatch(text, @"\bEXISTS\b")) + causes.Add("EXISTS"); + // IN with subquery — bare "IN (" followed by SELECT, not just "IN (1,2,3)" + if (Regex.IsMatch(text, @"\bIN\s*\(\s*SELECT\b")) + causes.Add("IN (subquery)"); + if (Regex.IsMatch(text, @"\bFAST\b")) + causes.Add("FAST hint"); + + return causes.Count > 0 + ? string.Join(", ", causes) + : "TOP, EXISTS, IN, or FAST hint"; + } + + private static string Truncate(string value, int maxLength) + { + return value.Length <= maxLength ? value : value[..maxLength] + "..."; + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs new file mode 100644 index 0000000..0b3ef69 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -0,0 +1,789 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { + AnalyzeNode(node, stmt, cfg); + + foreach (var child in node.Children) + AnalyzeNodeTree(child, stmt, cfg); + } + + private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { + // Rule 1: Filter operators — rows survived the tree just to be discarded + // Quantify the impact by summing child subtree cost (reads, CPU, time). + // Suppress when the filter's child subtree is trivial (low I/O, fast, cheap). + if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate) + && node.Children.Count > 0) + { + // Gate: skip trivial filters based on actual stats or estimated cost + bool isTrivial; + if (node.HasActualStats) + { + long childReads = 0; + foreach (var child in node.Children) + childReads += SumSubtreeReads(child); + var childElapsed = node.Children.Max(c => c.ActualElapsedMs); + isTrivial = childReads < 128 && childElapsed < 10; + } + else + { + var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); + isTrivial = childCost < 1.0; + } + + if (!isTrivial) + { + var impact = QuantifyFilterImpact(node); + var predicate = Truncate(node.Predicate, 200); + var message = "Filter operator discarding rows late in the plan."; + if (!string.IsNullOrEmpty(impact)) + message += $"\n{impact}"; + message += $"\nPredicate: {predicate}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Filter Operator", + Message = message, + Severity = PlanWarningSeverity.Warning + }); + } + } + + // Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly + if (!cfg.IsRuleDisabled(2) && node.LogicalOp == "Eager Spool" && + node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase)) + { + var message = "SQL Server is building a temporary index in TempDB at runtime because no suitable permanent index exists. This is expensive — it builds the index from scratch on every execution. Create a permanent index on the underlying table to eliminate this operator entirely."; + if (!string.IsNullOrEmpty(node.SuggestedIndex)) + message += $"\n\nCreate this index:\n{node.SuggestedIndex}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Eager Index Spool", + Message = message, + Severity = PlanWarningSeverity.Critical + }); + } + + // Rule 4: UDF timing — any node spending time in UDFs (actual plans) + if (!cfg.IsRuleDisabled(4) && (node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "UDF Execution", + Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", + Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Rule 5: Large estimate vs actual row gaps (actual plans only) + // Only warn when the bad estimate actually causes observable harm: + // - The node itself spilled (Sort/Hash with bad memory grant) + // - A parent join may have chosen the wrong strategy + // - Root nodes with no parent to harm are skipped + // - Nodes whose only parents are Parallelism/Top/Sort (no spill) are skipped + if (!cfg.IsRuleDisabled(5) && node.HasActualStats && node.EstimateRows > 0 + && !node.Lookup) // Key lookups are point lookups (1 row per execution) — per-execution estimate is misleading + { + if (node.ActualRows == 0) + { + // Zero rows with a significant estimate — only warn on operators that + // actually allocate meaningful resources (memory grants for hash/sort/spool). + // Skip Parallelism, Bitmap, Compute Scalar, Filter, Concatenation, etc. + // where 0 rows is just a consequence of upstream filtering. + if (node.EstimateRows >= 100 && AllocatesResources(node)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Row Estimate Mismatch", + Message = $"Estimated {node.EstimateRows:N0} rows but actual 0 rows returned. SQL Server allocated resources for rows that never materialized.", + Severity = PlanWarningSeverity.Warning + }); + } + } + else + { + // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) + var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; + var actualPerExec = (double)node.ActualRows / executions; + var ratio = actualPerExec / node.EstimateRows; + if (ratio >= 10.0 || ratio <= 0.1) + { + var harm = AssessEstimateHarm(node, ratio); + if (harm != null) + { + var direction = ratio >= 10.0 ? "underestimated" : "overestimated"; + var factor = ratio >= 10.0 ? ratio : 1.0 / ratio; + var actualDisplay = executions > 1 + ? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)" + : $"Actual {node.ActualRows:N0}"; + node.Warnings.Add(new PlanWarning + { + WarningType = "Row Estimate Mismatch", + Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}", + Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + } + } + + // Rule 6: Scalar UDF references (works on estimated plans too) + // Suppress when Serial Plan warning is already firing for a UDF-related reason — + // the Serial Plan warning already explains the issue, this would be redundant. + var serialPlanCoversUdf = stmt.NonParallelPlanReason is + "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "CouldNotGenerateValidParallelPlan"; + if (!cfg.IsRuleDisabled(6) && !serialPlanCoversUdf) + foreach (var udf in node.ScalarUdfs) + { + var type = udf.IsClrFunction ? "CLR" : "T-SQL"; + node.Warnings.Add(new PlanWarning + { + WarningType = "Scalar UDF", + Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 7: Spill detection — calculate operator time and set severity + // based on what percentage of statement elapsed time the spill accounts for. + // Exchange spills on Parallelism operators get special handling since their + // timing is unreliable but the write count tells the story. + if (!cfg.IsRuleDisabled(7)) + foreach (var w in node.Warnings.ToList()) + { + if (w.SpillDetails == null) + continue; + + var isExchangeSpill = w.SpillDetails.SpillType == "Exchange"; + + if (isExchangeSpill) + { + // Exchange spills: severity based on write count since timing is unreliable + var writes = w.SpillDetails.WritesToTempDb; + if (writes >= 1_000_000) + w.Severity = PlanWarningSeverity.Critical; + else if (writes >= 10_000) + w.Severity = PlanWarningSeverity.Warning; + + // Surface Parallelism operator time when available (actual plans) + if (node.ActualElapsedMs > 0) + { + var operatorMs = GetParallelismOperatorElapsedMs(node); + var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; + if (stmtMs > 0 && operatorMs > 0) + { + var pct = (double)operatorMs / stmtMs; + w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; + } + } + } + else if (node.ActualElapsedMs > 0) + { + // Sort/Hash spills: severity based on operator time percentage + var operatorMs = GetOperatorOwnElapsedMs(node); + var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; + + if (stmtMs > 0) + { + var pct = (double)operatorMs / stmtMs; + w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; + + if (pct >= 0.5) + w.Severity = PlanWarningSeverity.Critical; + else if (pct >= 0.1) + w.Severity = PlanWarningSeverity.Warning; + } + } + } + + // Rule 8: Parallel thread skew (actual plans with per-thread stats) + // Only warn when there are enough rows to meaningfully distribute across threads + // Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators + if (!cfg.IsRuleDisabled(8) && node.PerThreadStats.Count > 1) + { + var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList(); + if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback + var totalRows = workerThreads.Sum(t => t.ActualRows); + var minRowsForSkew = workerThreads.Count * 1000; + if (totalRows >= minRowsForSkew) + { + var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First(); + var skewRatio = (double)maxThread.ActualRows / totalRows; + // At DOP 2, a 60/40 split is normal — use higher threshold + var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50; + if (skewRatio >= skewThreshold) + { + var message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much."; + var severity = PlanWarningSeverity.Warning; + + // Batch mode sorts produce all output on a single thread by design + // unless their parent is a batch mode Window Aggregate + if (node.PhysicalOp == "Sort" + && (node.ActualExecutionMode ?? node.ExecutionMode) == "Batch" + && node.Parent?.PhysicalOp != "Window Aggregate") + { + message += " Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate."; + severity = PlanWarningSeverity.Info; + } + else + { + // Add practical context — skew is often hard to fix + message += " Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help."; + } + + node.Warnings.Add(new PlanWarning + { + WarningType = "Parallel Skew", + Message = message, + Severity = severity + }); + } + } + } + + // Rule 10: Key Lookup / RID Lookup with residual predicate + // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true + if (!cfg.IsRuleDisabled(10) && node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) + { + var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; + if (!string.IsNullOrEmpty(node.Predicate)) + message += $" Predicate: {Truncate(node.Predicate, 200)}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "RID Lookup", + Message = message, + Severity = PlanWarningSeverity.Warning + }); + } + else if (!cfg.IsRuleDisabled(10) && node.Lookup) + { + var lookupMsg = "Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns."; + + // Show what columns the lookup is fetching + if (!string.IsNullOrEmpty(node.OutputColumns)) + lookupMsg += $"\nColumns fetched: {Truncate(node.OutputColumns, 200)}"; + + // Only call out the predicate if it actually filters rows + if (!string.IsNullOrEmpty(node.Predicate)) + { + var predicateFilters = node.HasActualStats && node.ActualExecutions > 0 + && node.ActualRows < node.ActualExecutions; + if (predicateFilters) + lookupMsg += $"\nResidual predicate (filtered {node.ActualExecutions - node.ActualRows:N0} rows): {Truncate(node.Predicate, 200)}"; + } + + lookupMsg += "\nTo eliminate the lookup, consider adding the needed columns as INCLUDE columns on the nonclustered index. This widens the index, so weigh the read benefit against write and storage overhead."; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Key Lookup", + Message = lookupMsg, + Severity = PlanWarningSeverity.Critical + }); + } + + // Rule 12: Non-SARGable predicate on scan + // Skip for 0-execution nodes — the operator never ran, so the warning is academic + var nonSargableReason = cfg.IsRuleDisabled(12) || (node.HasActualStats && node.ActualExecutions == 0) + ? null : DetectNonSargablePredicate(node); + if (nonSargableReason != null) + { + var nonSargableAdvice = nonSargableReason switch + { + "Implicit conversion (CONVERT_IMPLICIT)" => + "Implicit conversion (CONVERT_IMPLICIT) prevents an index seek. Match the parameter or variable data type to the column data type.", + "ISNULL/COALESCE wrapping column" => + "ISNULL/COALESCE wrapping a column prevents an index seek. Rewrite the predicate to avoid wrapping the column, e.g. use \"WHERE col = @val OR col IS NULL\" instead of \"WHERE ISNULL(col, '') = @val\".", + "Leading wildcard LIKE pattern" => + "Leading wildcard LIKE prevents an index seek — SQL Server must scan every row. If substring search performance is critical, consider a full-text index or a trigram-based approach.", + "CASE expression in predicate" => + "CASE expression in a predicate prevents an index seek. Rewrite using separate WHERE clauses combined with OR, or split into multiple queries.", + _ when nonSargableReason.StartsWith("Function call") => + $"{nonSargableReason} prevents an index seek. Remove the function from the column side — apply it to the parameter instead, or create a computed column with the expression and index that.", + _ => + $"{nonSargableReason} prevents an index seek, forcing a scan." + }; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Non-SARGable Predicate", + Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 11: Scan with residual predicate (skip if non-SARGable already flagged) + // A PROBE() alone is just a bitmap filter — not a real residual predicate. + // Skip for 0-execution nodes — the operator never ran + if (!cfg.IsRuleDisabled(11) && nonSargableReason == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate) && + !IsProbeOnly(node.Predicate) && !(node.HasActualStats && node.ActualExecutions == 0)) + { + var displayPredicate = StripProbeExpressions(node.Predicate); + var details = BuildScanImpactDetails(node, stmt); + var severity = PlanWarningSeverity.Warning; + + // Elevate to Critical if the scan dominates the plan + if (details.CostPct >= 90 || details.ElapsedPct >= 90) + severity = PlanWarningSeverity.Critical; + + var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; + if (!string.IsNullOrEmpty(details.Summary)) + message += $" {details.Summary}"; + + // #215 E2: if the statement is executing a dynamic cursor, that's usually + // the reason an index didn't get used. Call it out so the user looks there + // first rather than hunting for a missing index. + var isDynamicCursor = string.Equals(stmt.CursorActualType, "Dynamic", + StringComparison.OrdinalIgnoreCase); + if (isDynamicCursor) + message += " This query is running inside a dynamic cursor, which can prevent index usage; changing the cursor type (FAST_FORWARD / STATIC / KEYSET) often fixes scans like this without any indexing change."; + else + message += " Check that you have appropriate indexes."; + + // I/O waits specifically confirm the scan is hitting disk — elevate + if (HasSignificantIoWaits(stmt.WaitStats) && details.CostPct >= 50 + && severity != PlanWarningSeverity.Critical) + severity = PlanWarningSeverity.Critical; + + message += $"\nPredicate: {Truncate(displayPredicate, 200)}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Scan With Predicate", + Message = message, + Severity = severity + }); + } + + // Rule 32: Cardinality misestimate on expensive scan — likely preventing index usage + // When a scan dominates the plan AND the estimate is vastly higher than actual rows, + // the optimizer chose a scan because it thought it needed most of the table. + // With accurate estimates, it would likely seek instead. + if (!cfg.IsRuleDisabled(32) && node.HasActualStats && IsRowstoreScan(node) + && node.EstimateRows > 0 && node.ActualRows >= 0 && node.ActualRowsRead > 0) + { + var impact = BuildScanImpactDetails(node, stmt); + var overestimateRatio = node.EstimateRows / Math.Max(1.0, node.ActualRows); + var selectivity = (double)node.ActualRows / node.ActualRowsRead; + + // Fire when: scan is >= 50% of plan, estimate is >= 10x actual, and < 10% selectivity + if ((impact.CostPct >= 50 || impact.ElapsedPct >= 50) + && overestimateRatio >= 10.0 + && selectivity < 0.10) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Scan Cardinality Misestimate", + Message = $"Estimated {node.EstimateRows:N0} rows but only {node.ActualRows:N0} returned ({selectivity * 100:N3}% of {node.ActualRowsRead:N0} rows read). " + + $"The {overestimateRatio:N0}x overestimate likely caused the optimizer to choose a scan instead of a seek. " + + $"An index on the predicate columns could dramatically reduce I/O.", + Severity = PlanWarningSeverity.Critical + }); + } + } + + // Rule 33: Estimated plan CE guess detection — scans with telltale default selectivity + // When the optimizer uses a local variable or can't sniff, it falls back to density-based + // guesses: 30% (equality), 10% (inequality), 9% (LIKE/between), ~16.43% (sqrt(30%)), + // 1% (multi-inequality). On large tables, these guesses can hide the need for an index. + if (!cfg.IsRuleDisabled(33) && !node.HasActualStats && IsRowstoreScan(node) + && node.TableCardinality >= 100_000 && node.EstimateRows > 0 + && !string.IsNullOrEmpty(node.Predicate)) + { + var impact = BuildScanImpactDetails(node, stmt); + if (impact.CostPct >= 50) + { + var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality); + if (guessDesc != null) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Estimated Plan CE Guess", + Message = $"Estimated {node.EstimateRows:N0} rows from {node.TableCardinality:N0} row table — {guessDesc}. " + + $"The optimizer may be using a default guess instead of accurate statistics. " + + $"If actual selectivity is much lower, an index on the predicate columns could help significantly.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 34: Bare scan with narrow output — NC index or columnstore candidate. + // When a Clustered Index Scan or heap Table Scan reads the full table with no + // predicate but only outputs a few columns, a narrower nonclustered index could + // cover the query with far less I/O. For analytical workloads, columnstore may + // be a better fit. + var isBareScanCandidate = (node.PhysicalOp == "Clustered Index Scan" || node.PhysicalOp == "Table Scan") + && !node.Lookup + && string.IsNullOrEmpty(node.Predicate) + && !string.IsNullOrEmpty(node.OutputColumns); + if (!cfg.IsRuleDisabled(34) && isBareScanCandidate) + { + var colCount = node.OutputColumns!.Split(',').Length; + var isSignificant = node.HasActualStats + ? GetOperatorOwnElapsedMs(node) > 0 + : node.CostPercent >= 20; + + if (isSignificant) + { + var scanKind = node.PhysicalOp == "Clustered Index Scan" + ? "Clustered index scan" + : "Heap table scan"; + + if (colCount <= 3) + { + // Narrow output: a nonclustered rowstore index can cover this cheaply. + var indexAdvice = node.PhysicalOp == "Clustered Index Scan" + ? "Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure." + : "Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure."; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Bare Scan", + Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} column(s): {Truncate(node.OutputColumns, 200)}. {indexAdvice} For analytical workloads, a columnstore index may be a better fit.", + Severity = PlanWarningSeverity.Warning + }); + } + else + { + // Wider output: rowstore NC index isn't a great fit (would have to + // carry too many columns), but columnstore doesn't care about column + // count. Suggest it for analytical / aggregate-style workloads. + node.Warnings.Add(new PlanWarning + { + WarningType = "Bare Scan", + Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} columns. A nonclustered rowstore index isn't a great fit for wide outputs, but if this is an analytical or aggregate-style query, a columnstore index (CCI or NCCI) can scan the same data far more cheaply — column count doesn't penalize columnstore the way it does rowstore indexes.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert) + if (!cfg.IsRuleDisabled(13) && node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues)) + { + var hasMismatch = node.DefinedValues.Contains("GetRangeWithMismatchedTypes", StringComparison.OrdinalIgnoreCase); + var hasConvert = node.DefinedValues.Contains("GetRangeThroughConvert", StringComparison.OrdinalIgnoreCase); + + if (hasMismatch || hasConvert) + { + var reason = hasMismatch + ? "Mismatched data types between the column and the parameter/literal. SQL Server is converting every row to compare, preventing index seeks. Match your data types — don't pass nvarchar to a varchar column, or int to a bigint column." + : "CONVERT/CAST wrapping a column in the predicate. SQL Server is converting every row to compare, preventing index seeks. Match your data types — convert the parameter/literal instead of the column."; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Data Type Mismatch", + Message = reason, + Severity = PlanWarningSeverity.Warning + }); + } + } + + // Rule 14: Lazy Table Spool unfavorable rebind/rewind ratio + // Rebinds = cache misses (child re-executes), rewinds = cache hits (reuse cached result) + // Exclude Lazy Index Spools: they cache by correlated parameter value (like a hash table) + // so rebind/rewind counts are unreliable. See https://www.sql.kiwi/2025/02/lazy-index-spool/ + if (!cfg.IsRuleDisabled(14) && node.LogicalOp == "Lazy Spool" + && !node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase)) + { + var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds; + var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; + var source = node.HasActualStats ? "actual" : "estimated"; + + if (rebinds > 100 && rewinds < rebinds * 5) + { + var severity = rewinds < rebinds + ? PlanWarningSeverity.Critical + : PlanWarningSeverity.Warning; + + var ratio = rewinds > 0 + ? $"{rewinds / rebinds:F1}x rewinds (cache hits) per rebind (cache miss)" + : "no rewinds (cache hits) at all"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Lazy Spool Ineffective", + Message = $"Lazy spool has low cache hit ratio ({source}): {rebinds:N0} rebinds (cache misses), {rewinds:N0} rewinds (cache hits) — {ratio}. The spool is caching results but rarely reusing them, adding overhead for no benefit.", + Severity = severity + }); + } + } + + // Rule 15: Join OR clause + // Pattern: Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation → [Compute Scalar] → 2+ Constant Scans + if (!cfg.IsRuleDisabled(15) && node.PhysicalOp == "Concatenation") + { + var constantScanBranches = node.Children + .Count(c => c.PhysicalOp == "Constant Scan" || + (c.PhysicalOp == "Compute Scalar" && + c.Children.Any(gc => gc.PhysicalOp == "Constant Scan"))); + + if (constantScanBranches >= 2 && IsOrExpansionChain(node)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Join OR Clause", + Message = $"OR in a join predicate. SQL Server rewrote the OR as {constantScanBranches} separate lookups, each evaluated independently — this multiplies the work on the inner side. Rewrite as separate queries joined with UNION ALL. For example, change \"FROM a JOIN b ON a.x = b.x OR a.y = b.y\" to \"FROM a JOIN b ON a.x = b.x UNION ALL FROM a JOIN b ON a.y = b.y\".", + Severity = PlanWarningSeverity.Warning + }); + } + } + + // Rule 16: Nested Loops high inner-side execution count + // Deep analysis: combine execution count + outer estimate mismatch + inner cost + if (!cfg.IsRuleDisabled(16) && node.PhysicalOp == "Nested Loops" && + node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && + !node.IsAdaptive && + node.Children.Count >= 2) + { + var outerChild = node.Children[0]; + var innerChild = node.Children[1]; + + if (innerChild.HasActualStats && innerChild.ActualExecutions > 100000) + { + var dop = stmt.DegreeOfParallelism > 0 ? stmt.DegreeOfParallelism : 1; + var details = new List(); + + // Core fact + details.Add($"Nested Loops inner side executed {innerChild.ActualExecutions:N0} times (DOP {dop})."); + + // Outer side estimate mismatch — explains WHY the optimizer chose NL + if (outerChild.HasActualStats && outerChild.EstimateRows > 0) + { + var outerExecs = outerChild.ActualExecutions > 0 ? outerChild.ActualExecutions : 1; + var outerActualPerExec = (double)outerChild.ActualRows / outerExecs; + var outerRatio = outerActualPerExec / outerChild.EstimateRows; + if (outerRatio >= 10.0) + { + details.Add($"Outer side: estimated {outerChild.EstimateRows:N0} rows, actual {outerActualPerExec:N0} ({outerRatio:F0}x underestimate). The optimizer chose Nested Loops expecting far fewer iterations."); + } + } + + // Inner side cost — reads and time spent doing the repeated work + long innerReads = SumSubtreeReads(innerChild); + if (innerReads > 0) + details.Add($"Inner side total: {innerReads:N0} logical reads."); + + if (innerChild.ActualElapsedMs > 0) + { + var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; + if (stmtMs > 0) + { + var pct = (double)innerChild.ActualElapsedMs / stmtMs * 100; + details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms ({pct:N0}% of statement)."); + } + else + { + details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms."); + } + } + + // Cause/recommendation + var hasParams = stmt.Parameters.Count > 0; + if (hasParams) + details.Add("This may be caused by parameter sniffing — the optimizer chose Nested Loops based on a sniffed value that produced far fewer outer rows."); + else + details.Add("Consider whether a hash or merge join would be more appropriate for this row count."); + + node.Warnings.Add(new PlanWarning + { + WarningType = "Nested Loops High Executions", + Message = string.Join(" ", details), + Severity = innerChild.ActualExecutions > 1000000 + ? PlanWarningSeverity.Critical + : PlanWarningSeverity.Warning + }); + } + // Estimated plans: the optimizer knew the row count and chose Nested Loops + // deliberately — don't second-guess it without actual execution data. + } + + // Rule 17: Many-to-many Merge Join + // In actual plans, the Merge Join operator reports logical reads when the worktable is used. + // When ActualLogicalReads is 0, the worktable wasn't hit and the warning is noise. + if (!cfg.IsRuleDisabled(17) && node.ManyToMany && node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase) && + (!node.HasActualStats || node.ActualLogicalReads > 0)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Many-to-Many Merge Join", + Message = node.HasActualStats + ? $"Many-to-many Merge Join — SQL Server created a worktable in TempDB ({node.ActualLogicalReads:N0} logical reads) because both sides have duplicate values in the join columns." + : "Many-to-many Merge Join — SQL Server will create a worktable in TempDB because both sides have duplicate values in the join columns.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 22: Table variables (Object name starts with @) + if (!cfg.IsRuleDisabled(22) && !string.IsNullOrEmpty(node.ObjectName) && + node.ObjectName.StartsWith("@")) + { + var isModificationOp = node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase); + + node.Warnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = isModificationOp + ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution." + : "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", + Severity = isModificationOp ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Rule 23: Table-valued functions + if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function") + { + var funcName = node.ObjectName ?? node.PhysicalOp; + node.Warnings.Add(new PlanWarning + { + WarningType = "Table-Valued Function", + Message = $"Table-valued function: {funcName}. Multi-statement TVFs have no statistics — SQL Server guesses 1 row (pre-2017) or 100 rows (2017+) regardless of actual size. Rewrite as an inline table-valued function if possible, or dump the function results into a #temp table and join to that instead.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 24: Top above a scan + // Detects Top or Top N Sort operators feeding from a scan. This often means the + // query is scanning the entire table/index and sorting just to return a few rows, + // when an appropriate index could satisfy the request directly. + if (!cfg.IsRuleDisabled(24)) + { + var isTop = node.PhysicalOp == "Top"; + var isTopNSort = node.LogicalOp == "Top N Sort"; + + if ((isTop || isTopNSort) && node.Children.Count > 0) + { + // Walk through pass-through operators below the Top to find the scan + var scanCandidate = node.Children[0]; + while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism") + && scanCandidate.Children.Count > 0) + scanCandidate = scanCandidate.Children[0]; + + if (IsScanOperator(scanCandidate)) + { + var topLabel = isTopNSort ? "Top N Sort" : "Top"; + var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2 + && node.Parent.Children[1] == node; + var innerNote = onInner + ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row." + : ""; + var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate) + ? " The scan has a residual predicate, so it may read many rows before the Top is satisfied." + : ""; + node.Warnings.Add(new PlanWarning + { + WarningType = "Top Above Scan", + Message = $"{topLabel} reads from {FormatNodeRef(scanCandidate)}.{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.", + Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 26: Row Goal (informational) — optimizer reduced estimate due to TOP/EXISTS/IN + // Only surface on data access operators (seeks/scans) where the row goal actually matters + var isDataAccess = node.PhysicalOp != null && + (node.PhysicalOp.Contains("Scan") || node.PhysicalOp.Contains("Seek")); + if (!cfg.IsRuleDisabled(26) && isDataAccess && + node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 && + node.EstimateRowsWithoutRowGoal > node.EstimateRows) + { + var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows; + // Require at least a 2x reduction to be worth mentioning — "1 to 1" or + // tiny floating-point differences that display identically are noise + if (reduction >= 2.0) + { + // If we have actual stats, check whether the row goal prediction was correct. + // When actual rows ≤ the row goal estimate, the optimizer stopped early as planned — benign. + var rowGoalWorked = false; + if (node.HasActualStats) + { + var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; + var actualPerExec = (double)node.ActualRows / executions; + rowGoalWorked = actualPerExec <= node.EstimateRows; + } + + if (!rowGoalWorked) + { + // Try to identify the specific row goal cause from the statement text + var cause = IdentifyRowGoalCause(stmt.StatementText); + + node.Warnings.Add(new PlanWarning + { + WarningType = "Row Goal", + Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to {cause}. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.", + Severity = PlanWarningSeverity.Info + }); + } + } + } + + // Rule 28: Row Count Spool — NOT IN with nullable column + // Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate, + // and statement text contains NOT IN + if (!cfg.IsRuleDisabled(28) && node.PhysicalOp?.Contains("Row Count Spool") == true) + { + var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; + if (rewinds > 10000 && HasNotInPattern(node, stmt)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "NOT IN with Nullable Column", + Message = $"Row Count Spool with {rewinds:N0} rewinds. This pattern occurs when NOT IN is used with a nullable column — SQL Server cannot use an efficient Anti Semi Join because it must check for NULL values on every outer row. Rewrite as NOT EXISTS, or add WHERE column IS NOT NULL to the subquery.", + Severity = rewinds > 1_000_000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + + // Rule 29: Enhance implicit conversion warnings — Seek Plan is more severe + // Skip for 0-execution nodes — the operator never ran + if (!cfg.IsRuleDisabled(29) && !(node.HasActualStats && node.ActualExecutions == 0)) + foreach (var w in node.Warnings.ToList()) + { + if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) + { + w.Severity = PlanWarningSeverity.Critical; + w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}"; + } + } + + // Rule 35: Expensive Operator — always show operators that take a significant + // share of statement time even when no other rule has something to say. Joe + // (#215 C8) wanted expensive scans that the tool had nothing to suggest on + // to still surface as top items. Threshold: self-time >= 20% of statement + // elapsed. Only emits if no other warning is already on the node to avoid + // doubling up. The benefit % is just the self-time share. + if (!cfg.IsRuleDisabled(35) && node.HasActualStats && node.Warnings.Count == 0 + && stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) + { + var selfMs = GetOperatorOwnElapsedMs(node); + var pct = (double)selfMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; + if (pct >= 20.0) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Expensive Operator", + Message = $"{node.PhysicalOp} took {selfMs:N0}ms ({pct:N1}% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be?", + Severity = pct >= 50 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning, + MaxBenefitPercent = Math.Round(Math.Min(100.0, pct), 1) + }); + } + } + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs new file mode 100644 index 0000000..2c0613f --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -0,0 +1,450 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata = null) + { + // Rule 3: Serial plan with reason + // Skip: cost < 1 (CTFP is an integer so cost < 1 can never go parallel), + // TRIVIAL optimization (can't go parallel anyway), + // and 0ms actual elapsed time (not worth flagging). + if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason) + && stmt.StatementSubTreeCost >= 1.0 + && stmt.StatementOptmLevel != "TRIVIAL" + && !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0)) + { + var reason = stmt.NonParallelPlanReason switch + { + // User/config forced serial + "MaxDOPSetToOne" => "MAXDOP is set to 1", + "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "ParallelismDisabledByTraceFlag" => "Parallelism disabled by trace flag", + + // Passive — optimizer chose serial, nothing wrong + "EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)", + + // Edition/environment limitations + "NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism", + "NoParallelCreateIndexInNonEnterpriseEdition" => "Parallel index creation requires Enterprise edition", + "NoParallelPlansDuringUpgrade" => "Parallel plans disabled during upgrade", + "NoParallelForPDWCompilation" => "Parallel plans not supported for PDW compilation", + "NoParallelForCloudDBReplication" => "Parallel plans not supported during cloud DB replication", + + // Query constructs that block parallelism (actionable) + "CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints", + "TSQLUserDefinedFunctionsNotParallelizable" => "T-SQL scalar UDF prevents parallelism. Rewrite as an inline table-valued function, or on SQL Server 2019+ check if the UDF is eligible for automatic inlining", + "CLRUserDefinedFunctionRequiresDataAccess" => "CLR UDF with data access prevents parallelism", + "NonParallelizableIntrinsicFunction" => "Non-parallelizable intrinsic function in the query", + "TableVariableTransactionsDoNotSupportParallelNestedTransaction" => "Table variable transaction prevents parallelism. Consider using a #temp table instead", + "UpdatingWritebackVariable" => "Updating a writeback variable prevents parallelism", + "DMLQueryReturnsOutputToClient" => "DML with OUTPUT clause returning results to client prevents parallelism", + "MixedSerialAndParallelOnlineIndexBuildNotSupported" => "Mixed serial/parallel online index build not supported", + "NoRangesResumableCreate" => "Resumable index create cannot use parallelism for this operation", + + // Cursor limitations + "NoParallelCursorFetchByBookmark" => "Cursor fetch by bookmark cannot use parallelism", + "NoParallelDynamicCursor" => "Dynamic cursors cannot use parallelism", + "NoParallelFastForwardCursor" => "Fast-forward cursors cannot use parallelism", + + // Memory-optimized / natively compiled + "NoParallelForMemoryOptimizedTables" => "Memory-optimized tables do not support parallel plans", + "NoParallelForDmlOnMemoryOptimizedTable" => "DML on memory-optimized tables cannot use parallelism", + "NoParallelForNativelyCompiledModule" => "Natively compiled modules do not support parallelism", + + // Remote queries + "NoParallelWithRemoteQuery" => "Remote queries cannot use parallelism", + "NoRemoteParallelismForMatrix" => "Remote parallelism not available for this query shape", + + _ => stmt.NonParallelPlanReason + }; + + // Actionable: user forced serial, or something in the query blocks parallelism + // that could potentially be rewritten. Info: passive (cost too low) or + // environmental (edition, upgrade, cursor type, memory-optimized). + var isActionable = stmt.NonParallelPlanReason is + "MaxDOPSetToOne" or "QueryHintNoParallelSet" or "ParallelismDisabledByTraceFlag" + or "CouldNotGenerateValidParallelPlan" + or "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "NonParallelizableIntrinsicFunction" + or "TableVariableTransactionsDoNotSupportParallelNestedTransaction" + or "UpdatingWritebackVariable" + or "DMLQueryReturnsOutputToClient" + or "NoParallelCursorFetchByBookmark" + or "NoParallelDynamicCursor" + or "NoParallelFastForwardCursor" + or "NoParallelWithRemoteQuery" + or "NoRemoteParallelismForMatrix"; + + // MaxDOPSetToOne needs special handling: check whether the user explicitly + // set MAXDOP 1 in the query text, or if it's a server/db/RG setting. + // SQL Server truncates StatementText at ~4,000 characters in plan XML. + if (stmt.NonParallelPlanReason == "MaxDOPSetToOne") + { + var text = stmt.StatementText ?? ""; + var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase); + var isTruncated = text.Length >= 3990; + + if (hasMaxdop1InText) + { + // User explicitly set MAXDOP 1 in the query — warn + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = "Query running serially: MAXDOP is set to 1 using a query hint.", + Severity = PlanWarningSeverity.Warning + }); + } + else if (isTruncated) + { + // Query text was truncated — can't tell if MAXDOP 1 is in the query + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}. MAXDOP 1 may be set at the server, database, resource governor, or query level (query text was truncated).", + Severity = PlanWarningSeverity.Info + }); + } + // else: not truncated, no MAXDOP 1 in text — server/db/RG setting, suppress entirely + } + else + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}.", + Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info + }); + } + } + + // Rule 9: Memory grant issues (statement-level) + if (!cfg.IsRuleDisabled(9) && stmt.MemoryGrant != null) + { + var grant = stmt.MemoryGrant; + + // Excessive grant — granted far more than actually used + if (grant.GrantedMemoryKB > 0 && grant.MaxUsedMemoryKB > 0) + { + var wasteRatio = (double)grant.GrantedMemoryKB / grant.MaxUsedMemoryKB; + if (wasteRatio >= 10 && grant.GrantedMemoryKB >= 1048576) + { + var grantMB = grant.GrantedMemoryKB / 1024.0; + var usedMB = grant.MaxUsedMemoryKB / 1024.0; + var message = $"Granted {grantMB:N0} MB but only used {usedMB:N0} MB ({wasteRatio:F0}x overestimate). The unused memory is reserved and unavailable to other queries."; + + // Note adaptive joins that chose Nested Loops at runtime — the grant + // was sized for a hash join that never happened. + if (stmt.RootNode != null && HasAdaptiveJoinChoseNestedLoop(stmt.RootNode)) + message += " An adaptive join in this plan executed as a Nested Loop at runtime — the memory grant was sized for the hash join alternative that wasn't used."; + + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Excessive Memory Grant", + Message = message, + Severity = PlanWarningSeverity.Warning + }); + } + } + + // Grant wait — query had to wait for memory + if (grant.GrantWaitTimeMs > 0) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Memory Grant Wait", + Message = $"Query waited {grant.GrantWaitTimeMs:N0}ms for a memory grant before it could start running. Other queries were using all available workspace memory.", + Severity = grant.GrantWaitTimeMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Large memory grant with top consumers + if (grant.GrantedMemoryKB >= 1048576 && stmt.RootNode != null) + { + var consumers = new List(); + FindMemoryConsumers(stmt.RootNode, consumers); + + var grantMB = grant.GrantedMemoryKB / 1024.0; + var guidance = ""; + if (consumers.Count > 0) + { + // Show only the top 3 consumers — listing 20+ is noise + var shown = consumers.Take(3); + var remaining = consumers.Count - 3; + guidance = $" Largest consumers: {string.Join(", ", shown)}"; + if (remaining > 0) + guidance += $", and {remaining} more"; + guidance += "."; + } + + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Large Memory Grant", + Message = $"Query granted {grantMB:F0} MB of memory.{guidance}", + Severity = grantMB >= 4096 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + + // Rule 18: Compile memory exceeded (early abort) + if (!cfg.IsRuleDisabled(18) && stmt.StatementOptmEarlyAbortReason == "MemoryLimitExceeded") + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Compile Memory Exceeded", + Message = "Optimization was aborted early because the compile memory limit was exceeded. The plan is likely suboptimal. Simplify the query by breaking it into smaller steps using #temp tables.", + Severity = PlanWarningSeverity.Critical + }); + } + + // Rule 19: High compile CPU + if (!cfg.IsRuleDisabled(19) && stmt.CompileCPUMs >= 1000) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "High Compile CPU", + Message = $"Query took {stmt.CompileCPUMs:N0}ms of CPU just to compile a plan (before any data was read). Simplify the query by breaking it into smaller steps using #temp tables.", + Severity = stmt.CompileCPUMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Rule 4 (statement-level): UDF execution timing from QueryTimeStats + // Some plans report UDF timing only at the statement level, not per-node. + if (!cfg.IsRuleDisabled(4) && (stmt.QueryUdfCpuTimeMs > 0 || stmt.QueryUdfElapsedTimeMs > 0)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "UDF Execution", + Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", + Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Rule 20: Local variables without RECOMPILE + // Parameters with no CompiledValue are likely local variables — the optimizer + // cannot sniff their values and uses density-based ("unknown") estimates. + // Skip statements with cost < 1 (can't go parallel, estimate quality rarely matters). + if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 1.0) + { + var unsnifffedParams = stmt.Parameters + .Where(p => string.IsNullOrEmpty(p.CompiledValue)) + .ToList(); + + if (unsnifffedParams.Count > 0) + { + var hasRecompile = stmt.StatementText?.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase) == true; + if (!hasRecompile) + { + var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Local Variables", + Message = $"Local variables detected: {names}. SQL Server cannot sniff local variable values at compile time, so it uses average density estimates instead of your actual values. Test with OPTION (RECOMPILE) to see if the plan improves. For a permanent fix, use dynamic SQL or a stored procedure to pass the values as parameters instead of local variables.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 21 (CTE referenced multiple times) removed per Joe's #215 feedback: + // for actual plans, SQL Server runtime stats show exactly where time was + // spent, so a statement-text-pattern warning about CTE reuse is guessing. + + // Rule 27: OPTIMIZE FOR UNKNOWN in statement text + if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) && + Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Optimize For Unknown", + Message = "OPTIMIZE FOR UNKNOWN uses average density estimates instead of sniffed parameter values. This can help when parameter sniffing causes plan instability, but may produce suboptimal plans for skewed data distributions.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 36: Dynamic cursor (#215 E1). Dynamic cursors can prevent index usage + // because they must tolerate underlying data changes between fetches, forcing + // scans and extra work per fetch. Switching to FAST_FORWARD, STATIC, or KEYSET + // often delivers a dramatic improvement. + if (!cfg.IsRuleDisabled(36) + && string.Equals(stmt.CursorActualType, "Dynamic", StringComparison.OrdinalIgnoreCase)) + { + var cursorLabel = string.IsNullOrEmpty(stmt.CursorName) ? "Cursor" : $"Cursor \"{stmt.CursorName}\""; + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Dynamic Cursor", + Message = $"{cursorLabel} is a dynamic cursor. Dynamic cursors tolerate underlying data changes between fetches, which prevents many index uses and forces extra work per fetch. If you don't need that semantic, switching to FAST_FORWARD (or STATIC / KEYSET, depending on requirements) typically gives a large performance improvement.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 37: CURSOR declaration without LOCAL (#215 E3). Default cursor scope + // is GLOBAL in SQL Server, which puts cursors in a shared namespace and can + // bloat the plan cache (Erik's writeup: + // https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). + if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText)) + { + // DECLARE [qualifier(s)] CURSOR ... FOR + // Flags the declaration if LOCAL isn't among the qualifiers before CURSOR. + var cursorDeclMatch = Regex.Match( + stmt.StatementText, + @"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (cursorDeclMatch.Success) + { + var qualifiers = cursorDeclMatch.Groups[1].Value; + if (!Regex.IsMatch(qualifiers, @"\bLOCAL\b", RegexOptions.IgnoreCase)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Cursor Missing LOCAL", + Message = "CURSOR declaration is missing the LOCAL keyword. Default cursor scope is GLOBAL, which puts the cursor in a shared namespace and can bloat the plan cache (see https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). Adding LOCAL is cheap and usually right.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 38: Standard Edition DOP 2 limitation with batch mode + // SQL Server Standard Edition limits DOP to 2 when batch mode operators are present. + if (!cfg.IsRuleDisabled(38) && stmt.DegreeOfParallelism == 2 && stmt.RootNode != null + && HasBatchModeNode(stmt.RootNode)) + { + // Suppress when the user explicitly set MAXDOP 2 as a query hint — the DOP + // cap is intentional, not the Standard Edition batch-mode limitation. + var hasMaxdop2Hint = !string.IsNullOrEmpty(stmt.StatementText) + && Regex.IsMatch(stmt.StatementText, @"MAXDOP\s+2\b", RegexOptions.IgnoreCase); + + if (!hasMaxdop2Hint) + { + var editionKnown = !string.IsNullOrEmpty(serverMetadata?.Edition); + if (editionKnown + && serverMetadata!.Edition!.Contains("Standard", StringComparison.OrdinalIgnoreCase)) + { + // Server context confirms Standard Edition — check MAXDOP + if (serverMetadata.MaxDop > 2) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Standard Edition DOP Limitation", + Message = $"DOP is limited to 2 because SQL Server Standard Edition caps parallelism at 2 when batch mode operators are present, even though MAXDOP is set to {serverMetadata.MaxDop}. Developer or Enterprise Edition would allow higher DOP in the same conditions.", + Severity = PlanWarningSeverity.Warning + }); + } + } + else if (!editionKnown) + { + // No server context, or edition unknown (e.g. collection failure) — suspect the limitation + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Standard Edition DOP Limitation", + Message = "DOP is limited to 2 and the plan uses batch mode operators. This may be caused by the SQL Server Standard Edition limitation, which caps parallelism at 2 when batch mode is in use. If this server runs Standard Edition, Developer or Enterprise Edition would allow higher DOP.", + Severity = PlanWarningSeverity.Info + }); + } + } + } + + // Rules 25 (Ineffective Parallelism) and 31 (Parallel Wait Bottleneck) were removed. + // The CPU:Elapsed ratio is now shown in the runtime summary, and wait stats speak + // for themselves — no need for meta-warnings guessing at causes. + + // Rule 30: Missing index quality evaluation + if (!cfg.IsRuleDisabled(30)) + { + // Detect duplicate suggestions for the same table + var tableSuggestionCount = stmt.MissingIndexes + .GroupBy(mi => $"{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + foreach (var mi in stmt.MissingIndexes) + { + var keyCount = mi.EqualityColumns.Count + mi.InequalityColumns.Count; + var includeCount = mi.IncludeColumns.Count; + var tableKey = $"{mi.Schema}.{mi.Table}"; + + // Low-impact suggestion (< 25% improvement) + if (mi.Impact < 25) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Low Impact Index", + Message = $"Missing index suggestion for {mi.Table} has only {mi.Impact:F0}% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement.", + Severity = PlanWarningSeverity.Info + }); + } + + // Wide INCLUDE columns (> 5) + if (includeCount > 5) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Wide Index Suggestion", + Message = $"Missing index suggestion for {mi.Table} has {includeCount} INCLUDE columns. This is a \"kitchen sink\" index — SQL Server suggests covering every column the query touches, but the resulting index would be very wide and expensive to maintain. Evaluate which columns are actually needed, or consider a narrower index with fewer includes.", + Severity = PlanWarningSeverity.Warning + }); + } + // Wide key columns (> 4) + else if (keyCount > 4) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Wide Index Suggestion", + Message = $"Missing index suggestion for {mi.Table} has {keyCount} key columns ({mi.EqualityColumns.Count} equality + {mi.InequalityColumns.Count} inequality). Wide key columns increase index size and maintenance cost. Evaluate whether all key columns are needed for seek predicates.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Multiple suggestions for same table + if (tableSuggestionCount.TryGetValue(tableKey, out var count)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Duplicate Index Suggestions", + Message = $"{count} missing index suggestions target {mi.Table}. Multiple suggestions for the same table often overlap — consolidate into fewer, broader indexes rather than creating all of them.", + Severity = PlanWarningSeverity.Warning + }); + // Only warn once per table + tableSuggestionCount.Remove(tableKey); + } + } + } + + // Rule 22 (statement-level): Table variable warnings + // Walk the tree to find table variable references, then emit statement-level warnings + if (!cfg.IsRuleDisabled(22) && stmt.RootNode != null) + { + var hasTableVar = false; + var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE"; + var modifiesTableVar = false; + CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar); + + if (hasTableVar && !modifiesTableVar) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", + Severity = PlanWarningSeverity.Warning + }); + } + + if (modifiesTableVar) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.", + Severity = PlanWarningSeverity.Critical + }); + } + } + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs new file mode 100644 index 0000000..7d4aaa3 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class PlanAnalyzer +{ + private static void FindMemoryConsumers(PlanNode node, List consumers) + { + // Collect all consumers first, then sort by row count descending + var raw = new List<(string Label, double Rows)>(); + FindMemoryConsumersRecursive(node, raw); + + foreach (var (label, _) in raw.OrderByDescending(c => c.Rows)) + consumers.Add(label); + } + + private static void FindMemoryConsumersRecursive(PlanNode node, List<(string Label, double Rows)> consumers) + { + if (node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase)) + { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; + var rows = node.HasActualStats + ? $"{node.ActualRows:N0} actual rows" + : $"{node.EstimateRows:N0} estimated rows"; + consumers.Add(($"Sort (Node {node.NodeId}, {rows})", rowCount)); + } + else if (node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) + { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; + var rows = node.HasActualStats + ? $"{node.ActualRows:N0} actual rows" + : $"{node.EstimateRows:N0} estimated rows"; + consumers.Add(($"Hash Match (Node {node.NodeId}, {rows})", rowCount)); + } + + foreach (var child in node.Children) + FindMemoryConsumersRecursive(child, consumers); + } + + /// + /// Calculates an operator's own elapsed time by subtracting child time. + /// In batch mode, operator times are self-contained (exclusive). + /// In row mode, times are cumulative (include all children below). + /// For parallel plans, we calculate self-time per-thread then take the max, + /// avoiding cross-thread subtraction errors. + /// Exchange operators accumulate downstream wait time (e.g. from spilling + /// children) so their self-time is unreliable — see sql.kiwi/2021/03. + /// + internal static long GetOperatorOwnElapsedMs(PlanNode node) + { + if (node.ActualExecutionMode == "Batch") + return node.ActualElapsedMs; + + // Parallel plan with per-thread data: calculate self-time per thread + if (node.PerThreadStats.Count > 1) + return GetPerThreadOwnElapsed(node); + + // Serial row mode: subtract all direct children's elapsed time + return GetSerialOwnElapsed(node); + } + + /// + /// Per-thread self-time calculation for parallel row mode operators. + /// For each thread: self = parent_elapsed[t] - sum(children_elapsed[t]). + /// Returns max across threads. + /// + private static long GetPerThreadOwnElapsed(PlanNode node) + { + // Build lookup: threadId -> parent elapsed for this node + var parentByThread = new Dictionary(); + foreach (var ts in node.PerThreadStats) + parentByThread[ts.ThreadId] = ts.ActualElapsedMs; + + // Build lookup: threadId -> sum of all direct children's elapsed + var childSumByThread = new Dictionary(); + foreach (var child in node.Children) + { + var childNode = child; + + // Exchange operators have unreliable times — look through to their child + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + childNode = child.Children.OrderByDescending(c => c.ActualElapsedMs).First(); + + foreach (var ts in childNode.PerThreadStats) + { + childSumByThread.TryGetValue(ts.ThreadId, out var existing); + childSumByThread[ts.ThreadId] = existing + ts.ActualElapsedMs; + } + } + + // Self-time per thread = parent - children, take max across threads + var maxSelf = 0L; + foreach (var (threadId, parentMs) in parentByThread) + { + childSumByThread.TryGetValue(threadId, out var childMs); + var self = Math.Max(0, parentMs - childMs); + if (self > maxSelf) maxSelf = self; + } + + return maxSelf; + } + + /// + /// Max per-thread self-CPU for this operator. + /// Parallel: for each thread, self_cpu = thread_cpu - Σ same-thread child cpu; take max. + /// Serial / single-thread: operator_cpu - Σ effective child cpu. + /// Needed for external-wait benefit scoring (Joe's formula). + /// + internal static long GetOperatorMaxThreadOwnCpuMs(PlanNode node) + { + if (!node.HasActualStats || node.ActualCPUMs <= 0) return 0; + + if (node.PerThreadStats.Count > 1) + { + var parentByThread = new Dictionary(); + foreach (var ts in node.PerThreadStats) + parentByThread[ts.ThreadId] = ts.ActualCPUMs; + + var childSumByThread = new Dictionary(); + foreach (var child in node.Children) + { + var childNode = child; + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + childNode = child.Children.OrderByDescending(c => c.ActualCPUMs).First(); + foreach (var ts in childNode.PerThreadStats) + { + childSumByThread.TryGetValue(ts.ThreadId, out var existing); + childSumByThread[ts.ThreadId] = existing + ts.ActualCPUMs; + } + } + + var maxSelf = 0L; + foreach (var (threadId, parentCpu) in parentByThread) + { + childSumByThread.TryGetValue(threadId, out var childCpu); + var self = Math.Max(0, parentCpu - childCpu); + if (self > maxSelf) maxSelf = self; + } + return maxSelf; + } + + // Serial: operator_cpu - Σ effective child cpu + var totalChildCpu = 0L; + foreach (var child in node.Children) + totalChildCpu += GetEffectiveChildCpuMs(child); + return Math.Max(0, node.ActualCPUMs - totalChildCpu); + } + + private static long GetEffectiveChildCpuMs(PlanNode child) + { + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + return child.Children.Max(GetEffectiveChildCpuMs); + if (child.ActualCPUMs > 0) + return child.ActualCPUMs; + if (child.Children.Count == 0) + return 0; + var sum = 0L; + foreach (var grandchild in child.Children) + sum += GetEffectiveChildCpuMs(grandchild); + return sum; + } + + /// + /// Serial row mode self-time: subtract all direct children's effective elapsed. + /// Pass-through operators (Compute Scalar, etc.) don't carry runtime stats — + /// look through them to the first descendant that does. Exchange children + /// use max-child elapsed because exchange times are unreliable. + /// + private static long GetSerialOwnElapsed(PlanNode node) + { + var totalChildElapsed = 0L; + foreach (var child in node.Children) + totalChildElapsed += GetEffectiveChildElapsedMs(child); + + return Math.Max(0, node.ActualElapsedMs - totalChildElapsed); + } + + /// + /// Returns the elapsed time a child contributes to its parent's subtree. + /// Looks through pass-through operators (Compute Scalar, Parallelism exchange) + /// that don't carry reliable runtime stats. + /// + private static long GetEffectiveChildElapsedMs(PlanNode child) + { + // Exchange operators: unreliable times, use max child + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + return child.Children.Max(GetEffectiveChildElapsedMs); + + // Batch mode pipelines — each operator's elapsed stands alone rather than + // rolling up its descendants the way row-mode does. For a parent computing + // self-time above a batch-mode subtree, subtract the whole pipeline's time + // (Joe #215 D1: Parallelism gather-streams above three batch operators). + var mode = child.ActualExecutionMode ?? child.ExecutionMode; + if (mode == "Batch" && child.HasActualStats) + return SumBatchSubtreeElapsedMs(child); + + // Child has its own stats: use them + if (child.ActualElapsedMs > 0) + return child.ActualElapsedMs; + + // No stats (Compute Scalar and similar): look through to descendants + if (child.Children.Count == 0) + return 0; + + var sum = 0L; + foreach (var grandchild in child.Children) + sum += GetEffectiveChildElapsedMs(grandchild); + return sum; + } + + /// + /// Sums ActualElapsedMs across a contiguous batch-mode subtree (stops at + /// Parallelism exchange zone boundaries). Batch operators pipeline — elapsed + /// times are standalone, not cumulative — so summing gives the total work the + /// zone did, which is what a row-mode parent above the zone should subtract + /// to get its own self-time. + /// + private static long SumBatchSubtreeElapsedMs(PlanNode node) + { + long sum = node.ActualElapsedMs; + foreach (var child in node.Children) + { + // Zone boundary — stop summing + if (child.PhysicalOp == "Parallelism") continue; + + var childMode = child.ActualExecutionMode ?? child.ExecutionMode; + if (childMode == "Batch" && child.HasActualStats) + sum += SumBatchSubtreeElapsedMs(child); + else + sum += GetEffectiveChildElapsedMs(child); + } + return sum; + } + + /// + /// Calculates a Parallelism (exchange) operator's own elapsed time. + /// Exchange times are unreliable — they accumulate wait time caused by + /// downstream operators (e.g. spilling sorts). This returns a best-effort + /// value but callers should treat it with caution. + /// + private static long GetParallelismOperatorElapsedMs(PlanNode node) + { + if (node.Children.Count == 0) + return node.ActualElapsedMs; + + if (node.PerThreadStats.Count > 1) + return GetPerThreadOwnElapsed(node); + + var maxChildElapsed = node.Children.Max(c => c.ActualElapsedMs); + return Math.Max(0, node.ActualElapsedMs - maxChildElapsed); + } + + /// + /// Quantifies the cost of work below a Filter operator by summing child subtree metrics. + /// Shows how many rows, reads, and elapsed time were spent producing rows that the + /// Filter then discarded. + /// + private static string QuantifyFilterImpact(PlanNode filterNode) + { + if (filterNode.Children.Count == 0) + return ""; + + var parts = new List(); + + // Rows input vs output — how many rows did the filter discard? + var inputRows = filterNode.Children.Sum(c => c.ActualRows); + if (filterNode.HasActualStats && inputRows > 0 && filterNode.ActualRows < inputRows) + { + var discarded = inputRows - filterNode.ActualRows; + var pct = (double)discarded / inputRows * 100; + parts.Add($"{discarded:N0} of {inputRows:N0} rows discarded ({pct:N0}%)"); + } + + // Logical reads across the entire child subtree + long totalReads = 0; + foreach (var child in filterNode.Children) + totalReads += SumSubtreeReads(child); + if (totalReads > 0) + parts.Add($"{totalReads:N0} logical reads below"); + + // Elapsed time: use the direct child's time (cumulative in row mode, includes its children) + var childElapsed = filterNode.Children.Max(c => c.ActualElapsedMs); + if (childElapsed > 0) + parts.Add($"{childElapsed:N0}ms elapsed below"); + + if (parts.Count == 0) + return ""; + + return string.Join("\n", parts.Select(p => "• " + p)); + } + + /// + /// Detects well-known CE default selectivity guesses by comparing EstimateRows to TableCardinality. + /// Returns a description of the guess pattern, or null if no known pattern matches. + /// + private static string? DetectCeGuess(double estimateRows, double tableCardinality) + { + if (tableCardinality <= 0) return null; + var selectivity = estimateRows / tableCardinality; + + // Known CE guess selectivities with a 2% tolerance band + return selectivity switch + { + >= 0.29 and <= 0.31 => $"matches the 30% equality guess ({selectivity * 100:N1}%)", + >= 0.098 and <= 0.102 => $"matches the 10% inequality guess ({selectivity * 100:N1}%)", + >= 0.088 and <= 0.092 => $"matches the 9% LIKE/BETWEEN guess ({selectivity * 100:N1}%)", + >= 0.155 and <= 0.175 => $"matches the ~16.4% compound predicate guess ({selectivity * 100:N1}%)", + >= 0.009 and <= 0.011 => $"matches the 1% multi-inequality guess ({selectivity * 100:N1}%)", + _ => null + }; + } + + private static long SumSubtreeReads(PlanNode node) + { + long reads = node.ActualLogicalReads; + foreach (var child in node.Children) + reads += SumSubtreeReads(child); + return reads; + } + + /// + /// Builds impact details for a scan node: what % of plan time/cost it represents, + /// and what fraction of rows survived filtering. + /// + private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement stmt) + { + var parts = new List(); + + // % of plan cost + double costPct = 0; + if (stmt.StatementSubTreeCost > 0 && node.EstimatedTotalSubtreeCost > 0) + { + costPct = node.EstimatedTotalSubtreeCost / stmt.StatementSubTreeCost * 100; + if (costPct >= 50) + parts.Add($"This scan is {costPct:N0}% of the plan cost."); + } + + // % of elapsed time (actual plans) + double elapsedPct = 0; + if (node.HasActualStats && node.ActualElapsedMs > 0 && + stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) + { + elapsedPct = (double)node.ActualElapsedMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; + if (elapsedPct >= 50) + parts.Add($"This scan took {elapsedPct:N0}% of elapsed time."); + } + + // Row selectivity: rows returned vs rows read (actual) or vs table cardinality (estimated) + if (node.HasActualStats && node.ActualRowsRead > 0 && node.ActualRows < node.ActualRowsRead) + { + var selectivity = (double)node.ActualRows / node.ActualRowsRead * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N3}% of rows survived filtering ({node.ActualRows:N0} of {node.ActualRowsRead:N0})."); + } + else if (!node.HasActualStats && node.TableCardinality > 0 && node.EstimateRows < node.TableCardinality) + { + var selectivity = node.EstimateRows / node.TableCardinality * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N1}% of rows estimated to survive filtering."); + } + + return new ScanImpact(costPct, elapsedPct, parts.Count > 0 ? string.Join(" ", parts) : null); + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 6591a05..4472936 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -10,7 +10,7 @@ namespace PlanViewer.Core.Services; /// Post-parse analysis pass that walks a parsed plan tree and adds warnings /// for common performance anti-patterns. Called after ShowPlanParser.Parse(). /// -public static class PlanAnalyzer +public static partial class PlanAnalyzer { private static readonly Regex FunctionInPredicateRegex = new( @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", @@ -76,27 +76,6 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null, Serve "Implicit Conversion", }; - private static void MarkLegacyWarnings(PlanStatement stmt) - { - foreach (var w in stmt.PlanWarnings) - { - if (LegacyWarningTypes.Contains(w.WarningType)) - w.IsLegacy = true; - } - if (stmt.RootNode != null) - MarkLegacyWarningsOnTree(stmt.RootNode); - } - - private static void MarkLegacyWarningsOnTree(PlanNode node) - { - foreach (var w in node.Warnings) - { - if (LegacyWarningTypes.Contains(w.WarningType)) - w.IsLegacy = true; - } - foreach (var child in node.Children) - MarkLegacyWarningsOnTree(child); - } // Rule number → WarningType mapping for severity overrides private static readonly Dictionary RuleWarningTypes = new() @@ -128,2111 +107,9 @@ static PlanAnalyzer() WarningTypeToRule[type] = rule; } - private static void ApplySeverityOverrides(ParsedPlan plan, AnalyzerConfig cfg) - { - foreach (var batch in plan.Batches) - { - foreach (var stmt in batch.Statements) - { - foreach (var w in stmt.PlanWarnings) - TryOverrideSeverity(w, cfg); - - if (stmt.RootNode != null) - ApplyOverridesToTree(stmt.RootNode, cfg); - } - } - } - - private static void ApplyOverridesToTree(PlanNode node, AnalyzerConfig cfg) - { - foreach (var w in node.Warnings) - TryOverrideSeverity(w, cfg); - foreach (var child in node.Children) - ApplyOverridesToTree(child, cfg); - } - - private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg) - { - // Find the rule number for this warning type (partial match for flexibility) - int? ruleNumber = null; - foreach (var (rule, type) in RuleWarningTypes) - { - if (warning.WarningType.Contains(type, StringComparison.OrdinalIgnoreCase) || - type.Contains(warning.WarningType, StringComparison.OrdinalIgnoreCase)) - { - ruleNumber = rule; - break; - } - } - - if (ruleNumber == null) return; - - var overrideSeverity = cfg.GetSeverityOverride(ruleNumber.Value); - if (overrideSeverity == null) return; - - if (Enum.TryParse(overrideSeverity, ignoreCase: true, out var severity)) - warning.Severity = severity; - } - - private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata = null) - { - // Rule 3: Serial plan with reason - // Skip: cost < 1 (CTFP is an integer so cost < 1 can never go parallel), - // TRIVIAL optimization (can't go parallel anyway), - // and 0ms actual elapsed time (not worth flagging). - if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason) - && stmt.StatementSubTreeCost >= 1.0 - && stmt.StatementOptmLevel != "TRIVIAL" - && !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0)) - { - var reason = stmt.NonParallelPlanReason switch - { - // User/config forced serial - "MaxDOPSetToOne" => "MAXDOP is set to 1", - "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", - "ParallelismDisabledByTraceFlag" => "Parallelism disabled by trace flag", - - // Passive — optimizer chose serial, nothing wrong - "EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)", - - // Edition/environment limitations - "NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism", - "NoParallelCreateIndexInNonEnterpriseEdition" => "Parallel index creation requires Enterprise edition", - "NoParallelPlansDuringUpgrade" => "Parallel plans disabled during upgrade", - "NoParallelForPDWCompilation" => "Parallel plans not supported for PDW compilation", - "NoParallelForCloudDBReplication" => "Parallel plans not supported during cloud DB replication", - - // Query constructs that block parallelism (actionable) - "CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints", - "TSQLUserDefinedFunctionsNotParallelizable" => "T-SQL scalar UDF prevents parallelism. Rewrite as an inline table-valued function, or on SQL Server 2019+ check if the UDF is eligible for automatic inlining", - "CLRUserDefinedFunctionRequiresDataAccess" => "CLR UDF with data access prevents parallelism", - "NonParallelizableIntrinsicFunction" => "Non-parallelizable intrinsic function in the query", - "TableVariableTransactionsDoNotSupportParallelNestedTransaction" => "Table variable transaction prevents parallelism. Consider using a #temp table instead", - "UpdatingWritebackVariable" => "Updating a writeback variable prevents parallelism", - "DMLQueryReturnsOutputToClient" => "DML with OUTPUT clause returning results to client prevents parallelism", - "MixedSerialAndParallelOnlineIndexBuildNotSupported" => "Mixed serial/parallel online index build not supported", - "NoRangesResumableCreate" => "Resumable index create cannot use parallelism for this operation", - - // Cursor limitations - "NoParallelCursorFetchByBookmark" => "Cursor fetch by bookmark cannot use parallelism", - "NoParallelDynamicCursor" => "Dynamic cursors cannot use parallelism", - "NoParallelFastForwardCursor" => "Fast-forward cursors cannot use parallelism", - - // Memory-optimized / natively compiled - "NoParallelForMemoryOptimizedTables" => "Memory-optimized tables do not support parallel plans", - "NoParallelForDmlOnMemoryOptimizedTable" => "DML on memory-optimized tables cannot use parallelism", - "NoParallelForNativelyCompiledModule" => "Natively compiled modules do not support parallelism", - - // Remote queries - "NoParallelWithRemoteQuery" => "Remote queries cannot use parallelism", - "NoRemoteParallelismForMatrix" => "Remote parallelism not available for this query shape", - - _ => stmt.NonParallelPlanReason - }; - - // Actionable: user forced serial, or something in the query blocks parallelism - // that could potentially be rewritten. Info: passive (cost too low) or - // environmental (edition, upgrade, cursor type, memory-optimized). - var isActionable = stmt.NonParallelPlanReason is - "MaxDOPSetToOne" or "QueryHintNoParallelSet" or "ParallelismDisabledByTraceFlag" - or "CouldNotGenerateValidParallelPlan" - or "TSQLUserDefinedFunctionsNotParallelizable" - or "CLRUserDefinedFunctionRequiresDataAccess" - or "NonParallelizableIntrinsicFunction" - or "TableVariableTransactionsDoNotSupportParallelNestedTransaction" - or "UpdatingWritebackVariable" - or "DMLQueryReturnsOutputToClient" - or "NoParallelCursorFetchByBookmark" - or "NoParallelDynamicCursor" - or "NoParallelFastForwardCursor" - or "NoParallelWithRemoteQuery" - or "NoRemoteParallelismForMatrix"; - - // MaxDOPSetToOne needs special handling: check whether the user explicitly - // set MAXDOP 1 in the query text, or if it's a server/db/RG setting. - // SQL Server truncates StatementText at ~4,000 characters in plan XML. - if (stmt.NonParallelPlanReason == "MaxDOPSetToOne") - { - var text = stmt.StatementText ?? ""; - var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase); - var isTruncated = text.Length >= 3990; - - if (hasMaxdop1InText) - { - // User explicitly set MAXDOP 1 in the query — warn - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Serial Plan", - Message = "Query running serially: MAXDOP is set to 1 using a query hint.", - Severity = PlanWarningSeverity.Warning - }); - } - else if (isTruncated) - { - // Query text was truncated — can't tell if MAXDOP 1 is in the query - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Serial Plan", - Message = $"Query running serially: {reason}. MAXDOP 1 may be set at the server, database, resource governor, or query level (query text was truncated).", - Severity = PlanWarningSeverity.Info - }); - } - // else: not truncated, no MAXDOP 1 in text — server/db/RG setting, suppress entirely - } - else - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Serial Plan", - Message = $"Query running serially: {reason}.", - Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info - }); - } - } - - // Rule 9: Memory grant issues (statement-level) - if (!cfg.IsRuleDisabled(9) && stmt.MemoryGrant != null) - { - var grant = stmt.MemoryGrant; - - // Excessive grant — granted far more than actually used - if (grant.GrantedMemoryKB > 0 && grant.MaxUsedMemoryKB > 0) - { - var wasteRatio = (double)grant.GrantedMemoryKB / grant.MaxUsedMemoryKB; - if (wasteRatio >= 10 && grant.GrantedMemoryKB >= 1048576) - { - var grantMB = grant.GrantedMemoryKB / 1024.0; - var usedMB = grant.MaxUsedMemoryKB / 1024.0; - var message = $"Granted {grantMB:N0} MB but only used {usedMB:N0} MB ({wasteRatio:F0}x overestimate). The unused memory is reserved and unavailable to other queries."; - - // Note adaptive joins that chose Nested Loops at runtime — the grant - // was sized for a hash join that never happened. - if (stmt.RootNode != null && HasAdaptiveJoinChoseNestedLoop(stmt.RootNode)) - message += " An adaptive join in this plan executed as a Nested Loop at runtime — the memory grant was sized for the hash join alternative that wasn't used."; - - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Excessive Memory Grant", - Message = message, - Severity = PlanWarningSeverity.Warning - }); - } - } - - // Grant wait — query had to wait for memory - if (grant.GrantWaitTimeMs > 0) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Memory Grant Wait", - Message = $"Query waited {grant.GrantWaitTimeMs:N0}ms for a memory grant before it could start running. Other queries were using all available workspace memory.", - Severity = grant.GrantWaitTimeMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // Large memory grant with top consumers - if (grant.GrantedMemoryKB >= 1048576 && stmt.RootNode != null) - { - var consumers = new List(); - FindMemoryConsumers(stmt.RootNode, consumers); - - var grantMB = grant.GrantedMemoryKB / 1024.0; - var guidance = ""; - if (consumers.Count > 0) - { - // Show only the top 3 consumers — listing 20+ is noise - var shown = consumers.Take(3); - var remaining = consumers.Count - 3; - guidance = $" Largest consumers: {string.Join(", ", shown)}"; - if (remaining > 0) - guidance += $", and {remaining} more"; - guidance += "."; - } - - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Large Memory Grant", - Message = $"Query granted {grantMB:F0} MB of memory.{guidance}", - Severity = grantMB >= 4096 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - - // Rule 18: Compile memory exceeded (early abort) - if (!cfg.IsRuleDisabled(18) && stmt.StatementOptmEarlyAbortReason == "MemoryLimitExceeded") - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Compile Memory Exceeded", - Message = "Optimization was aborted early because the compile memory limit was exceeded. The plan is likely suboptimal. Simplify the query by breaking it into smaller steps using #temp tables.", - Severity = PlanWarningSeverity.Critical - }); - } - - // Rule 19: High compile CPU - if (!cfg.IsRuleDisabled(19) && stmt.CompileCPUMs >= 1000) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "High Compile CPU", - Message = $"Query took {stmt.CompileCPUMs:N0}ms of CPU just to compile a plan (before any data was read). Simplify the query by breaking it into smaller steps using #temp tables.", - Severity = stmt.CompileCPUMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // Rule 4 (statement-level): UDF execution timing from QueryTimeStats - // Some plans report UDF timing only at the statement level, not per-node. - if (!cfg.IsRuleDisabled(4) && (stmt.QueryUdfCpuTimeMs > 0 || stmt.QueryUdfElapsedTimeMs > 0)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "UDF Execution", - Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", - Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // Rule 20: Local variables without RECOMPILE - // Parameters with no CompiledValue are likely local variables — the optimizer - // cannot sniff their values and uses density-based ("unknown") estimates. - // Skip statements with cost < 1 (can't go parallel, estimate quality rarely matters). - if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 1.0) - { - var unsnifffedParams = stmt.Parameters - .Where(p => string.IsNullOrEmpty(p.CompiledValue)) - .ToList(); - - if (unsnifffedParams.Count > 0) - { - var hasRecompile = stmt.StatementText?.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase) == true; - if (!hasRecompile) - { - var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Local Variables", - Message = $"Local variables detected: {names}. SQL Server cannot sniff local variable values at compile time, so it uses average density estimates instead of your actual values. Test with OPTION (RECOMPILE) to see if the plan improves. For a permanent fix, use dynamic SQL or a stored procedure to pass the values as parameters instead of local variables.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // Rule 21 (CTE referenced multiple times) removed per Joe's #215 feedback: - // for actual plans, SQL Server runtime stats show exactly where time was - // spent, so a statement-text-pattern warning about CTE reuse is guessing. - - // Rule 27: OPTIMIZE FOR UNKNOWN in statement text - if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) && - Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Optimize For Unknown", - Message = "OPTIMIZE FOR UNKNOWN uses average density estimates instead of sniffed parameter values. This can help when parameter sniffing causes plan instability, but may produce suboptimal plans for skewed data distributions.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Rule 36: Dynamic cursor (#215 E1). Dynamic cursors can prevent index usage - // because they must tolerate underlying data changes between fetches, forcing - // scans and extra work per fetch. Switching to FAST_FORWARD, STATIC, or KEYSET - // often delivers a dramatic improvement. - if (!cfg.IsRuleDisabled(36) - && string.Equals(stmt.CursorActualType, "Dynamic", StringComparison.OrdinalIgnoreCase)) - { - var cursorLabel = string.IsNullOrEmpty(stmt.CursorName) ? "Cursor" : $"Cursor \"{stmt.CursorName}\""; - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Dynamic Cursor", - Message = $"{cursorLabel} is a dynamic cursor. Dynamic cursors tolerate underlying data changes between fetches, which prevents many index uses and forces extra work per fetch. If you don't need that semantic, switching to FAST_FORWARD (or STATIC / KEYSET, depending on requirements) typically gives a large performance improvement.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Rule 37: CURSOR declaration without LOCAL (#215 E3). Default cursor scope - // is GLOBAL in SQL Server, which puts cursors in a shared namespace and can - // bloat the plan cache (Erik's writeup: - // https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). - if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText)) - { - // DECLARE [qualifier(s)] CURSOR ... FOR - // Flags the declaration if LOCAL isn't among the qualifiers before CURSOR. - var cursorDeclMatch = Regex.Match( - stmt.StatementText, - @"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b", - RegexOptions.IgnoreCase | RegexOptions.Singleline); - if (cursorDeclMatch.Success) - { - var qualifiers = cursorDeclMatch.Groups[1].Value; - if (!Regex.IsMatch(qualifiers, @"\bLOCAL\b", RegexOptions.IgnoreCase)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Cursor Missing LOCAL", - Message = "CURSOR declaration is missing the LOCAL keyword. Default cursor scope is GLOBAL, which puts the cursor in a shared namespace and can bloat the plan cache (see https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). Adding LOCAL is cheap and usually right.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // Rule 38: Standard Edition DOP 2 limitation with batch mode - // SQL Server Standard Edition limits DOP to 2 when batch mode operators are present. - if (!cfg.IsRuleDisabled(38) && stmt.DegreeOfParallelism == 2 && stmt.RootNode != null - && HasBatchModeNode(stmt.RootNode)) - { - // Suppress when the user explicitly set MAXDOP 2 as a query hint — the DOP - // cap is intentional, not the Standard Edition batch-mode limitation. - var hasMaxdop2Hint = !string.IsNullOrEmpty(stmt.StatementText) - && Regex.IsMatch(stmt.StatementText, @"MAXDOP\s+2\b", RegexOptions.IgnoreCase); - - if (!hasMaxdop2Hint) - { - var editionKnown = !string.IsNullOrEmpty(serverMetadata?.Edition); - if (editionKnown - && serverMetadata!.Edition!.Contains("Standard", StringComparison.OrdinalIgnoreCase)) - { - // Server context confirms Standard Edition — check MAXDOP - if (serverMetadata.MaxDop > 2) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Standard Edition DOP Limitation", - Message = $"DOP is limited to 2 because SQL Server Standard Edition caps parallelism at 2 when batch mode operators are present, even though MAXDOP is set to {serverMetadata.MaxDop}. Developer or Enterprise Edition would allow higher DOP in the same conditions.", - Severity = PlanWarningSeverity.Warning - }); - } - } - else if (!editionKnown) - { - // No server context, or edition unknown (e.g. collection failure) — suspect the limitation - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Standard Edition DOP Limitation", - Message = "DOP is limited to 2 and the plan uses batch mode operators. This may be caused by the SQL Server Standard Edition limitation, which caps parallelism at 2 when batch mode is in use. If this server runs Standard Edition, Developer or Enterprise Edition would allow higher DOP.", - Severity = PlanWarningSeverity.Info - }); - } - } - } - - // Rules 25 (Ineffective Parallelism) and 31 (Parallel Wait Bottleneck) were removed. - // The CPU:Elapsed ratio is now shown in the runtime summary, and wait stats speak - // for themselves — no need for meta-warnings guessing at causes. - - // Rule 30: Missing index quality evaluation - if (!cfg.IsRuleDisabled(30)) - { - // Detect duplicate suggestions for the same table - var tableSuggestionCount = stmt.MissingIndexes - .GroupBy(mi => $"{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase) - .Where(g => g.Count() > 1) - .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); - - foreach (var mi in stmt.MissingIndexes) - { - var keyCount = mi.EqualityColumns.Count + mi.InequalityColumns.Count; - var includeCount = mi.IncludeColumns.Count; - var tableKey = $"{mi.Schema}.{mi.Table}"; - - // Low-impact suggestion (< 25% improvement) - if (mi.Impact < 25) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Low Impact Index", - Message = $"Missing index suggestion for {mi.Table} has only {mi.Impact:F0}% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement.", - Severity = PlanWarningSeverity.Info - }); - } - - // Wide INCLUDE columns (> 5) - if (includeCount > 5) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Wide Index Suggestion", - Message = $"Missing index suggestion for {mi.Table} has {includeCount} INCLUDE columns. This is a \"kitchen sink\" index — SQL Server suggests covering every column the query touches, but the resulting index would be very wide and expensive to maintain. Evaluate which columns are actually needed, or consider a narrower index with fewer includes.", - Severity = PlanWarningSeverity.Warning - }); - } - // Wide key columns (> 4) - else if (keyCount > 4) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Wide Index Suggestion", - Message = $"Missing index suggestion for {mi.Table} has {keyCount} key columns ({mi.EqualityColumns.Count} equality + {mi.InequalityColumns.Count} inequality). Wide key columns increase index size and maintenance cost. Evaluate whether all key columns are needed for seek predicates.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Multiple suggestions for same table - if (tableSuggestionCount.TryGetValue(tableKey, out var count)) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Duplicate Index Suggestions", - Message = $"{count} missing index suggestions target {mi.Table}. Multiple suggestions for the same table often overlap — consolidate into fewer, broader indexes rather than creating all of them.", - Severity = PlanWarningSeverity.Warning - }); - // Only warn once per table - tableSuggestionCount.Remove(tableKey); - } - } - } - - // Rule 22 (statement-level): Table variable warnings - // Walk the tree to find table variable references, then emit statement-level warnings - if (!cfg.IsRuleDisabled(22) && stmt.RootNode != null) - { - var hasTableVar = false; - var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE"; - var modifiesTableVar = false; - CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar); - - if (hasTableVar && !modifiesTableVar) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Table Variable", - Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", - Severity = PlanWarningSeverity.Warning - }); - } - - if (modifiesTableVar) - { - stmt.PlanWarnings.Add(new PlanWarning - { - WarningType = "Table Variable", - Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.", - Severity = PlanWarningSeverity.Critical - }); - } - } - } - - private static bool HasBatchModeNode(PlanNode node) - { - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (string.Equals(mode, "Batch", StringComparison.OrdinalIgnoreCase)) - return true; - foreach (var child in node.Children) - { - if (HasBatchModeNode(child)) - return true; - } - return false; - } - - private static void CheckForTableVariables(PlanNode node, bool isModification, - ref bool hasTableVar, ref bool modifiesTableVar) - { - if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) - { - hasTableVar = true; - // The modification target is typically an Insert/Update/Delete operator on a table variable - if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase))) - { - modifiesTableVar = true; - } - } - foreach (var child in node.Children) - CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar); - } - - private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) - { - AnalyzeNode(node, stmt, cfg); - - foreach (var child in node.Children) - AnalyzeNodeTree(child, stmt, cfg); - } - - private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) - { - // Rule 1: Filter operators — rows survived the tree just to be discarded - // Quantify the impact by summing child subtree cost (reads, CPU, time). - // Suppress when the filter's child subtree is trivial (low I/O, fast, cheap). - if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate) - && node.Children.Count > 0) - { - // Gate: skip trivial filters based on actual stats or estimated cost - bool isTrivial; - if (node.HasActualStats) - { - long childReads = 0; - foreach (var child in node.Children) - childReads += SumSubtreeReads(child); - var childElapsed = node.Children.Max(c => c.ActualElapsedMs); - isTrivial = childReads < 128 && childElapsed < 10; - } - else - { - var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); - isTrivial = childCost < 1.0; - } - - if (!isTrivial) - { - var impact = QuantifyFilterImpact(node); - var predicate = Truncate(node.Predicate, 200); - var message = "Filter operator discarding rows late in the plan."; - if (!string.IsNullOrEmpty(impact)) - message += $"\n{impact}"; - message += $"\nPredicate: {predicate}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Filter Operator", - Message = message, - Severity = PlanWarningSeverity.Warning - }); - } - } - - // Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly - if (!cfg.IsRuleDisabled(2) && node.LogicalOp == "Eager Spool" && - node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase)) - { - var message = "SQL Server is building a temporary index in TempDB at runtime because no suitable permanent index exists. This is expensive — it builds the index from scratch on every execution. Create a permanent index on the underlying table to eliminate this operator entirely."; - if (!string.IsNullOrEmpty(node.SuggestedIndex)) - message += $"\n\nCreate this index:\n{node.SuggestedIndex}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Eager Index Spool", - Message = message, - Severity = PlanWarningSeverity.Critical - }); - } - - // Rule 4: UDF timing — any node spending time in UDFs (actual plans) - if (!cfg.IsRuleDisabled(4) && (node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "UDF Execution", - Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", - Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // Rule 5: Large estimate vs actual row gaps (actual plans only) - // Only warn when the bad estimate actually causes observable harm: - // - The node itself spilled (Sort/Hash with bad memory grant) - // - A parent join may have chosen the wrong strategy - // - Root nodes with no parent to harm are skipped - // - Nodes whose only parents are Parallelism/Top/Sort (no spill) are skipped - if (!cfg.IsRuleDisabled(5) && node.HasActualStats && node.EstimateRows > 0 - && !node.Lookup) // Key lookups are point lookups (1 row per execution) — per-execution estimate is misleading - { - if (node.ActualRows == 0) - { - // Zero rows with a significant estimate — only warn on operators that - // actually allocate meaningful resources (memory grants for hash/sort/spool). - // Skip Parallelism, Bitmap, Compute Scalar, Filter, Concatenation, etc. - // where 0 rows is just a consequence of upstream filtering. - if (node.EstimateRows >= 100 && AllocatesResources(node)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Row Estimate Mismatch", - Message = $"Estimated {node.EstimateRows:N0} rows but actual 0 rows returned. SQL Server allocated resources for rows that never materialized.", - Severity = PlanWarningSeverity.Warning - }); - } - } - else - { - // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; - var actualPerExec = (double)node.ActualRows / executions; - var ratio = actualPerExec / node.EstimateRows; - if (ratio >= 10.0 || ratio <= 0.1) - { - var harm = AssessEstimateHarm(node, ratio); - if (harm != null) - { - var direction = ratio >= 10.0 ? "underestimated" : "overestimated"; - var factor = ratio >= 10.0 ? ratio : 1.0 / ratio; - var actualDisplay = executions > 1 - ? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)" - : $"Actual {node.ActualRows:N0}"; - node.Warnings.Add(new PlanWarning - { - WarningType = "Row Estimate Mismatch", - Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}", - Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - } - } - - // Rule 6: Scalar UDF references (works on estimated plans too) - // Suppress when Serial Plan warning is already firing for a UDF-related reason — - // the Serial Plan warning already explains the issue, this would be redundant. - var serialPlanCoversUdf = stmt.NonParallelPlanReason is - "TSQLUserDefinedFunctionsNotParallelizable" - or "CLRUserDefinedFunctionRequiresDataAccess" - or "CouldNotGenerateValidParallelPlan"; - if (!cfg.IsRuleDisabled(6) && !serialPlanCoversUdf) - foreach (var udf in node.ScalarUdfs) - { - var type = udf.IsClrFunction ? "CLR" : "T-SQL"; - node.Warnings.Add(new PlanWarning - { - WarningType = "Scalar UDF", - Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Rule 7: Spill detection — calculate operator time and set severity - // based on what percentage of statement elapsed time the spill accounts for. - // Exchange spills on Parallelism operators get special handling since their - // timing is unreliable but the write count tells the story. - if (!cfg.IsRuleDisabled(7)) - foreach (var w in node.Warnings.ToList()) - { - if (w.SpillDetails == null) - continue; - - var isExchangeSpill = w.SpillDetails.SpillType == "Exchange"; - - if (isExchangeSpill) - { - // Exchange spills: severity based on write count since timing is unreliable - var writes = w.SpillDetails.WritesToTempDb; - if (writes >= 1_000_000) - w.Severity = PlanWarningSeverity.Critical; - else if (writes >= 10_000) - w.Severity = PlanWarningSeverity.Warning; - - // Surface Parallelism operator time when available (actual plans) - if (node.ActualElapsedMs > 0) - { - var operatorMs = GetParallelismOperatorElapsedMs(node); - var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - if (stmtMs > 0 && operatorMs > 0) - { - var pct = (double)operatorMs / stmtMs; - w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; - } - } - } - else if (node.ActualElapsedMs > 0) - { - // Sort/Hash spills: severity based on operator time percentage - var operatorMs = GetOperatorOwnElapsedMs(node); - var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - - if (stmtMs > 0) - { - var pct = (double)operatorMs / stmtMs; - w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; - - if (pct >= 0.5) - w.Severity = PlanWarningSeverity.Critical; - else if (pct >= 0.1) - w.Severity = PlanWarningSeverity.Warning; - } - } - } - - // Rule 8: Parallel thread skew (actual plans with per-thread stats) - // Only warn when there are enough rows to meaningfully distribute across threads - // Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators - if (!cfg.IsRuleDisabled(8) && node.PerThreadStats.Count > 1) - { - var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList(); - if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback - var totalRows = workerThreads.Sum(t => t.ActualRows); - var minRowsForSkew = workerThreads.Count * 1000; - if (totalRows >= minRowsForSkew) - { - var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First(); - var skewRatio = (double)maxThread.ActualRows / totalRows; - // At DOP 2, a 60/40 split is normal — use higher threshold - var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50; - if (skewRatio >= skewThreshold) - { - var message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much."; - var severity = PlanWarningSeverity.Warning; - - // Batch mode sorts produce all output on a single thread by design - // unless their parent is a batch mode Window Aggregate - if (node.PhysicalOp == "Sort" - && (node.ActualExecutionMode ?? node.ExecutionMode) == "Batch" - && node.Parent?.PhysicalOp != "Window Aggregate") - { - message += " Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate."; - severity = PlanWarningSeverity.Info; - } - else - { - // Add practical context — skew is often hard to fix - message += " Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help."; - } - - node.Warnings.Add(new PlanWarning - { - WarningType = "Parallel Skew", - Message = message, - Severity = severity - }); - } - } - } - - // Rule 10: Key Lookup / RID Lookup with residual predicate - // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true - if (!cfg.IsRuleDisabled(10) && node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) - { - var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; - if (!string.IsNullOrEmpty(node.Predicate)) - message += $" Predicate: {Truncate(node.Predicate, 200)}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "RID Lookup", - Message = message, - Severity = PlanWarningSeverity.Warning - }); - } - else if (!cfg.IsRuleDisabled(10) && node.Lookup) - { - var lookupMsg = "Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns."; - - // Show what columns the lookup is fetching - if (!string.IsNullOrEmpty(node.OutputColumns)) - lookupMsg += $"\nColumns fetched: {Truncate(node.OutputColumns, 200)}"; - - // Only call out the predicate if it actually filters rows - if (!string.IsNullOrEmpty(node.Predicate)) - { - var predicateFilters = node.HasActualStats && node.ActualExecutions > 0 - && node.ActualRows < node.ActualExecutions; - if (predicateFilters) - lookupMsg += $"\nResidual predicate (filtered {node.ActualExecutions - node.ActualRows:N0} rows): {Truncate(node.Predicate, 200)}"; - } - - lookupMsg += "\nTo eliminate the lookup, consider adding the needed columns as INCLUDE columns on the nonclustered index. This widens the index, so weigh the read benefit against write and storage overhead."; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Key Lookup", - Message = lookupMsg, - Severity = PlanWarningSeverity.Critical - }); - } - - // Rule 12: Non-SARGable predicate on scan - // Skip for 0-execution nodes — the operator never ran, so the warning is academic - var nonSargableReason = cfg.IsRuleDisabled(12) || (node.HasActualStats && node.ActualExecutions == 0) - ? null : DetectNonSargablePredicate(node); - if (nonSargableReason != null) - { - var nonSargableAdvice = nonSargableReason switch - { - "Implicit conversion (CONVERT_IMPLICIT)" => - "Implicit conversion (CONVERT_IMPLICIT) prevents an index seek. Match the parameter or variable data type to the column data type.", - "ISNULL/COALESCE wrapping column" => - "ISNULL/COALESCE wrapping a column prevents an index seek. Rewrite the predicate to avoid wrapping the column, e.g. use \"WHERE col = @val OR col IS NULL\" instead of \"WHERE ISNULL(col, '') = @val\".", - "Leading wildcard LIKE pattern" => - "Leading wildcard LIKE prevents an index seek — SQL Server must scan every row. If substring search performance is critical, consider a full-text index or a trigram-based approach.", - "CASE expression in predicate" => - "CASE expression in a predicate prevents an index seek. Rewrite using separate WHERE clauses combined with OR, or split into multiple queries.", - _ when nonSargableReason.StartsWith("Function call") => - $"{nonSargableReason} prevents an index seek. Remove the function from the column side — apply it to the parameter instead, or create a computed column with the expression and index that.", - _ => - $"{nonSargableReason} prevents an index seek, forcing a scan." - }; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Non-SARGable Predicate", - Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}", - Severity = PlanWarningSeverity.Warning - }); - } - - // Rule 11: Scan with residual predicate (skip if non-SARGable already flagged) - // A PROBE() alone is just a bitmap filter — not a real residual predicate. - // Skip for 0-execution nodes — the operator never ran - if (!cfg.IsRuleDisabled(11) && nonSargableReason == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate) && - !IsProbeOnly(node.Predicate) && !(node.HasActualStats && node.ActualExecutions == 0)) - { - var displayPredicate = StripProbeExpressions(node.Predicate); - var details = BuildScanImpactDetails(node, stmt); - var severity = PlanWarningSeverity.Warning; - - // Elevate to Critical if the scan dominates the plan - if (details.CostPct >= 90 || details.ElapsedPct >= 90) - severity = PlanWarningSeverity.Critical; - - var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; - if (!string.IsNullOrEmpty(details.Summary)) - message += $" {details.Summary}"; - - // #215 E2: if the statement is executing a dynamic cursor, that's usually - // the reason an index didn't get used. Call it out so the user looks there - // first rather than hunting for a missing index. - var isDynamicCursor = string.Equals(stmt.CursorActualType, "Dynamic", - StringComparison.OrdinalIgnoreCase); - if (isDynamicCursor) - message += " This query is running inside a dynamic cursor, which can prevent index usage; changing the cursor type (FAST_FORWARD / STATIC / KEYSET) often fixes scans like this without any indexing change."; - else - message += " Check that you have appropriate indexes."; - - // I/O waits specifically confirm the scan is hitting disk — elevate - if (HasSignificantIoWaits(stmt.WaitStats) && details.CostPct >= 50 - && severity != PlanWarningSeverity.Critical) - severity = PlanWarningSeverity.Critical; - - message += $"\nPredicate: {Truncate(displayPredicate, 200)}"; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Scan With Predicate", - Message = message, - Severity = severity - }); - } - - // Rule 32: Cardinality misestimate on expensive scan — likely preventing index usage - // When a scan dominates the plan AND the estimate is vastly higher than actual rows, - // the optimizer chose a scan because it thought it needed most of the table. - // With accurate estimates, it would likely seek instead. - if (!cfg.IsRuleDisabled(32) && node.HasActualStats && IsRowstoreScan(node) - && node.EstimateRows > 0 && node.ActualRows >= 0 && node.ActualRowsRead > 0) - { - var impact = BuildScanImpactDetails(node, stmt); - var overestimateRatio = node.EstimateRows / Math.Max(1.0, node.ActualRows); - var selectivity = (double)node.ActualRows / node.ActualRowsRead; - - // Fire when: scan is >= 50% of plan, estimate is >= 10x actual, and < 10% selectivity - if ((impact.CostPct >= 50 || impact.ElapsedPct >= 50) - && overestimateRatio >= 10.0 - && selectivity < 0.10) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Scan Cardinality Misestimate", - Message = $"Estimated {node.EstimateRows:N0} rows but only {node.ActualRows:N0} returned ({selectivity * 100:N3}% of {node.ActualRowsRead:N0} rows read). " + - $"The {overestimateRatio:N0}x overestimate likely caused the optimizer to choose a scan instead of a seek. " + - $"An index on the predicate columns could dramatically reduce I/O.", - Severity = PlanWarningSeverity.Critical - }); - } - } - - // Rule 33: Estimated plan CE guess detection — scans with telltale default selectivity - // When the optimizer uses a local variable or can't sniff, it falls back to density-based - // guesses: 30% (equality), 10% (inequality), 9% (LIKE/between), ~16.43% (sqrt(30%)), - // 1% (multi-inequality). On large tables, these guesses can hide the need for an index. - if (!cfg.IsRuleDisabled(33) && !node.HasActualStats && IsRowstoreScan(node) - && node.TableCardinality >= 100_000 && node.EstimateRows > 0 - && !string.IsNullOrEmpty(node.Predicate)) - { - var impact = BuildScanImpactDetails(node, stmt); - if (impact.CostPct >= 50) - { - var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality); - if (guessDesc != null) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Estimated Plan CE Guess", - Message = $"Estimated {node.EstimateRows:N0} rows from {node.TableCardinality:N0} row table — {guessDesc}. " + - $"The optimizer may be using a default guess instead of accurate statistics. " + - $"If actual selectivity is much lower, an index on the predicate columns could help significantly.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // Rule 34: Bare scan with narrow output — NC index or columnstore candidate. - // When a Clustered Index Scan or heap Table Scan reads the full table with no - // predicate but only outputs a few columns, a narrower nonclustered index could - // cover the query with far less I/O. For analytical workloads, columnstore may - // be a better fit. - var isBareScanCandidate = (node.PhysicalOp == "Clustered Index Scan" || node.PhysicalOp == "Table Scan") - && !node.Lookup - && string.IsNullOrEmpty(node.Predicate) - && !string.IsNullOrEmpty(node.OutputColumns); - if (!cfg.IsRuleDisabled(34) && isBareScanCandidate) - { - var colCount = node.OutputColumns!.Split(',').Length; - var isSignificant = node.HasActualStats - ? GetOperatorOwnElapsedMs(node) > 0 - : node.CostPercent >= 20; - - if (isSignificant) - { - var scanKind = node.PhysicalOp == "Clustered Index Scan" - ? "Clustered index scan" - : "Heap table scan"; - - if (colCount <= 3) - { - // Narrow output: a nonclustered rowstore index can cover this cheaply. - var indexAdvice = node.PhysicalOp == "Clustered Index Scan" - ? "Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure." - : "Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure."; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Bare Scan", - Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} column(s): {Truncate(node.OutputColumns, 200)}. {indexAdvice} For analytical workloads, a columnstore index may be a better fit.", - Severity = PlanWarningSeverity.Warning - }); - } - else - { - // Wider output: rowstore NC index isn't a great fit (would have to - // carry too many columns), but columnstore doesn't care about column - // count. Suggest it for analytical / aggregate-style workloads. - node.Warnings.Add(new PlanWarning - { - WarningType = "Bare Scan", - Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} columns. A nonclustered rowstore index isn't a great fit for wide outputs, but if this is an analytical or aggregate-style query, a columnstore index (CCI or NCCI) can scan the same data far more cheaply — column count doesn't penalize columnstore the way it does rowstore indexes.", - Severity = PlanWarningSeverity.Warning - }); - } - } - } - - // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert) - if (!cfg.IsRuleDisabled(13) && node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues)) - { - var hasMismatch = node.DefinedValues.Contains("GetRangeWithMismatchedTypes", StringComparison.OrdinalIgnoreCase); - var hasConvert = node.DefinedValues.Contains("GetRangeThroughConvert", StringComparison.OrdinalIgnoreCase); - - if (hasMismatch || hasConvert) - { - var reason = hasMismatch - ? "Mismatched data types between the column and the parameter/literal. SQL Server is converting every row to compare, preventing index seeks. Match your data types — don't pass nvarchar to a varchar column, or int to a bigint column." - : "CONVERT/CAST wrapping a column in the predicate. SQL Server is converting every row to compare, preventing index seeks. Match your data types — convert the parameter/literal instead of the column."; - - node.Warnings.Add(new PlanWarning - { - WarningType = "Data Type Mismatch", - Message = reason, - Severity = PlanWarningSeverity.Warning - }); - } - } - - // Rule 14: Lazy Table Spool unfavorable rebind/rewind ratio - // Rebinds = cache misses (child re-executes), rewinds = cache hits (reuse cached result) - // Exclude Lazy Index Spools: they cache by correlated parameter value (like a hash table) - // so rebind/rewind counts are unreliable. See https://www.sql.kiwi/2025/02/lazy-index-spool/ - if (!cfg.IsRuleDisabled(14) && node.LogicalOp == "Lazy Spool" - && !node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase)) - { - var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds; - var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; - var source = node.HasActualStats ? "actual" : "estimated"; - if (rebinds > 100 && rewinds < rebinds * 5) - { - var severity = rewinds < rebinds - ? PlanWarningSeverity.Critical - : PlanWarningSeverity.Warning; + /// + private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); - var ratio = rewinds > 0 - ? $"{rewinds / rebinds:F1}x rewinds (cache hits) per rebind (cache miss)" - : "no rewinds (cache hits) at all"; - node.Warnings.Add(new PlanWarning - { - WarningType = "Lazy Spool Ineffective", - Message = $"Lazy spool has low cache hit ratio ({source}): {rebinds:N0} rebinds (cache misses), {rewinds:N0} rewinds (cache hits) — {ratio}. The spool is caching results but rarely reusing them, adding overhead for no benefit.", - Severity = severity - }); - } - } - - // Rule 15: Join OR clause - // Pattern: Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation → [Compute Scalar] → 2+ Constant Scans - if (!cfg.IsRuleDisabled(15) && node.PhysicalOp == "Concatenation") - { - var constantScanBranches = node.Children - .Count(c => c.PhysicalOp == "Constant Scan" || - (c.PhysicalOp == "Compute Scalar" && - c.Children.Any(gc => gc.PhysicalOp == "Constant Scan"))); - - if (constantScanBranches >= 2 && IsOrExpansionChain(node)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Join OR Clause", - Message = $"OR in a join predicate. SQL Server rewrote the OR as {constantScanBranches} separate lookups, each evaluated independently — this multiplies the work on the inner side. Rewrite as separate queries joined with UNION ALL. For example, change \"FROM a JOIN b ON a.x = b.x OR a.y = b.y\" to \"FROM a JOIN b ON a.x = b.x UNION ALL FROM a JOIN b ON a.y = b.y\".", - Severity = PlanWarningSeverity.Warning - }); - } - } - - // Rule 16: Nested Loops high inner-side execution count - // Deep analysis: combine execution count + outer estimate mismatch + inner cost - if (!cfg.IsRuleDisabled(16) && node.PhysicalOp == "Nested Loops" && - node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && - !node.IsAdaptive && - node.Children.Count >= 2) - { - var outerChild = node.Children[0]; - var innerChild = node.Children[1]; - - if (innerChild.HasActualStats && innerChild.ActualExecutions > 100000) - { - var dop = stmt.DegreeOfParallelism > 0 ? stmt.DegreeOfParallelism : 1; - var details = new List(); - - // Core fact - details.Add($"Nested Loops inner side executed {innerChild.ActualExecutions:N0} times (DOP {dop})."); - - // Outer side estimate mismatch — explains WHY the optimizer chose NL - if (outerChild.HasActualStats && outerChild.EstimateRows > 0) - { - var outerExecs = outerChild.ActualExecutions > 0 ? outerChild.ActualExecutions : 1; - var outerActualPerExec = (double)outerChild.ActualRows / outerExecs; - var outerRatio = outerActualPerExec / outerChild.EstimateRows; - if (outerRatio >= 10.0) - { - details.Add($"Outer side: estimated {outerChild.EstimateRows:N0} rows, actual {outerActualPerExec:N0} ({outerRatio:F0}x underestimate). The optimizer chose Nested Loops expecting far fewer iterations."); - } - } - - // Inner side cost — reads and time spent doing the repeated work - long innerReads = SumSubtreeReads(innerChild); - if (innerReads > 0) - details.Add($"Inner side total: {innerReads:N0} logical reads."); - - if (innerChild.ActualElapsedMs > 0) - { - var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - if (stmtMs > 0) - { - var pct = (double)innerChild.ActualElapsedMs / stmtMs * 100; - details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms ({pct:N0}% of statement)."); - } - else - { - details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms."); - } - } - - // Cause/recommendation - var hasParams = stmt.Parameters.Count > 0; - if (hasParams) - details.Add("This may be caused by parameter sniffing — the optimizer chose Nested Loops based on a sniffed value that produced far fewer outer rows."); - else - details.Add("Consider whether a hash or merge join would be more appropriate for this row count."); - - node.Warnings.Add(new PlanWarning - { - WarningType = "Nested Loops High Executions", - Message = string.Join(" ", details), - Severity = innerChild.ActualExecutions > 1000000 - ? PlanWarningSeverity.Critical - : PlanWarningSeverity.Warning - }); - } - // Estimated plans: the optimizer knew the row count and chose Nested Loops - // deliberately — don't second-guess it without actual execution data. - } - - // Rule 17: Many-to-many Merge Join - // In actual plans, the Merge Join operator reports logical reads when the worktable is used. - // When ActualLogicalReads is 0, the worktable wasn't hit and the warning is noise. - if (!cfg.IsRuleDisabled(17) && node.ManyToMany && node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase) && - (!node.HasActualStats || node.ActualLogicalReads > 0)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Many-to-Many Merge Join", - Message = node.HasActualStats - ? $"Many-to-many Merge Join — SQL Server created a worktable in TempDB ({node.ActualLogicalReads:N0} logical reads) because both sides have duplicate values in the join columns." - : "Many-to-many Merge Join — SQL Server will create a worktable in TempDB because both sides have duplicate values in the join columns.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Rule 22: Table variables (Object name starts with @) - if (!cfg.IsRuleDisabled(22) && !string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith("@")) - { - var isModificationOp = node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) - || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase); - - node.Warnings.Add(new PlanWarning - { - WarningType = "Table Variable", - Message = isModificationOp - ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution." - : "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", - Severity = isModificationOp ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - - // Rule 23: Table-valued functions - if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function") - { - var funcName = node.ObjectName ?? node.PhysicalOp; - node.Warnings.Add(new PlanWarning - { - WarningType = "Table-Valued Function", - Message = $"Table-valued function: {funcName}. Multi-statement TVFs have no statistics — SQL Server guesses 1 row (pre-2017) or 100 rows (2017+) regardless of actual size. Rewrite as an inline table-valued function if possible, or dump the function results into a #temp table and join to that instead.", - Severity = PlanWarningSeverity.Warning - }); - } - - // Rule 24: Top above a scan - // Detects Top or Top N Sort operators feeding from a scan. This often means the - // query is scanning the entire table/index and sorting just to return a few rows, - // when an appropriate index could satisfy the request directly. - if (!cfg.IsRuleDisabled(24)) - { - var isTop = node.PhysicalOp == "Top"; - var isTopNSort = node.LogicalOp == "Top N Sort"; - - if ((isTop || isTopNSort) && node.Children.Count > 0) - { - // Walk through pass-through operators below the Top to find the scan - var scanCandidate = node.Children[0]; - while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism") - && scanCandidate.Children.Count > 0) - scanCandidate = scanCandidate.Children[0]; - - if (IsScanOperator(scanCandidate)) - { - var topLabel = isTopNSort ? "Top N Sort" : "Top"; - var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2 - && node.Parent.Children[1] == node; - var innerNote = onInner - ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row." - : ""; - var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate) - ? " The scan has a residual predicate, so it may read many rows before the Top is satisfied." - : ""; - node.Warnings.Add(new PlanWarning - { - WarningType = "Top Above Scan", - Message = $"{topLabel} reads from {FormatNodeRef(scanCandidate)}.{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.", - Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - } - - // Rule 26: Row Goal (informational) — optimizer reduced estimate due to TOP/EXISTS/IN - // Only surface on data access operators (seeks/scans) where the row goal actually matters - var isDataAccess = node.PhysicalOp != null && - (node.PhysicalOp.Contains("Scan") || node.PhysicalOp.Contains("Seek")); - if (!cfg.IsRuleDisabled(26) && isDataAccess && - node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 && - node.EstimateRowsWithoutRowGoal > node.EstimateRows) - { - var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows; - // Require at least a 2x reduction to be worth mentioning — "1 to 1" or - // tiny floating-point differences that display identically are noise - if (reduction >= 2.0) - { - // If we have actual stats, check whether the row goal prediction was correct. - // When actual rows ≤ the row goal estimate, the optimizer stopped early as planned — benign. - var rowGoalWorked = false; - if (node.HasActualStats) - { - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; - var actualPerExec = (double)node.ActualRows / executions; - rowGoalWorked = actualPerExec <= node.EstimateRows; - } - - if (!rowGoalWorked) - { - // Try to identify the specific row goal cause from the statement text - var cause = IdentifyRowGoalCause(stmt.StatementText); - - node.Warnings.Add(new PlanWarning - { - WarningType = "Row Goal", - Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to {cause}. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.", - Severity = PlanWarningSeverity.Info - }); - } - } - } - - // Rule 28: Row Count Spool — NOT IN with nullable column - // Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate, - // and statement text contains NOT IN - if (!cfg.IsRuleDisabled(28) && node.PhysicalOp?.Contains("Row Count Spool") == true) - { - var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; - if (rewinds > 10000 && HasNotInPattern(node, stmt)) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "NOT IN with Nullable Column", - Message = $"Row Count Spool with {rewinds:N0} rewinds. This pattern occurs when NOT IN is used with a nullable column — SQL Server cannot use an efficient Anti Semi Join because it must check for NULL values on every outer row. Rewrite as NOT EXISTS, or add WHERE column IS NOT NULL to the subquery.", - Severity = rewinds > 1_000_000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning - }); - } - } - - // Rule 29: Enhance implicit conversion warnings — Seek Plan is more severe - // Skip for 0-execution nodes — the operator never ran - if (!cfg.IsRuleDisabled(29) && !(node.HasActualStats && node.ActualExecutions == 0)) - foreach (var w in node.Warnings.ToList()) - { - if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) - { - w.Severity = PlanWarningSeverity.Critical; - w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}"; - } - } - - // Rule 35: Expensive Operator — always show operators that take a significant - // share of statement time even when no other rule has something to say. Joe - // (#215 C8) wanted expensive scans that the tool had nothing to suggest on - // to still surface as top items. Threshold: self-time >= 20% of statement - // elapsed. Only emits if no other warning is already on the node to avoid - // doubling up. The benefit % is just the self-time share. - if (!cfg.IsRuleDisabled(35) && node.HasActualStats && node.Warnings.Count == 0 - && stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) - { - var selfMs = GetOperatorOwnElapsedMs(node); - var pct = (double)selfMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; - if (pct >= 20.0) - { - node.Warnings.Add(new PlanWarning - { - WarningType = "Expensive Operator", - Message = $"{node.PhysicalOp} took {selfMs:N0}ms ({pct:N1}% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be?", - Severity = pct >= 50 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning, - MaxBenefitPercent = Math.Round(Math.Min(100.0, pct), 1) - }); - } - } - } - - /// - /// Detects the NOT IN with nullable column pattern: statement has NOT IN, - /// and a nearby Nested Loops Anti Semi Join has an IS NULL residual predicate. - /// Checks ancestors and their children (siblings of ancestors) since the IS NULL - /// predicate may be on a sibling Anti Semi Join rather than a direct parent. - /// - private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) - { - // Check statement text for NOT IN - if (string.IsNullOrEmpty(stmt.StatementText) || - !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) - return false; - - // Walk up the tree checking ancestors and their children - var parent = spoolNode.Parent; - while (parent != null) - { - if (IsAntiSemiJoinWithIsNull(parent)) - return true; - - // Check siblings: the IS NULL predicate may be on a sibling Anti Semi Join - // (e.g. outer NL Anti Semi Join has two children: inner NL Anti Semi Join + Row Count Spool) - foreach (var sibling in parent.Children) - { - if (sibling != spoolNode && IsAntiSemiJoinWithIsNull(sibling)) - return true; - } - - parent = parent.Parent; - } - - return false; - } - - private static bool IsAntiSemiJoinWithIsNull(PlanNode node) => - node.PhysicalOp == "Nested Loops" && - node.LogicalOp.Contains("Anti Semi", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(node.Predicate) && - node.Predicate.Contains("IS NULL", StringComparison.OrdinalIgnoreCase); - - /// - /// Returns true for rowstore scan operators (Index Scan, Clustered Index Scan, - /// Table Scan). Excludes columnstore scans, spools, and constant scans. - /// - private static bool IsRowstoreScan(PlanNode node) - { - return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Columnstore", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Returns true when the predicate contains ONLY PROBE() bitmap filter(s) - /// with no real residual predicate. PROBE alone is a bitmap filter pushed - /// down from a hash join — not interesting by itself. If a real predicate - /// exists alongside PROBE (e.g. "[col]=(1) AND PROBE(...)"), returns false. - /// - private static bool IsProbeOnly(string predicate) - { - // Strip all PROBE(...) expressions — PROBE args can contain nested parens - var stripped = Regex.Replace(predicate, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", - RegexOptions.IgnoreCase).Trim(); - - // Remove leftover AND/OR connectors and whitespace - stripped = Regex.Replace(stripped, @"\b(AND|OR)\b", "", RegexOptions.IgnoreCase).Trim(); - - // If nothing meaningful remains, it was PROBE-only - return stripped.Length == 0; - } - - /// - /// Strips PROBE(...) bitmap filter expressions from a predicate for display, - /// leaving only the real residual predicate columns. - /// - private static string StripProbeExpressions(string predicate) - { - var stripped = Regex.Replace(predicate, @"\s*AND\s+PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", - RegexOptions.IgnoreCase); - stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*AND\s+", "", - RegexOptions.IgnoreCase); - stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "", - RegexOptions.IgnoreCase); - return stripped.Trim(); - } - - /// - /// Returns true for any scan operator including columnstore. - /// Excludes spools and constant scans. - /// - private static bool IsScanOperator(PlanNode node) - { - return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase); - } - - - /// - /// Detects non-SARGable patterns in scan predicates. - /// Returns a description of the issue, or null if the predicate is fine. - /// - private static string? DetectNonSargablePredicate(PlanNode node) - { - if (string.IsNullOrEmpty(node.Predicate)) - return null; - - // Only check rowstore scan operators — columnstore is designed to be scanned - if (!IsRowstoreScan(node)) - return null; - - var predicate = node.Predicate; - - // CASE expression in predicate — check first because CASE bodies - // often contain CONVERT_IMPLICIT that isn't the root cause - if (CaseInPredicateRegex.IsMatch(predicate)) - return "CASE expression in predicate"; - - // CONVERT_IMPLICIT — most common non-SARGable pattern - if (predicate.Contains("CONVERT_IMPLICIT", StringComparison.OrdinalIgnoreCase)) - return "Implicit conversion (CONVERT_IMPLICIT)"; - - // ISNULL / COALESCE wrapping column - if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) - return "ISNULL/COALESCE wrapping column"; - - // Common function calls on columns — but only if the function wraps a column, - // not a parameter/variable. Split on comparison operators to check which side - // the function is on. Predicate format: [db].[schema].[table].[col]>func(...) - var funcMatch = FunctionInPredicateRegex.Match(predicate); - if (funcMatch.Success) - { - var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); - if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch)) - return $"Function call ({funcName}) on column"; - } - - // Leading wildcard LIKE - if (LeadingWildcardLikeRegex.IsMatch(predicate)) - return "Leading wildcard LIKE pattern"; - - return null; - } - - /// - /// Checks whether a function call in a predicate is on the column side of the comparison. - /// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var]) - /// If the function is only on the parameter/literal side, it's still SARGable. - /// - private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch) - { - // Find the comparison operator that splits the predicate into left/right sides. - // Operators in ScalarString: >=, <=, <>, >, <, = - var compMatch = Regex.Match(predicate, @"(?])([<>=!]{1,2})(?![<>=])"); - if (!compMatch.Success) - return true; // No comparison found — can't determine side, assume worst case - - var compPos = compMatch.Index; - var funcPos = funcMatch.Index; - - // Determine which side the function is on - var funcSide = funcPos < compPos ? "left" : "right"; - - // Check if that side also contains a column reference [...].[...].[...] - string side = funcSide == "left" - ? predicate[..compPos] - : predicate[(compPos + compMatch.Length)..]; - - // Column references are multi-part bracket-qualified: [schema].[table].[column] - // Variables are [@var] or [@var] — single bracket pair with @ prefix. - // Match [identifier].[identifier] (at least two dotted parts) to distinguish columns. - return Regex.IsMatch(side, @"\[[^\]@]+\]\.\["); - } - - /// - /// Verifies the OR expansion chain walking up from a Concatenation node: - /// Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation - /// - private static bool IsOrExpansionChain(PlanNode concatenationNode) - { - // Walk up, skipping Compute Scalar - var parent = concatenationNode.Parent; - while (parent != null && parent.PhysicalOp == "Compute Scalar") - parent = parent.Parent; - - // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort") - if (parent == null || parent.LogicalOp != "Top N Sort") - return false; - - // Walk up to Merge Interval - parent = parent.Parent; - if (parent == null || parent.PhysicalOp != "Merge Interval") - return false; - - // Walk up to Nested Loops - parent = parent.Parent; - if (parent == null || parent.PhysicalOp != "Nested Loops") - return false; - - // If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN - // subquery pattern (Merge Interval optimizing range lookups), not an OR expansion - var nlParent = parent.Parent; - if (nlParent != null && nlParent.LogicalOp != null && - nlParent.LogicalOp.Contains("Semi")) - return false; - - return true; - } - - /// - /// Finds Sort and Hash Match operators in the tree that consume memory. - /// - /// - /// Returns true if the plan contains an adaptive join that executed as a Nested Loop. - /// Indicates a memory grant was sized for the hash alternative but never needed. - /// - private static bool HasAdaptiveJoinChoseNestedLoop(PlanNode node) - { - if (node.IsAdaptive && node.ActualJoinType != null - && node.ActualJoinType.Contains("Nested", StringComparison.OrdinalIgnoreCase)) - return true; - - foreach (var child in node.Children) - if (HasAdaptiveJoinChoseNestedLoop(child)) - return true; - - return false; - } - - private static void FindMemoryConsumers(PlanNode node, List consumers) - { - // Collect all consumers first, then sort by row count descending - var raw = new List<(string Label, double Rows)>(); - FindMemoryConsumersRecursive(node, raw); - - foreach (var (label, _) in raw.OrderByDescending(c => c.Rows)) - consumers.Add(label); - } - - private static void FindMemoryConsumersRecursive(PlanNode node, List<(string Label, double Rows)> consumers) - { - if (node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase)) - { - var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; - var rows = node.HasActualStats - ? $"{node.ActualRows:N0} actual rows" - : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add(($"Sort (Node {node.NodeId}, {rows})", rowCount)); - } - else if (node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) - { - var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; - var rows = node.HasActualStats - ? $"{node.ActualRows:N0} actual rows" - : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add(($"Hash Match (Node {node.NodeId}, {rows})", rowCount)); - } - - foreach (var child in node.Children) - FindMemoryConsumersRecursive(child, consumers); - } - - /// - /// Calculates an operator's own elapsed time by subtracting child time. - /// In batch mode, operator times are self-contained (exclusive). - /// In row mode, times are cumulative (include all children below). - /// For parallel plans, we calculate self-time per-thread then take the max, - /// avoiding cross-thread subtraction errors. - /// Exchange operators accumulate downstream wait time (e.g. from spilling - /// children) so their self-time is unreliable — see sql.kiwi/2021/03. - /// - internal static long GetOperatorOwnElapsedMs(PlanNode node) - { - if (node.ActualExecutionMode == "Batch") - return node.ActualElapsedMs; - - // Parallel plan with per-thread data: calculate self-time per thread - if (node.PerThreadStats.Count > 1) - return GetPerThreadOwnElapsed(node); - - // Serial row mode: subtract all direct children's elapsed time - return GetSerialOwnElapsed(node); - } - - /// - /// Per-thread self-time calculation for parallel row mode operators. - /// For each thread: self = parent_elapsed[t] - sum(children_elapsed[t]). - /// Returns max across threads. - /// - private static long GetPerThreadOwnElapsed(PlanNode node) - { - // Build lookup: threadId -> parent elapsed for this node - var parentByThread = new Dictionary(); - foreach (var ts in node.PerThreadStats) - parentByThread[ts.ThreadId] = ts.ActualElapsedMs; - - // Build lookup: threadId -> sum of all direct children's elapsed - var childSumByThread = new Dictionary(); - foreach (var child in node.Children) - { - var childNode = child; - - // Exchange operators have unreliable times — look through to their child - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - childNode = child.Children.OrderByDescending(c => c.ActualElapsedMs).First(); - - foreach (var ts in childNode.PerThreadStats) - { - childSumByThread.TryGetValue(ts.ThreadId, out var existing); - childSumByThread[ts.ThreadId] = existing + ts.ActualElapsedMs; - } - } - - // Self-time per thread = parent - children, take max across threads - var maxSelf = 0L; - foreach (var (threadId, parentMs) in parentByThread) - { - childSumByThread.TryGetValue(threadId, out var childMs); - var self = Math.Max(0, parentMs - childMs); - if (self > maxSelf) maxSelf = self; - } - - return maxSelf; - } - - /// - /// Max per-thread self-CPU for this operator. - /// Parallel: for each thread, self_cpu = thread_cpu - Σ same-thread child cpu; take max. - /// Serial / single-thread: operator_cpu - Σ effective child cpu. - /// Needed for external-wait benefit scoring (Joe's formula). - /// - internal static long GetOperatorMaxThreadOwnCpuMs(PlanNode node) - { - if (!node.HasActualStats || node.ActualCPUMs <= 0) return 0; - - if (node.PerThreadStats.Count > 1) - { - var parentByThread = new Dictionary(); - foreach (var ts in node.PerThreadStats) - parentByThread[ts.ThreadId] = ts.ActualCPUMs; - - var childSumByThread = new Dictionary(); - foreach (var child in node.Children) - { - var childNode = child; - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - childNode = child.Children.OrderByDescending(c => c.ActualCPUMs).First(); - foreach (var ts in childNode.PerThreadStats) - { - childSumByThread.TryGetValue(ts.ThreadId, out var existing); - childSumByThread[ts.ThreadId] = existing + ts.ActualCPUMs; - } - } - - var maxSelf = 0L; - foreach (var (threadId, parentCpu) in parentByThread) - { - childSumByThread.TryGetValue(threadId, out var childCpu); - var self = Math.Max(0, parentCpu - childCpu); - if (self > maxSelf) maxSelf = self; - } - return maxSelf; - } - - // Serial: operator_cpu - Σ effective child cpu - var totalChildCpu = 0L; - foreach (var child in node.Children) - totalChildCpu += GetEffectiveChildCpuMs(child); - return Math.Max(0, node.ActualCPUMs - totalChildCpu); - } - - private static long GetEffectiveChildCpuMs(PlanNode child) - { - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - return child.Children.Max(GetEffectiveChildCpuMs); - if (child.ActualCPUMs > 0) - return child.ActualCPUMs; - if (child.Children.Count == 0) - return 0; - var sum = 0L; - foreach (var grandchild in child.Children) - sum += GetEffectiveChildCpuMs(grandchild); - return sum; - } - - /// - /// Serial row mode self-time: subtract all direct children's effective elapsed. - /// Pass-through operators (Compute Scalar, etc.) don't carry runtime stats — - /// look through them to the first descendant that does. Exchange children - /// use max-child elapsed because exchange times are unreliable. - /// - private static long GetSerialOwnElapsed(PlanNode node) - { - var totalChildElapsed = 0L; - foreach (var child in node.Children) - totalChildElapsed += GetEffectiveChildElapsedMs(child); - - return Math.Max(0, node.ActualElapsedMs - totalChildElapsed); - } - - /// - /// Returns the elapsed time a child contributes to its parent's subtree. - /// Looks through pass-through operators (Compute Scalar, Parallelism exchange) - /// that don't carry reliable runtime stats. - /// - private static long GetEffectiveChildElapsedMs(PlanNode child) - { - // Exchange operators: unreliable times, use max child - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - return child.Children.Max(GetEffectiveChildElapsedMs); - - // Batch mode pipelines — each operator's elapsed stands alone rather than - // rolling up its descendants the way row-mode does. For a parent computing - // self-time above a batch-mode subtree, subtract the whole pipeline's time - // (Joe #215 D1: Parallelism gather-streams above three batch operators). - var mode = child.ActualExecutionMode ?? child.ExecutionMode; - if (mode == "Batch" && child.HasActualStats) - return SumBatchSubtreeElapsedMs(child); - - // Child has its own stats: use them - if (child.ActualElapsedMs > 0) - return child.ActualElapsedMs; - - // No stats (Compute Scalar and similar): look through to descendants - if (child.Children.Count == 0) - return 0; - - var sum = 0L; - foreach (var grandchild in child.Children) - sum += GetEffectiveChildElapsedMs(grandchild); - return sum; - } - - /// - /// Sums ActualElapsedMs across a contiguous batch-mode subtree (stops at - /// Parallelism exchange zone boundaries). Batch operators pipeline — elapsed - /// times are standalone, not cumulative — so summing gives the total work the - /// zone did, which is what a row-mode parent above the zone should subtract - /// to get its own self-time. - /// - private static long SumBatchSubtreeElapsedMs(PlanNode node) - { - long sum = node.ActualElapsedMs; - foreach (var child in node.Children) - { - // Zone boundary — stop summing - if (child.PhysicalOp == "Parallelism") continue; - - var childMode = child.ActualExecutionMode ?? child.ExecutionMode; - if (childMode == "Batch" && child.HasActualStats) - sum += SumBatchSubtreeElapsedMs(child); - else - sum += GetEffectiveChildElapsedMs(child); - } - return sum; - } - - /// - /// Calculates a Parallelism (exchange) operator's own elapsed time. - /// Exchange times are unreliable — they accumulate wait time caused by - /// downstream operators (e.g. spilling sorts). This returns a best-effort - /// value but callers should treat it with caution. - /// - private static long GetParallelismOperatorElapsedMs(PlanNode node) - { - if (node.Children.Count == 0) - return node.ActualElapsedMs; - - if (node.PerThreadStats.Count > 1) - return GetPerThreadOwnElapsed(node); - - var maxChildElapsed = node.Children.Max(c => c.ActualElapsedMs); - return Math.Max(0, node.ActualElapsedMs - maxChildElapsed); - } - - /// - /// Quantifies the cost of work below a Filter operator by summing child subtree metrics. - /// Shows how many rows, reads, and elapsed time were spent producing rows that the - /// Filter then discarded. - /// - private static string QuantifyFilterImpact(PlanNode filterNode) - { - if (filterNode.Children.Count == 0) - return ""; - - var parts = new List(); - - // Rows input vs output — how many rows did the filter discard? - var inputRows = filterNode.Children.Sum(c => c.ActualRows); - if (filterNode.HasActualStats && inputRows > 0 && filterNode.ActualRows < inputRows) - { - var discarded = inputRows - filterNode.ActualRows; - var pct = (double)discarded / inputRows * 100; - parts.Add($"{discarded:N0} of {inputRows:N0} rows discarded ({pct:N0}%)"); - } - - // Logical reads across the entire child subtree - long totalReads = 0; - foreach (var child in filterNode.Children) - totalReads += SumSubtreeReads(child); - if (totalReads > 0) - parts.Add($"{totalReads:N0} logical reads below"); - - // Elapsed time: use the direct child's time (cumulative in row mode, includes its children) - var childElapsed = filterNode.Children.Max(c => c.ActualElapsedMs); - if (childElapsed > 0) - parts.Add($"{childElapsed:N0}ms elapsed below"); - - if (parts.Count == 0) - return ""; - - return string.Join("\n", parts.Select(p => "• " + p)); - } - - /// - /// Detects well-known CE default selectivity guesses by comparing EstimateRows to TableCardinality. - /// Returns a description of the guess pattern, or null if no known pattern matches. - /// - private static string? DetectCeGuess(double estimateRows, double tableCardinality) - { - if (tableCardinality <= 0) return null; - var selectivity = estimateRows / tableCardinality; - - // Known CE guess selectivities with a 2% tolerance band - return selectivity switch - { - >= 0.29 and <= 0.31 => $"matches the 30% equality guess ({selectivity * 100:N1}%)", - >= 0.098 and <= 0.102 => $"matches the 10% inequality guess ({selectivity * 100:N1}%)", - >= 0.088 and <= 0.092 => $"matches the 9% LIKE/BETWEEN guess ({selectivity * 100:N1}%)", - >= 0.155 and <= 0.175 => $"matches the ~16.4% compound predicate guess ({selectivity * 100:N1}%)", - >= 0.009 and <= 0.011 => $"matches the 1% multi-inequality guess ({selectivity * 100:N1}%)", - _ => null - }; - } - - private static long SumSubtreeReads(PlanNode node) - { - long reads = node.ActualLogicalReads; - foreach (var child in node.Children) - reads += SumSubtreeReads(child); - return reads; - } - - /// - private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); - - /// - /// Builds impact details for a scan node: what % of plan time/cost it represents, - /// and what fraction of rows survived filtering. - /// - private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement stmt) - { - var parts = new List(); - - // % of plan cost - double costPct = 0; - if (stmt.StatementSubTreeCost > 0 && node.EstimatedTotalSubtreeCost > 0) - { - costPct = node.EstimatedTotalSubtreeCost / stmt.StatementSubTreeCost * 100; - if (costPct >= 50) - parts.Add($"This scan is {costPct:N0}% of the plan cost."); - } - - // % of elapsed time (actual plans) - double elapsedPct = 0; - if (node.HasActualStats && node.ActualElapsedMs > 0 && - stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) - { - elapsedPct = (double)node.ActualElapsedMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; - if (elapsedPct >= 50) - parts.Add($"This scan took {elapsedPct:N0}% of elapsed time."); - } - - // Row selectivity: rows returned vs rows read (actual) or vs table cardinality (estimated) - if (node.HasActualStats && node.ActualRowsRead > 0 && node.ActualRows < node.ActualRowsRead) - { - var selectivity = (double)node.ActualRows / node.ActualRowsRead * 100; - if (selectivity < 10) - parts.Add($"Only {selectivity:N3}% of rows survived filtering ({node.ActualRows:N0} of {node.ActualRowsRead:N0})."); - } - else if (!node.HasActualStats && node.TableCardinality > 0 && node.EstimateRows < node.TableCardinality) - { - var selectivity = node.EstimateRows / node.TableCardinality * 100; - if (selectivity < 10) - parts.Add($"Only {selectivity:N1}% of rows estimated to survive filtering."); - } - - return new ScanImpact(costPct, elapsedPct, parts.Count > 0 ? string.Join(" ", parts) : null); - } - - /// Determines whether a row estimate mismatch actually caused observable harm. - /// Returns a description of the harm, or null if the bad estimate is benign. - /// - /// False-positive suppression (from reviewer feedback): - /// - Root node (no parent) — nothing above to be harmed by the bad estimate - /// - Sort that didn't spill — the estimate was wrong but no harm done - /// - /// Real harm: - /// - The node itself has a spill warning (bad estimate → bad memory grant) - /// - The node is a join (wrong join type or excessive inner side work) - /// - A parent join may have chosen the wrong strategy based on bad row count - /// - A parent Sort/Hash spilled (downstream estimate caused bad grant) - /// - /// - /// Returns a short label describing what a wait type means (e.g., "I/O — reading from disk"). - /// Public for use by UI components that annotate wait stats inline. - /// - public static string GetWaitLabel(string waitType) - { - var wt = waitType.ToUpperInvariant(); - return wt switch - { - _ when wt.StartsWith("PAGEIOLATCH") => "I/O — reading data from disk", - _ when wt.Contains("IO_COMPLETION") => "I/O — spills to TempDB or eager writes", - _ when wt == "SOS_SCHEDULER_YIELD" => "CPU — scheduler yielding", - _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "parallelism — thread skew", - _ when wt.StartsWith("CXSYNC") => "parallelism — exchange synchronization", - _ when wt == "HTBUILD" => "hash — building hash table", - _ when wt == "HTDELETE" => "hash — cleaning up hash table", - _ when wt == "HTREPARTITION" => "hash — repartitioning", - _ when wt.StartsWith("HT") => "hash operation", - _ when wt == "BPSORT" => "batch sort", - _ when wt == "BMPBUILD" => "bitmap filter build", - _ when wt.Contains("MEMORY_ALLOCATION_EXT") => "memory allocation", - _ when wt.StartsWith("PAGELATCH") => "page latch — in-memory contention", - _ when wt.StartsWith("LATCH_") => "latch contention", - _ when wt.StartsWith("LCK_") => "lock contention", - _ when wt == "LOGBUFFER" => "transaction log writes", - _ when wt == "ASYNC_NETWORK_IO" => "network — client not consuming results", - _ when wt == "SOS_PHYS_PAGE_CACHE" => "physical page cache contention", - _ => "" - }; - } - - /// - /// Returns true if the statement has significant I/O waits (PAGEIOLATCH_*, IO_COMPLETION). - /// Used for severity elevation decisions where I/O specifically indicates disk access. - /// Thresholds: I/O waits >= 20% of total wait time AND >= 100ms absolute. - /// - private static bool HasSignificantIoWaits(List waits) - { - if (waits.Count == 0) - return false; - - var totalMs = waits.Sum(w => w.WaitTimeMs); - if (totalMs == 0) - return false; - - long ioMs = 0; - foreach (var w in waits) - { - var wt = w.WaitType.ToUpperInvariant(); - if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION")) - ioMs += w.WaitTimeMs; - } - - var pct = (double)ioMs / totalMs * 100; - return ioMs >= 100 && pct >= 20; - } - - private static bool AllocatesResources(PlanNode node) - { - // Operators that get memory grants or allocate structures based on row estimates. - // Hash Match (hash table), Sort (sort buffer), Spool (worktable). - var op = node.PhysicalOp; - return op.StartsWith("Hash", StringComparison.OrdinalIgnoreCase) - || op.StartsWith("Sort", StringComparison.OrdinalIgnoreCase) - || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); - } - - private static string? AssessEstimateHarm(PlanNode node, double ratio) - { - // Root node: no parent to harm. - // The synthetic statement root (SELECT/INSERT/etc.) has NodeId == -1. - if (node.Parent == null || node.Parent.NodeId == -1) - return null; - - // The node itself has a spill — bad estimate caused bad memory grant - if (HasSpillWarning(node)) - { - return ratio >= 10.0 - ? "The underestimate likely caused an insufficient memory grant, leading to a spill to TempDB." - : "The overestimate may have caused an excessive memory grant, wasting workspace memory."; - } - - // Sort/Hash that did NOT spill — estimate was wrong but no observable harm - if ((node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || - node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) && - !HasSpillWarning(node)) - { - return null; - } - - // The node is a join — bad estimate means wrong join type or excessive work - // Adaptive joins (2017+) switch strategy at runtime, so the estimate didn't lock in a bad choice. - if (node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && !node.IsAdaptive) - { - return ratio >= 10.0 - ? "The underestimate may have caused the optimizer to make poor choices." - : "The overestimate may have caused the optimizer to make poor choices."; - } - - // Walk up to check if a parent was harmed by this bad estimate - var ancestor = node.Parent; - while (ancestor != null) - { - // Transparent operators — skip through - if (ancestor.PhysicalOp == "Parallelism" || - ancestor.PhysicalOp == "Compute Scalar" || - ancestor.PhysicalOp == "Segment" || - ancestor.PhysicalOp == "Sequence Project" || - ancestor.PhysicalOp == "Top" || - ancestor.PhysicalOp == "Filter") - { - ancestor = ancestor.Parent; - continue; - } - - // Parent join — bad row count from below caused wrong join choice - // Adaptive joins handle this at runtime, so skip them. - if (ancestor.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase)) - { - if (ancestor.IsAdaptive) - return null; // Adaptive join self-corrects — no harm - - return ratio >= 10.0 - ? $"The underestimate may have caused the optimizer to make poor choices." - : $"The overestimate may have caused the optimizer to make poor choices."; - } - - // Parent Sort/Hash that spilled — downstream bad estimate caused the spill - if (HasSpillWarning(ancestor)) - { - return ratio >= 10.0 - ? $"The underestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) spilling to TempDB." - : $"The overestimate contributed to {ancestor.PhysicalOp} (Node {ancestor.NodeId}) receiving an excessive memory grant."; - } - - // Parent Sort/Hash with no spill — benign - if (ancestor.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) || - ancestor.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - // Any other operator — stop walking - break; - } - - // Default: the estimate is off but we can't identify specific harm - return null; - } - - /// - /// Checks if a node has any spill-related warnings (Sort/Hash/Exchange spills). - /// - private static bool HasSpillWarning(PlanNode node) - { - return node.Warnings.Any(w => w.SpillDetails != null); - } - - /// - /// Formats a node reference for use in warning messages. Includes object name - /// for data access operators where it helps identify which table is involved. - /// - private static string FormatNodeRef(PlanNode node) - { - if (!string.IsNullOrEmpty(node.ObjectName)) - { - var objRef = !string.IsNullOrEmpty(node.DatabaseName) - ? $"{node.DatabaseName}.{node.ObjectName}" - : node.ObjectName; - return $"{node.PhysicalOp} on {objRef} (Node {node.NodeId})"; - } - - return $"{node.PhysicalOp} (Node {node.NodeId})"; - } - - /// - /// Identifies the specific cause of a row goal from the statement text. - /// Returns a specific cause when detectable, or a generic list as fallback. - /// - private static string IdentifyRowGoalCause(string stmtText) - { - if (string.IsNullOrEmpty(stmtText)) - return "TOP, EXISTS, IN, or FAST hint"; - - var text = stmtText.ToUpperInvariant(); - var causes = new List(4); - - if (Regex.IsMatch(text, @"\bTOP\b")) - causes.Add("TOP"); - if (Regex.IsMatch(text, @"\bEXISTS\b")) - causes.Add("EXISTS"); - // IN with subquery — bare "IN (" followed by SELECT, not just "IN (1,2,3)" - if (Regex.IsMatch(text, @"\bIN\s*\(\s*SELECT\b")) - causes.Add("IN (subquery)"); - if (Regex.IsMatch(text, @"\bFAST\b")) - causes.Add("FAST hint"); - - return causes.Count > 0 - ? string.Join(", ", causes) - : "TOP, EXISTS, IN, or FAST hint"; - } - - private static string Truncate(string value, int maxLength) - { - return value.Length <= maxLength ? value : value[..maxLength] + "..."; - } } diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 9e23c72..87d2e4a 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -25,6 +25,11 @@ + + + + +