From bfd1a2d46ddcb94221bef9660763a25aa2b9c95d Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 15:15:00 +0200 Subject: [PATCH] feat: compare synthetic pilot at existing container limits with failure cleanup Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- SCOPE.md | 7 + config/synthetic-pilot-container.json | 16 + .../2026-10-19-synthetic-sizing/README.md | 31 + .../container-comparison.json | 60 ++ .../container-preflight-failure.json | 9 + .../container-sizing-run.json.gz | Bin 0 -> 48336 bytes docs/synthetic-pilot.md | 34 + scripts/compare_synthetic_limits.py | 69 ++ scripts/summarize_synthetic_sizing.py | 6 + scripts/synthetic_pilot.py | 757 +++++++++++------- scripts/test_synthetic_pilot.py | 38 + 11 files changed, 754 insertions(+), 273 deletions(-) create mode 100644 config/synthetic-pilot-container.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/container-comparison.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/container-preflight-failure.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/container-sizing-run.json.gz create mode 100644 scripts/compare_synthetic_limits.py diff --git a/SCOPE.md b/SCOPE.md index 3dfde1d..6090795 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -202,3 +202,10 @@ sample passes the two-user ordinary p95<=2s/zero-error criterion; it does not es human acceptance or the production60m CPU request. Coverage and provisional values are in `docs/synthetic-pilot.md` and `docs/evidence/2026-10-19-synthetic-sizing/`. No deployment or publication is performed by these tools. + +The follow-up container profile uses the owner Dockerfile at the existing 1000m CPU +and 1Gi memory limits: the same two-user mix passes locally with ordinary p95 of +0.412s and zero errors. It records cgroup throttling and memory separately from +PostgreSQL. This supports retaining those limits for this provisional scenario; +it does not validate a 60m reservation under contention or predict human demand. +Sampler gaps refuse sizing, and bounded cleanup applies on errors as well as success. diff --git a/config/synthetic-pilot-container.json b/config/synthetic-pilot-container.json new file mode 100644 index 0000000..47fdff5 --- /dev/null +++ b/config/synthetic-pilot-container.json @@ -0,0 +1,16 @@ +{ + "users": 2, + "workers": 2, + "iterations": 100000, + "tenders": 100, + "document_bytes": 1048576, + "think_seconds": 0.2, + "timeout_seconds": 30, + "ordinary_p95_seconds": 2.0, + "duration_seconds": 180, + "idle_seconds": 30, + "runtime": "container", + "app_image": "vergabe-synthetic:local-11f7c8a", + "app_cpu_limit": 1.0, + "app_memory_limit": "1g" +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/README.md b/docs/evidence/2026-10-19-synthetic-sizing/README.md index 5e44ffd..9502745 100644 --- a/docs/evidence/2026-10-19-synthetic-sizing/README.md +++ b/docs/evidence/2026-10-19-synthetic-sizing/README.md @@ -42,3 +42,34 @@ UTC boundaries are retained as observed and may differ due to wall-clock adjustm Validation: six focused configuration/percentile/sizing tests; Ruff clean for maintained scripts; complete real HTTP run passed all3028 operations. No customer records, external identity, live credentials or production writes were used. + + +## Existing-limit follow-up + +Owner Dockerfile runtime image: `sha256:57da46165ab113d0395e6885a7215a99a8866fe33731150c2985f6115138ea8b`. +Inspected app limits:1000000000 NanoCPUs and1073741824 bytes. No OOM kill. +The same configuration (two users/workers,100 initial tenders,1MiB documents, +0.2s think time,30s idle and180s load) completed258 cycles,2068 requests: +1552 ordinary requests p95 **0.411574s**,516 transfers p95 **0.158373s**,zero errors. +Measurement collection completed with no gaps. Closed-loop throughput falls versus +unbounded execution; the latency criterion still passes at the existing CPU limit. + +Application mean CPU940m; sampled p95 1037m reflects period-boundary measurement, +not a higher configured limit.1449/1807 periods were throttled (about80%); throttled +time counter increased117195212µs. Cgroup memory.current peaked425.59MiB including +page cache, which is not directly comparable with earlier summedRSS. Separate DB +CPU snapshots p95 were60.63% of one core; database demand is outside the app limit. + +Recommendation: retain the existing1000m/1Gi limits for this provisional scenario; +do not adopt the earlier unbounded2900m envelope as a production increase. The60m +request remains unproven under contention. Actual reservation changes require owner +headroom/demand review; new users/corpus/mix, bursts, restart, ingress/SSO or hardware +changes require another measurement. No production settings changed. + +`container-sizing-run.json.gz` retains raw operations, resource samples, exact +maintained-driver digest and runtime provenance. `container-comparison.json` +is reproduced by `scripts/compare_synthetic_limits.py` after decompressing that +input. `container-preflight-failure.json` retains the failed internal-network setup +and verified cleanup; the successful rerun uses a dedicated bridge with only +loopback published ports, not a claimed egress firewall. Maintained source includes +bounded failure cleanup and explicit sampler-gap refusal; ten focused tests pass. diff --git a/docs/evidence/2026-10-19-synthetic-sizing/container-comparison.json b/docs/evidence/2026-10-19-synthetic-sizing/container-comparison.json new file mode 100644 index 0000000..6e4c4e2 --- /dev/null +++ b/docs/evidence/2026-10-19-synthetic-sizing/container-comparison.json @@ -0,0 +1,60 @@ +{ + "status": "provisional-existing-limit-comparison", + "ordinary": { + "requests": 1552, + "errors": 0, + "p95_seconds": 0.41157431800093036 + }, + "document_transfer": { + "requests": 516, + "errors": 0, + "p95_seconds": 0.15837287099930109 + }, + "complete": true, + "provisional_pass": true, + "measurement_complete": true, + "measurement_gaps": [], + "config": { + "users": 2, + "workers": 2, + "iterations": 100000, + "tenders": 100, + "document_bytes": 1048576, + "think_seconds": 0.2, + "timeout_seconds": 30, + "ordinary_p95_seconds": 2.0, + "duration_seconds": 180, + "idle_seconds": 30, + "runtime": "container", + "app_image": "vergabe-synthetic:local-11f7c8a", + "app_cpu_limit": 1.0, + "app_memory_limit": "1g" + }, + "completed_iterations": [ + 129, + 129 + ], + "app_runtime": { + "image_id": "sha256:57da46165ab113d0395e6885a7215a99a8866fe33731150c2985f6115138ea8b", + "nano_cpus": 1000000000, + "memory_bytes": 1073741824, + "memory_kind": "cgroup-memory-current includes page cache", + "oom_killed": false, + "dockerfile_sha256": "154d116566361c632dced1f83678cac5c111b1ac17344421cdba4f06213fa498" + }, + "application_measured": { + "sample_intervals": 256, + "cpu_p95_m": 1037.0409363759723, + "cpu_mean_m": 940.156508347305, + "cgroup_memory_peak_mib": 425.5859375, + "throttled_periods": 1449, + "periods": 1807, + "throttled_usec": 117195212 + }, + "database_separate": { + "samples": 88, + "cpu_p95_percent": 60.63, + "memory_snapshots_kind": "docker usage; full intervals retained raw" + }, + "interpretation": "No production limit increase implied. The inspected limit is tested only for this local synthetic mix. 60m request is not validated as adequate under contention. Memory includes page cache; comparison to earlier summedRSS is not equivalent. No human/ingress/SSO acceptance." +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/container-preflight-failure.json b/docs/evidence/2026-10-19-synthetic-sizing/container-preflight-failure.json new file mode 100644 index 0000000..9e0ecaf --- /dev/null +++ b/docs/evidence/2026-10-19-synthetic-sizing/container-preflight-failure.json @@ -0,0 +1,9 @@ +{ + "status": "failed-before-workload", + "reason": "Docker internal network suppressed published PostgreSQL loopback port", + "affected_runtime": "vergabe-synthetic-28b8611b54cf", + "workload_requests": 0, + "cleanup_verified": true, + "verification": "docker ps -a and docker network ls filters returned no matching objects", + "correction": "dedicated bridge with loopback-only published ports; no egress isolation claim" +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/container-sizing-run.json.gz b/docs/evidence/2026-10-19-synthetic-sizing/container-sizing-run.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..a481fc07e7b14725fad5b01f1d0e89b336414aac GIT binary patch literal 48336 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νZYmd-z z`7y@UQFgvP_`*aD&|I9X{`4&D6@UXh%9B3s0|}|6T2^s_#Y8#is(BoO*arEAqFrSV zu)g77e7GWIv*}KAVBcx{hm|F_;ET0P2%5rKGrnoozib6JJR^V#rLrE?fe=w$&9)E{ zs6jKz&hIsERH(tpZ!DZ%(yKN86QHtW=jlZ$pr+zbm+V($)tg~WjmSFnO;X0xS1WF1 z$^OEL$P)yRrcu_W8T3j59&Ar29rp&nFtM>$qU$`BmbWo4{}DMQ+K8)M`GfVi;lnaD zJ4X&&^;B@cY=oSI@qM{VttJV{G6m4WxE=CSBJOR`*hz^Y3#a)%b+$Rs^(mcFA$w`n zQta=2*Zihxv-r1G;IKj-OrMkV;C!xYQF}T6DEq23$ugY&lbDxAF6-y@L<}(M>}ek8 zk1gA(Dc8Sz-n6p_bTgqhkbV*nL8|U4^)weRdRDKw(x@In)!HQhK^u<4wZMcn{zt){ zQg^m6L8CO>>R^1q_jt^X>5b<3F|W(?n?Nw5q8Oz%p?0_Z@vERYYa!2{BnjeQa*bR# z&|1ola|We+>F+Q}gT;=~sOXTe=8xVyixax--ad!w^mw8V%_D*H1;CkY2ggQjQ}4Nu zP=7=~XSTHCKNN@it1nDsV06LcjPds@E{O~DxPgBAq&iwOx99S_Lk)#XhbzW}+W#&; zEQ8j3SHB>tVE`j~>WW27k*7%3+V{N?g4U7Djep!Af!}x^dMmiUM@~HhTcWqr*9852 zD|7IxIm|NOX;jnCv-j=kUg~H+r*StIK(8ixwf6rLlichgqp#Xat(!q&tr$0WPw@HUrv8Z4$ zhFe`haW4HXi;O1U!Lk*5uR1)etyvauqJm)_RiX&es<@!$a-u0E>tx=$5q`-LZ%-w* z&zX`p`!ui4B*0kYu#)LrYC2ePjE?N`3_m!OY5(+5mk)wzB*tfV`eFH<<2h5A`}l)l z`~@<#9I+e;L%;WbxM#-;oNCG(2bq-Y$bP%7sX2w{)4UgS_LA+uDZ9G{M(_LUIBIs4 zPUZ3?9m0b)J;Tc$k+%L*ib*{I9|`tJuL{$c1d998bSV&-o7fDfJawx+-)(rKRf|iK zjej_HL-Sqfm7?)=Dnm^Tlt`)fc);`@8B{0Eeu}DQ#7#Z+XE8sjtImO07`v8MWvwPu z&<=XcTo^TFq~r$cN+|17tw5K3(kouWG?x8ueW5IXY|UjAwkr6e`HcmqTeAH&jb#r2 z-V$QmOmgL-Lk$2GD05>WC~7EL+VbR=Bp{R-OH`musa2SK+2(&nBfA{~mYCcD*ZUpt zbzP11#wnifwY&Q25N4M8xNu>~@eE%rO+WlR?5a>w&j{#Y#7PkJ` zPOVCquvC~s4!s@|VgiXV)_Ite8jG8SGt?Bp{5iruot3`W8$#v)Gfya>)Ky%ZF(NC5 zCNpu~GMQ%X(A>uv)-N3lvW2lGuwDj5Y^Qt&!BwAY`DaEm`@TAqy;`#aRu|yi7>Ur{ z)t}rk>{a`l<0QTE+iusxem5frppOcjA~4ev-}?|g-09_yn+10u`%XYMYiuUJh#SKH zp&i*!?8FxnS_3=Yz4Ky&+@w(xyTU9LaTWgfwjo(m+2t)KxJ#zY>?VHLh(XXUg1x~| zj4Yt@VBT7l@Ye^qCXv!|MSf3bU$iWJ-C>y;7N&-u{Ukyc;xgdmKz-9vA36v8Z)M~C#A`^ z!Ug>MX5)#XeY1>r_bm_Lls)e6beK04Mt-eMH#nM!ysj+;e*)09VFhj7mXwx@G4~75 z7I0McNEsysT0M1^9la}EdIX(uT~*^F(0CKP;JU?26yG+c3;ZAl{fGvs|3%; zP*;Y~S_$)tfNY1_8=|$WMFXc(AWEwKcaFh1(2=8jEtD)#q2_C#|E$vt=`l6v^hi@Y z0*atUou|FknvWsfD*DN}?+u4tacEA0JUd-1>lCPn3k_lNASbBoSp+%EK~oQ$&zH8+ zH3On@zgx0HiQeJnUU~W}=~ZX^r;G`Xq;4=BdW>4ji_nxRdV-GR9y-o2m8Yuv$0u(1 zSCv<_)qN?7idd5<7)M!Vsackfp#}|@hN5QaZ=YW>Y21p9`QRhVp3AQI)ta1U`#?Vp z8X(PTeF_%iOfPoE>|vlSsFQs&m5yKs?ATRq8j_#EEEJGWAEOZw{PeKJyzB+kJ`~P7 zO3GlL>6l$E9Cet*dToy?-jjnPn-Od?2KpDFpoRpXz%lc;W<%S5{pVi*7BzN+%_L^&a9IHZm;#T-CQvCQ%EL1DGAFfqSESG$@?8rls_DCh z2_^CvpT`Qd{E^wKnoJzs#m-2wIXbWwT=wDwtZS`#S*q7>SUsc%!H9Ll!Bh z*h+>v|3Z0#ve`_d$_yW=yGr?^eZ7GPt~tElR8kWD=M>fbczO7t1&w=BlKK)jruB>p zF4nPdVNaRLQHa(wWA5Y1Y$@sJ&d@UFM&AF8s#t;q@o{D-`}LnTH+W@>U9ZEM^Hl%1 z_5$869=`%CBUr%n&HZ9VEp!s_@>Ml482|#p38438RLX zx_=<)s8@dN5rnXw<_U5E-Rg;?DXukW$G=9dx=Kw*FU~Yphy1&%N&kxko~5QRyfMT- z-FwmkdT4~AO3qbbQk>o{Xc_aA?7l`~T>lB`C3)Hfsv7hDkrS4LZuj>NTomf&zZnxs zs8nU-;^7K~^qPi_XF37JHLKQ0-98tquekD;?tISZz;1jKLMsQWs^G6~`BX=jRzgxj)U~BU{{y3~^*UR?PS* zH{+r?YZy;AtjD!zK`aj~`_l|ladWpu6?yQJgE6~Lg59%~Zmnn30nxNT=bymew#pv? zH!0&YBY|qHFD<)6Xm^LWc|o{hE?=)VJ#6y+DHI~T`kU5OHlg;^Hq(sJ(|}ilQHpc) zXxruk3`Df=Mu0Ujdo+oAvHaaHo*k5k$+1=T-xT9V0 z*a&2g8SXqY&>P!y-z{iOj$krc6$jdx?R zuC!7bNkugIj83tCw2rJumWvsNv!yZ*DJ8_U1YtUVMYR~##%a$TxZiXub9qjtGFwhY z#1km;eENl?-eqjx@Eri?#C1tNA@d}8IuhU?_ZC9Y)}@s*yi>)n7L%lN^k%uArBBP& z>mByphtgAdCUw9Z(y~x-uzZrz8TnV^hykke?Aqnn1}!;OxT zNdo&eo<>1(R)T4y25;vms{B;nL;V9G6dpL%pfug6)LNcpI;vb~|Co=P-9tj4qE>7= z_Hp@I6i69us#@1Vayy5nkylQX?aD_f{PqKq0ZD*K+@wMqgg|5NVXNZGG?lu5ctHKf zv1=8LH7#97>x-xfgub17gn(`X2B=5()0=Q=*k1-4n^YulkFud#^n34iZDZP z2Q@T#ejF}wl5VG#u>ZjTWe$CDp@nLxml3Xuu!7iI-VFXWsxG|Z31h0TF@O3LJE9B^ z)K6*Rl*?O43q)F`dn4M1DKpZGsVu;8OP{!^oT)oST9y|0; zq`34H*eebZlxxT=;@d$?zA#(`zBmD*_!;sLBZ<7J9pT3U`3jX%wp&r4`k?YQ^jG`BLF zo;&gGIM3^kO56E~L(f2*_=4x#oIqpBa8cO#@GK{yK+dT@IOyw(7aM)kmWU@D=m$v5 zo=p^#t{_%NkW;P%aXg>_UoaqLO7{GW{(-w31KG16=vWgv1o^@)zPyGaEeVsz1MtZo zkvUk^MiK07leo~}kBjWxTcDse6ZTffK#qClnFdB`JZTSCp{Qg$)kBiDx`8NP4n5Ma z-=P(`GpD9<7s5s#aeZ*YTWMWdEfbwPfLNsYg34~5ZCO)nzd7%+S3>6L=)8 z1gVVI*sl4HMfv5W|06Fz1J^#BEQJ;`N+L|Izl7rTKrLedL9Tz^^f=~!c`;gmu&fE!DZ z^8+!UGdA$;*@qDsNR9>Vw)`{?{3BMpXtAHsLoq}ynRRR00+4OweB*Y={1P7c<0mgL zB2t?MXh`alU-Uf}I(l~Me|WtEx?&tBJl&yW#OW9f1w7SlFbd*l8uCu;Hd-^)SD=!I z**+WDU;Gvdp}xV5G-@%QYQF&<{qpbwC7q1!y4Tt4uy8PVZVIqeyIyGgSGoBb@Xv*= zLnU+(}Xv&(hs5KT9`BgKxSZ_W&8GpoV#$HJCjPe3+}`kN9rll-8*Z)U-jwG7;Ba z#xe@T(kgVE7OtI0oiBVXFFZplT)+MMznoF)%yCLsV@hz5pjbGqf4-Oql0Ss_&Yk>% z+yb`=1td)Ra%dy|W+R-0TKKOwTX1M0-)TNld5RS0+O5Ugmx!(uQg884U1jf*|CQAX znB!k!Vd7Gubk8W$=u1}BJ(>L~^4mmF7&;&&E(T5hR6JVqU)pynFEy&lb3n~l zusld3PFr`hlKL3(#r}utNG5RBu3qGw?yi|m14 zyFTfF>}1W+R+cI_)v1aoIncIhVk;{gx;66#Rl+iZN3sA9uP;#@(wg2os%1X$G-suk z4Q^*?v~+xiLV;|^z!>k7S$2bYt1?D6(BZwBb?BvDJpR30>htHxiJ?@?1F72`9H)G| zxuLk27+-y?A;0t#X};9ZXQP860PRR$`rg*c>C)P}_|lxa_*FrM(~WX56q646a~BiX zO5fX=9k!i3fb1HQF7Z@w1zD9ef4iaV%s2dRA)Nd*_W}JR=gbeEb~Isva&?K=Gag z=-~CtNI};!nR`g604bs(j)iGT+&*Y!1G!jIg2uBhtg4fW zp7+Av`Jy7@&KDDu7)BHz>QfqtC9@YSIPb#%OHGMu`KrN%W-4UEsUiUe+7CS4tPyRANy8vwL!U0kFiTYop1&C=3EhpA%D(}}@&dw5s zQIY^WY?=6RDVv^ZTp-z~3{=M83)b>&@fPa`{96r(&aWZ&EEUM0Lv){a@i_R4)FS(s zaVwKWTWa`Yf>Ib)spU+5Jo0Bo2(`?NO-{ z_@IpG=GnN%Ky&No929i4$SP8C6C-n`n@ko#Mk3-ov!ZEb96sKQG+^!?Ca5LW=R7$p z{V+oCXAHD2Pia_77AB2vw@TA6k`GXtc^DiLcqS+|C}+%u{4}r-Km(B5ot0qFF5Cl3 zjljMH9K`sP;QvVh(i?YStEI8O2Hi+=&@b@?RYyT-Z1}hILE7w*J%QCmf$|{Xm?2?B zjPAl2evsg+-EYtXzcMk9{X?lLLZ?5eoc?T%ujTw;qM~o;w@7Dl%ck_it5+EyQv^Fv zskVZUKTO2NuwDAQHBRB8CzIm?pNPG{bFIOUCeuUNSdLm3MORR9>tp4u9#(R^M`gW ztx$HgYzEG16x3ax_HG^(IEO4ufS4N>^8glzGXB6MKq@WhzBxpmDMLvyp%gQ&Q1P8Q zD4=TRxK@M}!t>K|a$WICaOurb9`INu6L7QzmQsk3& z&2QIr3``q%sjMutb>19#zF!5*+n6r?%ri$mnio3o0h1ii)p(bq4iQgwKJu#5fFviZ z39U;Uya!E+mB3$6{qaEkc567lW_@Hg@T;QkW=(Ea6gXx#EkZ)4fhdsV=PRRiL~3@B z?Sw(&>2eNqoc&X0Np+_7R9e#1Ye3u8cSDciIJxKvXqFE0J#R0@PDxTu!&x zNd|AEo?5SV#1nmSSJy)*GK#wH%OE#StxKOPwM+Ww$vR9ZvsU3Am`!~ys|?rtvsuZ{ z4PpA?Nz0+$+xVM{T0yzH?;zV86~lagw9OA8w0Bf|if>f^Sue4G)u*8fv({c*yWqYP z3dh{u)%gg*1{IwD?L*tr zm(YQ|vqYviI^#+kztRj9c;w3y0#W`>bDoD=)xb%g6;%f@mrKf7C*$obYyT5xL=`&S z%fSO}tVsa`TC6@&mdw6DmSZ9zKJ+$N9QOO}8}QwJj&JsK9B25(R@|kAP~9C)9bh~K zXz-DycYsXNSHnG^=gaG!Or2UJ9AMBbf&*sqaWrA0Hi9sxIq7rloGGFg+EWz2xG2Xp z^Um}rQ%xy)#7cphgz0P1;GMNWjZ!2RRu0g+!!jOXA-~o=f^&NJih0be{7aKAaO)zA8=*}x85 zs#5|n0hByi9sh)->0R_ z1KlbmXX$nQh2iJ&{!^j$5M!uRC6SKcpW2f4b=jAEY@$S|Q-7?a8azx-I&u#Fj(>;3 zz5i||{_nJ9xnzv@GxWTnrx)ysg5H%_Ix`+4cP1mKhG~v zK|`_!w0g$fg;=Eii+tRYbUx<&m#CS)&6VtepD!P{1N)R4>s}@(LhMb$3%f;%e=BWAo|#4b)2sCl zvaWnNxV?X)SF48IhN{D}G)nFxkP=J&5q&WKW#@1Rv$FlS?b9+wP8(+1y;XVfR9%Ul zSb67g?7&T-#TxYfhLlvKXisig+PLr7vX05wav@@htmNiO zTrRx(11fxa#qE*LglAh6p_lsF&|xF?9+FO z*8+Nz)PNVWLg5`iqPHY^jldGepW;>Hx5MaowC+U}E(Is=zFh4zD=}FyQMaC=PG2P0 zQ9~Pn(7#okq{QEM3Ce}%A{^Zw$?IKJsU6*15UI4ia}J@k%qHID9AgHiW72|bH}nhi zS^P&#{kg~$>w`W}f5}XcRXysv&~%1>IF}FlajN#`ATQY}Cgt#DChH))aP8TW>$0-_ zvlAQKAnfl1kYK||Rx$DROio0sYOZ{$VpGnPWXoy9UyjS~;D|rJ`4d6+VQNJj$u&L7 zm4C~4S20p`;NO zOUH6KS=)QlHiho!@J67M=fCz`+1+(n&~Ck57rV@vT`(_ z?i=oA1M2IjEid>8rz@O~ebR8Y896$eJ;43y8AWf8+ntj2UDT?&bp;Ck&z-6}j(iN_8Cp@nwwN$e2N+ACXi9=26^D%6j~#dOqEC*f%b&zns1Y z%wgbR9&rxHw*>K{Sd(E#uHvVc^p-rCk zp0G?}c}}?rJ_ob`wVBv%X2}kc_pB0MHfiU?Y6y~k65!76nK7dVybrBJ4hhhnq_&pr_{ylxgFUs~kP94r_Gt4OFX2NS6%gKW)bq#$;<+dYFsjq>?=7r6!)9USP7Sm+t3-Si+ zBh(<05>G(TetVE|r&$xSq;YAnN~?dLF41c_SO^HLSS-N&r|)LtsL7_U(h3=qhAvOZ zb7qaYR_v`O+KeHi-58Y9Pwr+}1}29vR)_FOlb1PNK`Q{}ZU`m+ej9Z)R$BbjfifH6 zh0jieiD^pabUtA3+|^FAE>+Dc2!^FbLzO@Uhev7IzITb=t}? zx!n#aAQ7O{KfDpnV2MeKZ4oR>ofU)UHhv)5&oP^wuQCW(>j(JuzLutM#d=^x;X$2q0y#OrN4g=GP}Hv`2IjsPBG^8VQkcTK-s#E!rl_Qn2g67KO2h$zmyyD1qWF2flK&SLhA>+!$uSYTk3 ziyp27NuCRo`x_W-`tM{lMAt$H8NT&FPMxfV9q@{VF4Lx;O@1|s(`irQ|JZ9l=9j*@ zT2c-bx6@Dk*+xr`CUBnM2#G0QwRwxCgfbcDPSIv>im#d|*Wt-K*bYt^F)oeIpbXqU zNDNH5;hbzN0-vN>l!EDEVBZ7UZNLaaek0X99+pKlqAWMVQ{eCRN`oRYo^^xD8WW)Er|<`tv~)bfqZ8!bU`NIOzp0c>1)ZF4A|L!=^t; zFXUBsBw@P2o5|?3C-8+#e+cunDJYt9%AP9o2@0Sz4Vn%enT*XM1EBN0GrCxX1AE6`;L zpJ`S9EfcH7F_u(L6YwHZ%^b0jyrX|d8R?;t&DdTJ1Cy2h#Dt%S3abQ_Sh6|Y(WnP%j zep)wQYD1z=rPFRs#T4?EuV|X6YNL1vsL79(%i50l)OGNc+!Xf$FZjhjwpS7;WwTHI zs7lu|voL@=<`fkb^;!F5UZSQx-N3d`In&jjai!zeRisml&c++@ztcvNF$?2g5yh&U zvC|Ak=wr}ocrVs$4uqA;^N2{F(rM}RLj~^he|C;L)!AJ@Fp+JJ4rFGc!OKL6A}S|* zQD#tzsP~oJq}Pv}0x&auNv{PVY7NG_%h77fvk1a$cNlH$>5z$}a@iUXXW|2gDA)Q< zm@OE7hljvlk;Xe6^&P$OW`r^Rij*2+>I?C>_f$#6EA~y`^l0W)0XKn(Mze;f8i#Mc zeJRyT;bW;ivkaGfAgMc_rx?6@d+iDkcbqEP+ex%pL#hQEN?qe>a{5BpM=!CnY@;fy z<5qHKRrm7vI{}T&L-%P~xk2m%oCO(t4-76aAKKT@MH#AjszE_AE+m2pxv*9%Gs)@V zHswlE&4o%x@0)gr`DN1COCXp~O_KL8|Cg1Mf4OuTKiiGvQmM!n>GW5|W{iLb>o4=D!@;-Y3ziQrRerAC096-%oa(1EAB(qT=D@d@Hgn?9YF3diU~ zi|IwzE~V4=2p$<8XM25DE=Z&4$*naFNg{ z*r*08-znNwZa9j$LYTy$n*MF-FLE&k<#MW*0rp_~YOAE}e41*Ixrz)|E)t8qWUiaN zp|l1!5li3DOP2<)_7m*6tUg+Q1JGnS$kuRViPyR3+@*}s)>v{^g2-iUzpe*y>2_B^ z9rqWoW~*eaGJFb%v@9r`1kP=Ak5F&X4RjN67jQ`fylUaEqsMt926b@6H84*+?fu^O zqS>Zw4w6qAx(%3@)|2%jxyfNp*r=?NEbVmfVjk-NSDe0=+5wUN7No3B#SYc2$*@+Q ztAUK4i`6PM<)6GT;1;O0jIlt{aP7C##gI&n5tZjchBrA$5lJ%IE4Q!(lhIQtP&t3Q)#ClU};kGwLu%g{~wenRhCcWcPh`ODl z4Nvas({eSGxEukhe9khQDyLKjZmHk$W;ENNxK$wvY{;zVl=4#L@f6QQK1WOAr8aja za;S^@d^BQy?zdOxFvq7qs3_u~-)boInw6kQ|EAT)pc;Xx4!NPr;L>}iS>>5o=CNhb z+UTCZrOP)i6Ud=X`pU-%DJwl4y!kD5su7K1R%+F(U};3_u5at8@t{) zl1vn9{yKl>q#8tQJf3A6u<2}@_nUmG+sVFH+mKzAk^g+$fIC-c{lkV6SbSN*S&{T7 z`cStH2>HI96-OM^%w;>5{)@!$U8Qb9M{`9ZqMGPxB%BQrP4?c>E>wZ)ph6CeR|A_R zEoLMTpcj-eD^|27mLz4{j@ z&qvb7p*uN!MX|PVm*|=E?1v3I|L+4VN*+s{b9)~gtFfW3L$HRB$d6gy5Z*%eW*>pyvzUtxTk0DI8n4BolH{pr0ACgQ+?L z+##PhJgAgIa6YX%G3yBEp-P)0m1S+S&rWu)4%MMZ$%GT>Gnc(zpmgzU?E*E&HR##d znS;0T=e@PTmWb2>c|7@EogS<=we51q4@KY#9)8!wM&3{p@{427bX zeK6j>_D}xysYZa5e+iftX9rOh9n|3r)99RBSMyXhlj3g=#ij20HzX5-I4hVHZO`Nq z!uwlI8vH4;th{MzW&w(lcv%yUbjh=%ufqLglS7`Nh4_96W%#e=HO)+^K1@&uuQhMV z(jp_5j^dV+(Ux_sUi#ie?t~-QqBphG#YxI*e%*Wa)C2^Q!Bn%BZzMBF9o7dEw04tm zD^a%5y&PoH#G?Y6<<&oV7`W4|IC*K{O%JfV>2w(qOz*$3C|!Ic-lWFE-|{@rEpDAS>jGWIRh zB5^uK4%VEIh&3{Bvq1TqgP<}cyPnbf=F=xQQPxrIPMImQ$C0d5 zURKR1QDej*&ZHHImsyWAYS^rAq4%`SLS{eF2$?P}<4V^rhZE_juY~2Xp?^w~-|E9K)rLBMMmIKnBJ9qFWNU7JHe|hOkiEZ=;{cj&+L}nXm*{X{O#T38Cy4+T}%rZN{my!}AsUWAwS58(4uDmYcg|z z+qn8Qk+_`H{ z#XgAo{=lx?oX-E{q0aMWuA1s^xBRxw&=eIl%fF{^i^wSzLp4%vqVeop*(H;5NTxC| zg$c-|SSvY8T~K{F#;9#BP>jZUT*Wn*`uJmcSjr~`{3c;4cM_=^>UT zmg8xGQeU;eKXORlf?8D#yz^LV;bJt(BMzFNrbSacUt8}~A=l@Ei`ZMIbv8KFnAQ~} zFUXJvy2^Wbh*4WvXx5$>%Z(V`)jQzoeB#z5_~2r3Z>lKHM3xooM_OPW-}4`D3*KrU zzVzy6b;7lt>qyE=vGZ$5P7?x0rJQ6JrK=RjO@+KcD$dL@w!Nvb5QP{!qLAqAxm+hg z>09QvJDcAlhf`M2`iFY(e-8h$`kKm15VK;z!ABusImnLgNArL523oPQ7YmFb&Dvjh zU)y;0W_FdJwyX36=&(~gOe`{_L(ru$sQWl=RDk;tW-5FdmNb8IBBWB(AnPz=q-ts^WYw!Pt*Gzsqzy~0l3N4ZVx+nNFC1a; z(Pg`Q-r@K7KjC`VaOWHI$~LPfD1o5ilRq4-cdGkoOjth3(b0 z)f|I$5IJ?tW#d&NZabq3x$d@+I#NKv(+zad^^-D&;800Vz^PQW24pYE^y@m~)L5taH0M zQhn5rU^YbmhKi~)Koa3?73I4od)RZ=`pf!HB*?U!W_yP!8(~Sa7bE+9iM)(q_Mi{)~?R{?h5**4QaK zCq|N-HSDM24hS>iq*m^n)4=FQ)H$XBIQk|bacSW3G3(+s;AG(_;NM}E>)HSJi+OzD zX{#HddHiB(rm^CC6ZjF?66N9cD)45c?Y`6bf$MXL=au@x!F^NU2;KetBPdW7=i%V! zVes+6Wb$$CDB$Ec{0`YMMdIP)mTqt1S{CGWKQlCf>?@Pidb@VD@~9_q5cnb?@Zjjt z;Qr=lEZ}UU&8$7}fz|(RN5b#1sg0BE+$1aDQsPVBKL*>L?#JWKe}6ofP68h)y$>HB zmX2E6F7+PoZ?nYxNoUt5bj|P{7q-$KHdY?%{$U21NgVAw3I`f*wvKhYFu#7dWudcQ zF)jB$3!g}{4t(_AAgXbGH`D48km(`1TKCX8RO?(HWzKZFaQk+q^(0p{YSsDaubte| z%9$g|Hs|Y{O9FG`=d$*tW4c}|e_O5y60TJFI5X{5^0^{H=q)ZCbBH{&PEUsC(D-JC z*UXiM&YeYs7KU9AyABc*H_jqgW1rQ?i%hR7s@zWF4t_SfZ~M8xen8t=+_VpVrhpw# zs#a%a-`_0^-;;_&?hxp@jjeb()n;rYZS;8hfe)RrD-JXL^1rDqtR1>!N`!h6eY)r4 z)J3)o>uy;xkYCYbow=Ji%A`G97B1dysZ&p=QzxUY_T7XBP^utX8@9JB#Sm9e+#VwP zW=_v;wtOCm|EwOlpYhhD4L9v_yanAjU59JAvLz;d3T9jPZ`qf;*Vp>Sm~c+KSA9i} z;Pp*p+F=%VLB|*IPmkALkE{1fsX}UMuc`xY-Y2hnpbHu|1y7BdvM~u&TKqFV`5cJa z;^xJD82a|Pgd;t75YLjNgE9eeQX*?HjZ^m4l_K5lGb;*s@J)<_WA?GV&~tKk8_`pO z{d%68xRc0vNeYiJCBJ3KnHeW~o`QSAH+JY{G;M+PvrTP*y)%_6F|qUFCZ7{dt!^U8 zBLeJ6AR+(ihxVQw?(nwvx=Pxn+~eIh2Ud7Uj-B1PN>SDaPU9_|SbT5eb*9r1cZyp| zo=H2TL+p)VuU;I+oQ~XXi#?L;XIza4f9J25aQuTw9Jnm$?T8|UcupxqQycur;l8!! zWG?Q}X7ZNx-1~OD@7Cd}Hh~;t6m@$$tIqlTvZSILE#2NdHSk{dd+@)O>SOy2iSZ9w zG7IelUB;K%H!_a&u|DWa63*Fe*C<>=Ue&1V@HPkf3$&;!^_&asY>Q<{@O%-$#2L?1n( zo7#qXwd?ID#zA*bD$9L~&~qPZwmdZr#0a%bg=(-XrxRlox=5z|X z_u~wGUNokX#pYx{6F4P^-QWbzw-b4W5kj-|IwYJ|WAd)gH>h<2A1jeRfuavDpwjo6 zm%0znPgvT*wqhtRx-f8%jQ10Im9Bym9TfZHq@by)#eH)yXN8#b*`z7Z4mltR=BfOd z^g=N~%E&Ewg{V{ww49+22kX#;75_W|5?RHJ>1dMq$DTpO;JW5EZTp9U1yz0E5?ke5 zq_E^~EXVA@5o!t^QOO#mN*pxIt+C4OP?qnfT$^UEX}Z;^u`2@5Js3KVUC6%rNcqJ$ zuQ6(*N*pc69AxpR?6k!W=4!Hr6yu`>7N3#0j6RH6qetPjo!@cBP;r6r7bW+*kB>~` zR@6IUg(W0R5NNHlDAW65ntZS7gK3C>WKMJd0=v9r$y*=}?zuU-UTCgeHU+_=c1yvn61|YlLELs0YdU1%2-r0_2$A!+d> zJ4rxv;m=ESWnCTB@X&9e%2S}njK%**e0Q(?sL^f1XLI%U?1Y^s;P#EG{hQ8u>`dRZ z=OQ`KiU7aUY=RW^wC6lrh@k^za$_n=f<^KDkE5Fw7|%n&E`i+4D$X0-M<$$ODl(W( z^cT`zF+`A#OC+)$O32an3y$d4#=&{Px68C2nuKoM(2m`9AE!1lGYdQRPU31)w)@{f!q!`jivTDwc9ke02~N(xL2` zu*=^HCQ;}r@wDI>MkIKkvxU} zsM~bW_!Rs%Px@Q#=9+Xhnols}=OrH;9*?`7O$#LJ<-QUwN**lH?)08Yczi`Q>SzeZ)@>_v5Y{Z#UT#uuOXkPO64s%@D*ToH;pRt4zijE#-jGcgY9C<1!i0PQU zFhc#8=UnFn`%1JTQD5gD{VMHeU;V*e*zx9wuCPf9UNn{?y}wR!N*Y*_gZ<1#@R{E# zrg=r|vc$Jne;#$0A^Y_%=wnT7(xdNn>PcgM8U&%dr^JbUokstB*03j}MmEL*tFJ9` zU-oiVhltNKj;a##+gLHV$Jq(!S07Y29a}OSJ*bvD$kR+PIgG)^=TBVZHj%lH<%>*Q z$t8Xb6N4Uq#-L`l-8bc23_*4dj0ZO}F3vf{R^+q%&-Xft$o}X_y^AaCeI|8sV-)q{ zPv}PuR-bDqH=`McVnx2(>u1k+U2LWDR{IaR7CSvH4#x03s=sNoMI+pWPYhoaT zo{1hGMTaFDtnfkR8%~skn~f(}Tr@EW)me8nCjUKbsLkOFBPg3XWL@u7csdv$s;SB` z9FdMzT1{l~VMkNDG3ebPnwrW8VL!?~+L)ygJ8pBbY^j#LBQ|PS6+>~#1XT#}&GY6f zhWz4v83jEQbb6I%U5N!=-sp7eF9ca+FtRC7O82_AHR)~21(<{0ZOLGZm+`V`pd!zl zE9w6T_RI-?8FEVbZvENmHaWuoHFfl#unv^$a$Hj<={U47#%H7P;dw6CWX)dUUN4~4 zn`K`gq4VDR#{3v=eF>Htr1a{fKvhyz8v_Nq=eICTWXKtTsyeBR! zH3EFZ8N~g@D~SGZcQPv!ExU1#0zd7n$nW6q#%T~DmP^EH(@}%vqiBNUd9R5Ys~<-J z<4m8FE`RI2>=km z`u5HtS)4h)Ca40m#2zpoE1c+UIOLr%^1_b(-6;pWQsMj(f=cSwa%&Ae9eFACFPEhQ zgCXeSpMVbdxefetO@UuPLl(TBt&Gr1 z5wXwPHMu&r6BxqwPykR@1;fk3to50XBl{D_98!K?w4!F>Eu|z&5d2(M8Mv*j06(KM z{S-6JH`2_ZyyvHRuL?n5lZ(?|rL`Ot?UuJ%qr@nrS?Um@d5Hxn_ED!0Q;e8Z0Z}}M zLz5s_Z$#Zt!ZDEU+=J3(YgL8v9aRuLNRL$Vw=!4MoecKs-*Z+qjoNs?XnK$=xh7%P$_a`;+fTYzTL^Nf^ew0*mH~OofBW_`XlnlLTJ*u;CT9b z&x13PwNAVzKxU?M!j2|w>An^v7*}+qYFf*!q5xYQGc$J5=G6AL+c5Q^D9-?~(kD8q zBI1dV!OavwjOA3w+5MqcqeR!nDU4uGGoStyISZg%jO7TqKeI^&VhFYr1$R>&eM8S^ zPYGbvSIRUC7@`Z18CWz5l$(;b{l_jl7h=S@Ofu`8@tsH?hg{?#DAB56dynPfvFe0` z1?wU+%5HRkeJ{JLJW>od%P#n9TE#AhQn@lQ@2)mULvPV`?!SJv>TMqVxV=V6_@ zMpa2^4->DMrF>~A1p3K=Vixne5zHm#Z&(Wdkdnh~D6oQs-~6Vxpk?CwF?lCF`Yr#9FlTW1wKMK&OZ(D=_oX$jOtTeDLD#c(-?MkLa=M0RLlK^Mf}_aMjAkz(5J# z1L;La>$YBBA>SH%zTY1u3a-u~{lpK{%EeR$dXESpkr*t+hz+jt((Cyl?B|H;Nd`5z-?X|@0W literal 0 HcmV?d00001 diff --git a/docs/synthetic-pilot.md b/docs/synthetic-pilot.md index 4c40da2..cad1b5a 100644 --- a/docs/synthetic-pilot.md +++ b/docs/synthetic-pilot.md @@ -80,3 +80,37 @@ folded into the app recommendation. No resource setting is applied by this tool. The configured cycle creates rather than edits tender/lot/task records. Editing, startup timing, restart, a distinct burst and target-platform quota/throttling remain coverage gaps before full pilot sizing acceptance. + +## Existing-limit container comparison + +Build the existing owner runtime target, then use the separate configurable +container profile. Neither command publishes an image or modifies a deployment: + +```sh +docker build --target runtime -t vergabe-synthetic:local-11f7c8a . +.venv/bin/python scripts/synthetic_pilot.py \ + --config config/synthetic-pilot-container.json \ + --output /tmp/vergabe-container-sizing.json +``` + +The profile retains the same workload assumptions and adds `runtime: container`, +`app_image`, `app_cpu_limit: 1.0`, and `app_memory_limit: 1g`. This tests the accepted +1000m CPU limit and 1Gi memory limit; a 60m request is scheduling reservation, not +an execution quota. The image is built from the owner Dockerfile, with its locked +runtime dependencies. App and database are attached to a uniquely named bridge; +only random loopback host ports are published. This is data/process isolation, +not an egress firewall. Synthetic settings disable SSO and external issue routing. + +Container samples read `cpu.stat` (including throttling counters) and +`memory.current` inside its cgroup. For raw-schema compatibility the latter uses +`rss_bytes` with `memory_kind: cgroup-memory-current`; it includes page cache and +is not comparable directly with the earlier host summed-RSS measurement. The +short sampling exec processes also consume a small amount of the container quota. +The database remains separately sampled, outside the application's CPU limit. + +All runtime paths use bounded `finally` cleanup; host Gunicorn runs in its own +process group for TERM/KILL teardown. Sampler failures are retained in +`measurement_gaps`, make `measurement_complete` false, and return a nonzero CLI +status even if HTTP latency passes. The envelope calculator refuses both gaps +and capped runs: a capped run must be compared directly against its limit, not +extrapolated into a recommendation to raise that limit. diff --git a/scripts/compare_synthetic_limits.py b/scripts/compare_synthetic_limits.py new file mode 100644 index 0000000..330426b --- /dev/null +++ b/scripts/compare_synthetic_limits.py @@ -0,0 +1,69 @@ +import argparse +import json +import math +from pathlib import Path + +parser = argparse.ArgumentParser( + description="Compare measured application cgroup limits; never apply resources" +) +parser.add_argument("input", type=Path) +parser.add_argument("--output", type=Path, required=True) +parser.add_argument("--expected-cpu-m", type=int, default=1000) +parser.add_argument("--expected-memory-mib", type=int, default=1024) +args = parser.parse_args() +v = json.loads(args.input.read_text()) +if not v.get("measurement_complete") or v.get("measurement_gaps"): + raise ValueError("measurement gaps prevent a limit comparison") +if v["app_runtime"]["nano_cpus"] != args.expected_cpu_m * 1000000: + raise ValueError("actual CPU limit does not match expected limit") +if v["app_runtime"]["memory_bytes"] != args.expected_memory_mib * 1024**2: + raise ValueError("actual memory limit does not match expected limit") +samples = [s for s in v["runtime_samples"] if s.get("phase") == "ordinary" and "cpu_seconds" in s] +rates = [ + (b["cpu_seconds"] - a["cpu_seconds"]) / (b["monotonic_seconds"] - a["monotonic_seconds"]) * 1000 + for a, b in zip(samples, samples[1:]) +] + + +def p95(values): + return sorted(values)[math.ceil(0.95 * len(values)) - 1] + + +start, end = samples[0]["cpu_stat"], samples[-1]["cpu_stat"] +db = [s["docker_stats"] for s in v["database_samples"] if s["phase"] == "ordinary"] +report = { + "status": "provisional-existing-limit-comparison", + "ordinary": v["ordinary"], + "document_transfer": v["document_transfer"], + "complete": v["complete"], + "provisional_pass": v["provisional_pass"], + "measurement_complete": v["measurement_complete"], + "measurement_gaps": v["measurement_gaps"], + "config": v["config"], + "completed_iterations": v["completed_iterations"], + "app_runtime": v["app_runtime"], + "application_measured": { + "sample_intervals": len(rates), + "cpu_p95_m": p95(rates), + "cpu_mean_m": (samples[-1]["cpu_seconds"] - samples[0]["cpu_seconds"]) + / (samples[-1]["monotonic_seconds"] - samples[0]["monotonic_seconds"]) + * 1000, + "cgroup_memory_peak_mib": max(s["rss_bytes"] for s in samples) / 1024**2, + "throttled_periods": end["nr_throttled"] - start["nr_throttled"], + "periods": end["nr_periods"] - start["nr_periods"], + "throttled_usec": end["throttled_usec"] - start["throttled_usec"], + }, + "database_separate": { + "samples": len(db), + "cpu_p95_percent": p95([float(s["CPUPerc"].rstrip("%")) for s in db]), + "memory_snapshots_kind": "docker usage; full intervals retained raw", + }, + "interpretation": ( + "No production limit increase implied. The inspected limit is tested only for this " + "local synthetic mix. 60m request is not validated as adequate under contention. " + "Memory includes page cache; comparison to earlier summedRSS is not equivalent. " + "No human/ingress/SSO acceptance." + ), +} +args.output.write_text(json.dumps(report, indent=2) + "\n") +print(json.dumps(report, indent=2)) diff --git a/scripts/summarize_synthetic_sizing.py b/scripts/summarize_synthetic_sizing.py index 7ab520a..da59270 100644 --- a/scripts/summarize_synthetic_sizing.py +++ b/scripts/summarize_synthetic_sizing.py @@ -14,6 +14,12 @@ def percentile(values): def recommend(result, margin): + if result.get("measurement_gaps") or result.get("measurement_complete") is False: + raise ValueError("sampling gaps prevent a sizing recommendation") + if result.get("app_runtime"): + raise ValueError( + "quota-capped run requires direct limit comparison, not unbounded extrapolation" + ) samples = result["runtime_samples"] cpu = [] memory = [] diff --git a/scripts/synthetic_pilot.py b/scripts/synthetic_pilot.py index 99ef103..dab01ec 100644 --- a/scripts/synthetic_pilot.py +++ b/scripts/synthetic_pilot.py @@ -2,7 +2,6 @@ """Provisional loopback HTTP workload using Gunicorn and disposable PostgreSQL.""" import argparse -import atexit import concurrent.futures import hashlib import http.cookiejar @@ -10,6 +9,7 @@ import json import math import os import platform +import signal import socket import subprocess import sys @@ -58,19 +58,69 @@ def validate(config): raise ValueError("duration must be positive and idle nonnegative") if config["document_bytes"] < 8: raise ValueError("document_bytes must be at least 8") + if config.get("runtime", "host") not in ("host", "container"): + raise ValueError("unsupported runtime") + if config.get("runtime") == "container": + if ( + not isinstance(config["app_cpu_limit"], (int, float)) + or not 0 < config["app_cpu_limit"] <= 8 + ): + raise ValueError("app_cpu_limit must be positive and at most 8") + if not config.get("app_image") or not config.get("app_memory_limit"): + raise ValueError("container image and memory limit required") return config +def stop_process(server): + """Bounded teardown of a process group, including Gunicorn workers.""" + if server is None: + return + try: + os.killpg(server.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(server.pid, signal.SIGKILL) + except ProcessLookupError: + pass + server.wait(timeout=5) + + +def guarded_sampler(function, errors): + try: + function() + except Exception as exc: + errors.append({"sampler": function.__name__, "error_type": type(exc).__name__}) + + +def sampling_gaps(samples, db_samples, errors): + gaps = list(errors) + if any(s.get("sample_error") for s in samples): + gaps.append({"sampler": "application", "reason": "sample failed"}) + good = [s for s in samples if "monotonic_seconds" in s] + if len(good) < 2 or not db_samples: + gaps.append({"sampler": "coverage", "reason": "insufficient samples"}) + if any(b["monotonic_seconds"] - a["monotonic_seconds"] > 3 for a, b in zip(good, good[1:])): + gaps.append({"sampler": "application", "reason": "sampling interval exceeds 3 seconds"}) + return gaps + + def run(config, root): # Never consume a deployment database, media tree, session or credential. os.environ["DATABASE_URL"] = os.environ["SYNTHETIC_DATABASE_URL"] os.environ["SECRET_KEY"] = "synthetic-disposable-not-a-deployment-key" os.environ["DJANGO_SETTINGS_MODULE"] = "synthetic_settings" + container_mode = config.get("runtime", "host") == "container" + media = "/tmp/synthetic-media" if container_mode else str(root / "media") + issue_db = "/tmp/synthetic-issues.db" if container_mode else str(root / "issues.db") (root / "synthetic_settings.py").write_text( "from vergabe_teilnahme.settings.base import *\n" 'DEBUG = False\nALLOWED_HOSTS = ["127.0.0.1"]\nNETKINGDOM_ENABLED = False\n' - f"MEDIA_ROOT = {str(root / 'media')!r}\n" - f"ISSUE_FACADE_LOCAL_DB = {str(root / 'issues.db')!r}\n" + f"MEDIA_ROOT = {media!r}\n" + f"ISSUE_FACADE_LOCAL_DB = {issue_db!r}\n" 'ISSUE_FACADE_GITEA = None\nFORCE_SCRIPT_NAME = None\nMEDIA_URL = "/media/"\n' 'EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"\n' "SESSION_COOKIE_SECURE = False\nCSRF_COOKIE_SECURE = False\n" @@ -100,267 +150,408 @@ def run(config, root): base_url = f"http://127.0.0.1:{port}" env = dict(os.environ, PYTHONPATH=str(root) + os.pathsep + str(Path.cwd())) server_log = (root / "server.log").open("w") - server = subprocess.Popen( - [ - sys.executable, - "-m", - "gunicorn", - "vergabe_teilnahme.wsgi:application", - "--bind", - f"127.0.0.1:{port}", - "--workers", - str(config["workers"]), - "--timeout", - "30", - ], - env=env, - stdout=server_log, - stderr=server_log, - ) - atexit.register(lambda: server.terminate() if server.poll() is None else None) - for _ in range(100): - try: - with urllib.request.urlopen(base_url + "/health/", timeout=1) as response: - if response.status == 200: - break - except OSError: - if server.poll() is not None: - raise RuntimeError("Gunicorn startup failed; see isolated server log") - time.sleep(0.1) - else: - server.terminate() - raise RuntimeError("Gunicorn readiness timeout") - samples = [] - db_samples = [] + server = None + app_container = None sampling_done = threading.Event() - phase = {"name": "idle"} - - def sample(): - ticks_per_second = os.sysconf("SC_CLK_TCK") - while not sampling_done.is_set(): - pids = [server.pid] - try: - pids += [ - int(x) - for x in Path(f"/proc/{server.pid}/task/{server.pid}/children") - .read_text() - .split() - ] - cpu, rss = 0, 0 - for pid in pids: - fields = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split() - cpu += (int(fields[11]) + int(fields[12])) / ticks_per_second - rss += int(fields[21]) * os.sysconf("SC_PAGE_SIZE") - samples.append( - { - "at": datetime.now(UTC).isoformat(), - "monotonic_seconds": time.monotonic(), - "phase": phase["name"], - "cpu_seconds": cpu, - "rss_bytes": rss, - "pids": pids, - } - ) - except (OSError, ValueError): - samples.append({"at": datetime.now(UTC).isoformat(), "sample_error": True}) - sampling_done.wait(0.5) - - def sample_db(): - while not sampling_done.is_set(): - value = subprocess.check_output( + sampler_threads = [] + sampler_errors = [] + try: + if container_mode: + app_container = os.environ["SYNTHETIC_CONTAINER"] + "-app" + root.chmod(0o755) + subprocess.run( [ "docker", - "stats", - "--no-stream", - "--format", - "{{json .}}", - os.environ["SYNTHETIC_CONTAINER"], + "run", + "--detach", + "--pull=never", + "--name", + app_container, + "--network", + os.environ["SYNTHETIC_NETWORK"], + "--publish", + "127.0.0.1::8000", + "--cpus", + str(config["app_cpu_limit"]), + "--memory", + config["app_memory_limit"], + "--pids-limit", + "256", + "--mount", + ( + f"type=bind,src={root / 'synthetic_settings.py'}," + "dst=/synthetic_settings.py,readonly" + ), + "--env", + "PYTHONPATH=/", + "--env", + "DJANGO_SETTINGS_MODULE=synthetic_settings", + "--env", + "SECRET_KEY=synthetic-disposable-not-a-deployment-key", + "--env", + "DATABASE_URL=postgres://synthetic:synthetic-local-only@db:5432/synthetic", + config["app_image"], + "gunicorn", + "vergabe_teilnahme.wsgi:application", + "--bind", + "0.0.0.0:8000", + "--workers", + str(config["workers"]), + "--timeout", + "30", ], - text=True, + check=True, + timeout=30, + stdout=subprocess.DEVNULL, ) - db_samples.append( - { - "at": datetime.now(UTC).isoformat(), - "phase": phase["name"], - "docker_stats": json.loads(value), - } - ) - sampling_done.wait(1) - - sampler = threading.Thread(target=sample, daemon=True) - db_sampler = threading.Thread(target=sample_db, daemon=True) - sampler.start() - db_sampler.start() - time.sleep(config["idle_seconds"]) - phase["name"] = "ordinary" - deadline = time.monotonic() + config["duration_seconds"] - completed = [0] * config["users"] - barrier = threading.Barrier(config["users"]) - - def worker(i): - jar = http.cookiejar.CookieJar() - - class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, *args, **kwargs): - return None - - opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar), NoRedirect()) - rows = [] - - def request(operation, method, path, data=None, expected=200, transfer=False): - started = time.perf_counter() - try: - headers = {} - body = None - if method == "post": - token = next((c.value for c in jar if c.name == "csrftoken"), "") - fields = dict(data or {}, csrfmiddlewaretoken=token) - if transfer: - boundary = "synthetic-" + uuid.uuid4().hex - body = b"" - for name, value in fields.items(): - body += (f'--{boundary}\r\nContent-Disposition: form-data; ' - f'name="{name}"\r\n\r\n{value}\r\n').encode() - body += ( - (f'--{boundary}\r\nContent-Disposition: form-data; ' - 'name="datei"; filename="synthetic.pdf"\r\n' - 'Content-Type: application/pdf\r\n\r\n').encode() - + b"%PDF-1.4" - + b"x" * (config["document_bytes"] - 8) - + f"\r\n--{boundary}--\r\n".encode() - ) - headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" - else: - body = urllib.parse.urlencode(fields).encode() - headers["Referer"] = base_url + path - req = urllib.request.Request( - base_url + path, data=body, headers=headers, method=method.upper() + port = ( + subprocess.check_output( + ["docker", "port", app_container, "8000/tcp"], text=True, timeout=10 ) - try: - response = opener.open(req, timeout=config["timeout_seconds"]) - except urllib.error.HTTPError as exc: - response = exc - received = len(response.read()) - status = response.status - ok = status == expected - response.close() - except Exception: - status, ok, received = None, False, 0 - rows.append( - { - "user": i, - "operation": operation, - "transfer": transfer, - "seconds": time.perf_counter() - started, - "status": status, - "ok": ok, - "response_bytes": received, - } + .strip() + .split(":")[-1] ) - return ok + base_url = f"http://127.0.0.1:{port}" + else: + server = subprocess.Popen( + [ + sys.executable, + "-m", + "gunicorn", + "vergabe_teilnahme.wsgi:application", + "--bind", + f"127.0.0.1:{port}", + "--workers", + str(config["workers"]), + "--timeout", + "30", + ], + start_new_session=True, + env=env, + stdout=server_log, + stderr=server_log, + ) + for _ in range(100): + try: + with urllib.request.urlopen(base_url + "/health/", timeout=1) as response: + if response.status == 200: + break + except OSError: + if server is not None and server.poll() is not None: + raise RuntimeError("Gunicorn startup failed; see isolated server log") + time.sleep(0.1) + else: + raise RuntimeError("Gunicorn readiness timeout") + samples = [] + db_samples = [] + phase = {"name": "idle"} - request("login-form", "get", "/accounts/login/") - barrier.wait(timeout=30) - request( - "login", - "post", - "/accounts/login/", - {"username": users[i], "password": password}, - 302, + def sample(): + ticks_per_second = os.sysconf("SC_CLK_TCK") + while not sampling_done.is_set(): + if container_mode: + value = subprocess.check_output( + [ + "docker", + "exec", + app_container, + "sh", + "-c", + ( + "cat /sys/fs/cgroup/cpu.stat; echo MEMORY; " + "cat /sys/fs/cgroup/memory.current" + ), + ], + text=True, + timeout=5, + ) + cpu_text, memory_text = value.split("MEMORY\n") + counters = { + k: int(v) for k, v in (line.split() for line in cpu_text.splitlines()) + } + samples.append( + { + "at": datetime.now(UTC).isoformat(), + "monotonic_seconds": time.monotonic(), + "phase": phase["name"], + "cpu_seconds": counters["usage_usec"] / 1e6, + "rss_bytes": int(memory_text.strip()), + "memory_kind": "cgroup-memory-current", + "cpu_stat": counters, + "pids": [app_container], + } + ) + sampling_done.wait(0.5) + continue + pids = [server.pid] + try: + pids += [ + int(x) + for x in Path(f"/proc/{server.pid}/task/{server.pid}/children") + .read_text() + .split() + ] + cpu, rss = 0, 0 + for pid in pids: + fields = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split() + cpu += (int(fields[11]) + int(fields[12])) / ticks_per_second + rss += int(fields[21]) * os.sysconf("SC_PAGE_SIZE") + samples.append( + { + "at": datetime.now(UTC).isoformat(), + "monotonic_seconds": time.monotonic(), + "phase": phase["name"], + "cpu_seconds": cpu, + "rss_bytes": rss, + "pids": pids, + } + ) + except (OSError, ValueError): + samples.append({"at": datetime.now(UTC).isoformat(), "sample_error": True}) + sampling_done.wait(0.5) + + def sample_db(): + while not sampling_done.is_set(): + value = subprocess.check_output( + [ + "docker", + "stats", + "--no-stream", + "--format", + "{{json .}}", + os.environ["SYNTHETIC_CONTAINER"], + ], + text=True, + timeout=5, + ) + db_samples.append( + { + "at": datetime.now(UTC).isoformat(), + "phase": phase["name"], + "docker_stats": json.loads(value), + } + ) + sampling_done.wait(1) + + sampler = threading.Thread( + target=guarded_sampler, args=(sample, sampler_errors), daemon=True ) - for n in range(config["iterations"]): - if time.monotonic() >= deadline: - break - request("list", "get", "/ausschreibungen/") - request("search", "get", "/ausschreibungen/?q=Synthetic") - title = f"Workload-{i}-{n}" - created = request( - "tender-create", + db_sampler = threading.Thread( + target=guarded_sampler, args=(sample_db, sampler_errors), daemon=True + ) + sampler_threads.extend([sampler, db_sampler]) + sampler.start() + db_sampler.start() + time.sleep(config["idle_seconds"]) + phase["name"] = "ordinary" + deadline = time.monotonic() + config["duration_seconds"] + completed = [0] * config["users"] + barrier = threading.Barrier(config["users"]) + + def worker(i): + jar = http.cookiejar.CookieJar() + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar), NoRedirect() + ) + rows = [] + + def request(operation, method, path, data=None, expected=200, transfer=False): + started = time.perf_counter() + try: + headers = {} + body = None + if method == "post": + token = next((c.value for c in jar if c.name == "csrftoken"), "") + fields = dict(data or {}, csrfmiddlewaretoken=token) + if transfer: + boundary = "synthetic-" + uuid.uuid4().hex + body = b"" + for name, value in fields.items(): + body += ( + f"--{boundary}\r\nContent-Disposition: form-data; " + f'name="{name}"\r\n\r\n{value}\r\n' + ).encode() + body += ( + ( + f"--{boundary}\r\nContent-Disposition: form-data; " + 'name="datei"; filename="synthetic.pdf"\r\n' + "Content-Type: application/pdf\r\n\r\n" + ).encode() + + b"%PDF-1.4" + + b"x" * (config["document_bytes"] - 8) + + f"\r\n--{boundary}--\r\n".encode() + ) + headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + else: + body = urllib.parse.urlencode(fields).encode() + headers["Referer"] = base_url + path + req = urllib.request.Request( + base_url + path, data=body, headers=headers, method=method.upper() + ) + try: + response = opener.open(req, timeout=config["timeout_seconds"]) + except urllib.error.HTTPError as exc: + response = exc + received = len(response.read()) + status = response.status + ok = status == expected + response.close() + except Exception: + status, ok, received = None, False, 0 + rows.append( + { + "user": i, + "operation": operation, + "transfer": transfer, + "seconds": time.perf_counter() - started, + "status": status, + "ok": ok, + "response_bytes": received, + } + ) + return ok + + request("login-form", "get", "/accounts/login/") + barrier.wait(timeout=30) + request( + "login", "post", - "/ausschreibungen/neu/", - {"titel": title, "ausschreiber": "Synthetic"}, + "/accounts/login/", + {"username": users[i], "password": password}, 302, ) - tender = Ausschreibung.objects.filter(titel=title).first() if created else None - if tender: - base = f"/ausschreibungen/{tender.pk}/" - request("detail", "get", base) - request( - "lot-create", + for n in range(config["iterations"]): + if time.monotonic() >= deadline: + break + request("list", "get", "/ausschreibungen/") + request("search", "get", "/ausschreibungen/?q=Synthetic") + title = f"Workload-{i}-{n}" + created = request( + "tender-create", "post", - base + "lose/neu/", - {"losnummer": "1", "lostitel": "Synthetic lot"}, + "/ausschreibungen/neu/", + {"titel": title, "ausschreiber": "Synthetic"}, 302, ) - request( - "task-create", - "post", - base + "aufgaben/neu/", - {"titel": "Synthetic task", "typ": "fachlich", "prioritaet": 2}, - 302, - ) - request( - "document-upload", - "post", - base + "dokumente/hochladen/", - {"kategorie": "intern", "version": "1.0"}, - 302, - True, - ) - document = Dokument.objects.filter(ausschreibung=tender).first() - if document: - request("document-download", "get", document.datei.url, transfer=True) - completed[i] += 1 - time.sleep(config["think_seconds"]) - connections.close_all() - return rows + tender = Ausschreibung.objects.filter(titel=title).first() if created else None + if tender: + base = f"/ausschreibungen/{tender.pk}/" + request("detail", "get", base) + request( + "lot-create", + "post", + base + "lose/neu/", + {"losnummer": "1", "lostitel": "Synthetic lot"}, + 302, + ) + request( + "task-create", + "post", + base + "aufgaben/neu/", + {"titel": "Synthetic task", "typ": "fachlich", "prioritaet": 2}, + 302, + ) + request( + "document-upload", + "post", + base + "dokumente/hochladen/", + {"kategorie": "intern", "version": "1.0"}, + 302, + True, + ) + document = Dokument.objects.filter(ausschreibung=tender).first() + if document: + request("document-download", "get", document.datei.url, transfer=True) + completed[i] += 1 + time.sleep(config["think_seconds"]) + connections.close_all() + return rows - started = datetime.now(UTC).isoformat() - before = time.perf_counter() - with concurrent.futures.ThreadPoolExecutor(max_workers=config["users"]) as pool: - rows = [r for group in pool.map(worker, range(config["users"])) for r in group] - ordinary = summarize([r for r in rows if not r["transfer"]]) - transfers = summarize([r for r in rows if r["transfer"]]) - complete = all(completed) and len(rows) == sum(completed) * 8 + config["users"] * 2 - result = { - "schema_version": 1, - "started_at": started, - "finished_at": datetime.now(UTC).isoformat(), - "duration_seconds": time.perf_counter() - before, - "config": config, - "environment": { - "python": platform.python_version(), - "django": django.get_version(), - "platform": platform.platform(), - "database": "disposable PostgreSQL 16 container", - "transport": "loopback HTTP Gunicorn", - "workers": config["workers"], - }, - "ordinary": ordinary, - "document_transfer": transfers, - "complete": complete, - "provisional_pass": complete - and ordinary["errors"] == 0 - and transfers["errors"] == 0 - and ordinary["p95_seconds"] <= config["ordinary_p95_seconds"], - "limitations": [ - "No browser, ingress, TLS, SSO or application container quota", - "No CPU/memory allocation recommendation or human acceptance", - "No timed startup/restart or distinct burst; configured idle and ordinary phases only", - ], - "operations": rows, - "runtime_samples": samples, - "database_samples": db_samples, - "completed_iterations": completed, - } - sampling_done.set() - sampler.join() - db_sampler.join() - server.terminate() - server.wait(timeout=10) - server_log.close() - connections.close_all() - return result + started = datetime.now(UTC).isoformat() + before = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=config["users"]) as pool: + rows = [r for group in pool.map(worker, range(config["users"])) for r in group] + ordinary = summarize([r for r in rows if not r["transfer"]]) + transfers = summarize([r for r in rows if r["transfer"]]) + complete = all(completed) and len(rows) == sum(completed) * 8 + config["users"] * 2 + result = { + "schema_version": 1, + "started_at": started, + "finished_at": datetime.now(UTC).isoformat(), + "duration_seconds": time.perf_counter() - before, + "config": config, + "environment": { + "python": platform.python_version(), + "django": django.get_version(), + "platform": platform.platform(), + "database": "disposable PostgreSQL 16 container", + "transport": "loopback HTTP Gunicorn", + "workers": config["workers"], + }, + "ordinary": ordinary, + "document_transfer": transfers, + "complete": complete, + "provisional_pass": complete + and ordinary["errors"] == 0 + and transfers["errors"] == 0 + and ordinary["p95_seconds"] <= config["ordinary_p95_seconds"], + "limitations": [ + "No browser, ingress, TLS or SSO", + "No CPU/memory allocation recommendation or human acceptance", + "No timed startup/restart or distinct burst; idle and ordinary phases only", + ], + "operations": rows, + "runtime_samples": samples, + "database_samples": db_samples, + "completed_iterations": completed, + } + sampling_done.set() + for thread in sampler_threads: + thread.join(timeout=7) + gaps = sampling_gaps(samples, db_samples, sampler_errors) + if any(thread.is_alive() for thread in sampler_threads): + gaps.append({"sampler": "cleanup", "reason": "thread did not stop"}) + result["measurement_gaps"] = gaps + result["measurement_complete"] = not gaps + if container_mode: + result["app_container"] = json.loads( + subprocess.check_output( + ["docker", "inspect", "--format", "{{json .}}", app_container], + text=True, + timeout=10, + ) + ) + # Retain only public runtime identifiers/limits, never full container environment. + info = result.pop("app_container") + result["app_runtime"] = { + "image_id": info["Image"], + "nano_cpus": info["HostConfig"]["NanoCpus"], + "memory_bytes": info["HostConfig"]["Memory"], + "memory_kind": "cgroup-memory-current includes page cache", + "oom_killed": info["State"]["OOMKilled"], + "dockerfile_sha256": hashlib.sha256(Path("Dockerfile").read_bytes()).hexdigest(), + } + return result + finally: + sampling_done.set() + for thread in sampler_threads: + if thread.ident is not None: + thread.join(timeout=7) + stop_process(server) + if app_container is not None: + subprocess.run( + ["docker", "rm", "--force", "--volumes", app_container], + timeout=15, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + server_log.close() + connections.close_all() if __name__ == "__main__": @@ -371,29 +562,41 @@ if __name__ == "__main__": config = validate(json.loads(args.config.read_text())) sys.path.insert(0, str(Path(__file__).resolve().parents[1])) name = "vergabe-synthetic-" + uuid.uuid4().hex[:12] - # Cached image only, loopback random host port, no existing volume or database. - subprocess.run( - [ - "docker", - "run", - "--detach", - "--pull=never", - "--name", - name, - "--publish", - "127.0.0.1::5432", - "--env", - "POSTGRES_DB=synthetic", - "--env", - "POSTGRES_USER=synthetic", - "--env", - "POSTGRES_PASSWORD=synthetic-local-only", - "postgres:16-alpine", - ], - check=True, - stdout=subprocess.DEVNULL, - ) + network = name + "-network" + network_args = [] try: + if config.get("runtime") == "container": + subprocess.run( + ["docker", "network", "create", network], + check=True, + timeout=15, + stdout=subprocess.DEVNULL, + ) + os.environ["SYNTHETIC_NETWORK"] = network + network_args = ["--network", network, "--network-alias", "db"] + # Cached image only, loopback random host port, no existing volume or database. + subprocess.run( + [ + "docker", + "run", + "--detach", + "--pull=never", + "--name", + name, + "--publish", + "127.0.0.1::5432", + "--env", + "POSTGRES_DB=synthetic", + "--env", + "POSTGRES_USER=synthetic", + "--env", + "POSTGRES_PASSWORD=synthetic-local-only", + *network_args, + "postgres:16-alpine", + ], + check=True, + stdout=subprocess.DEVNULL, + ) for _ in range(100): ready = subprocess.run( ["docker", "exec", name, "pg_isready", "-U", "synthetic"], @@ -428,9 +631,17 @@ if __name__ == "__main__": finally: subprocess.run( ["docker", "rm", "--force", "--volumes", name], - check=True, + timeout=15, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) + if network_args: + subprocess.run( + ["docker", "network", "rm", network], + check=True, + timeout=15, + stdout=subprocess.DEVNULL, + ) result["source_revision"] = subprocess.check_output( ["git", "rev-parse", "HEAD"], text=True ).strip() @@ -459,7 +670,7 @@ if __name__ == "__main__": "cgroup_cpu_max": Path("/sys/fs/cgroup/cpu.max").read_text().strip() if Path("/sys/fs/cgroup/cpu.max").exists() else "unavailable", - "app_cpu_quota": "not imposed by harness", + "app_cpu_quota": config.get("app_cpu_limit", "not imposed by harness"), } result["config_sha256"] = hashlib.sha256(args.config.read_bytes()).hexdigest() args.output.write_text(json.dumps(result, indent=2) + "\n") @@ -476,4 +687,4 @@ if __name__ == "__main__": } ) ) - raise SystemExit(0 if result["provisional_pass"] else 1) + raise SystemExit(0 if result["provisional_pass"] and result["measurement_complete"] else 1) diff --git a/scripts/test_synthetic_pilot.py b/scripts/test_synthetic_pilot.py index 5e546af..535163f 100644 --- a/scripts/test_synthetic_pilot.py +++ b/scripts/test_synthetic_pilot.py @@ -1,4 +1,6 @@ import importlib.util +import subprocess +import sys import unittest from pathlib import Path @@ -52,6 +54,33 @@ class SyntheticContractTests(unittest.TestCase): self.assertEqual(self.config()["ordinary_p95_seconds"], 2) +class LifecycleTests(unittest.TestCase): + def test_process_group_cleanup_stops_real_child(self): + process = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], start_new_session=True + ) + try: + raise RuntimeError("injected worker failure") + except RuntimeError: + pilot.stop_process(process) + self.assertIsNotNone(process.poll()) + + def test_sampler_exception_is_retained_as_gap(self): + errors = [] + + def broken_sampler(): + raise OSError("injected collection failure") + + pilot.guarded_sampler(broken_sampler, errors) + gaps = pilot.sampling_gaps([], [], errors) + self.assertEqual(gaps[0]["error_type"], "OSError") + self.assertTrue(gaps) + + def test_sparse_samples_are_not_complete(self): + rows = [dict(monotonic_seconds=0), dict(monotonic_seconds=5)] + self.assertTrue(pilot.sampling_gaps(rows, [{}], [])) + + class SizingTests(unittest.TestCase): def test_failed_measurement_does_not_recommend(self): spec = importlib.util.spec_from_file_location( @@ -62,6 +91,15 @@ class SizingTests(unittest.TestCase): with self.assertRaises(ValueError): sizing.recommend({"runtime_samples": [], "complete": False}, {}) + def test_sampler_gap_prevents_recommendation(self): + spec = importlib.util.spec_from_file_location( + "sizing", Path(__file__).with_name("summarize_synthetic_sizing.py") + ) + sizing = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sizing) + with self.assertRaisesRegex(ValueError, "sampling gaps"): + sizing.recommend({"measurement_complete": False}, {}) + def test_cpu_delta_and_rounding(self): spec = importlib.util.spec_from_file_location( "sizing", Path(__file__).with_name("summarize_synthetic_sizing.py")