Repository navigation
adaptive: error-rate revert floor of 3 faults, ioUringViable counts the started loops, slotsMu/Workers/bind-wait tests, the four 2-CPU test failures (v1.6.0) - #1128
Merged
Conversation
…controller's conns-per-worker policy off (v1.6.0, Refs #886) With 2 workers 64 conns are 32 conns/worker, over the 24 up-threshold, so the tick loop promoted the conns back to io_uring about 2 s after each forced revert to epoll and TestReverseTransplant, TestReverseTransplantAsync, TestBidirectionalFlap and TestBidirectionalFlapAsync read the wrong engine. They failed 40 of 40 runs at --cpus 2 and passed 40 of 40 at --cpus 4. Only newBoundAdaptiveH is touched; all its callers switch by ForceSwitch.
…oUringViable counting the loops the engines start (v1.6.0, Refs #886) error_rate_floor_886_linux_test.go: one or two faults in an interval must not read as a rate (886 item 29, decided A on 2026-10-25: a minimum fault count of 3 per interval; max(requests, connection-seconds) is kept). iouring_viable_loops_886_linux_test.go: on a big.LITTLE host ioUringViable must compare the RLIMIT_MEMLOCK ceiling with the loops the engines start (the platform plan), not with the requested count (886 item 30, part 1). The tests need a seam to stand in such a host, so this commit adds planLoopCPUs, which nothing reads yet: the tests fail on it. The next commit reads it.
…l; ioUringViable counts the loops the engines start (v1.6.0, Refs #886) 886 item 29 (user decision A, 2026-10-25): faultRate returns rate 0 for an interval with fewer than minRevertFaults (3) faults, so one stray accept error (ECONNABORTED, EINTR, ECANCELED, EBADF) in an idle interval does not revert a server started on io_uring, where the revert is one-way. The divisor is still reported, ErrorDelta still holds the bucket growth, max(requests, connection-seconds) is kept. Per interval, not cumulative. 886 item 30, part 1: ioUringViable compares the RLIMIT_MEMLOCK ceiling with the length of the platform CPU plan (startedLoops), the number of loops the engines start, not with the requested count; on a big.LITTLE host a ceiling between the plan and the request no longer keeps the engine off io_uring. Part 2 (the conns-per-worker thresholds) is a calibration that needs cluster data and is not changed.
…oth blocks); Metrics().Workers sums unequal sub-engines (v1.6.0, Refs #886) 886 item 26: the test holds slotsMu, runs a switch that has to build and publish the lazy standby, and requires performSwitch to park on the lock before it publishes or commits. It needs neither -race nor an overlap. Both blocks are covered: the io_uring standby of an epoll start and the epoll standby of an io_uring start, the one no test caught. 886 item 31 (test part): Metrics().Workers for 8 + 10 loops is 18, the lazy shape adds none, and the controller divides by the active engine's own loops. The NIC-interrupt Warn de-duplication of that item is platform code outside this lane.
…ithout a 3 s deadline and log how long it took (v1.6.0, Refs #890) 890 item 98: the 3 s poll was missed once in 400 runs under CPU starvation, and nobody knew whether Listen was slow or the poll starved. awaitBind890 waits until the address is published, Listen returns (its own error is then reported), or 30 s, and the bind duration is logged.
886 item 27: the doc of assertSettledRetimed592 describes the stack read, not the replaced latency bar; both /ping windows probe through the same goroutine-and-timer shape (the stack watch used to add them to one window only); the early-return test doc lists its three ways; the stack dump grows until it fits; the classifier reads RWMutex and header wait reasons as well as a sync.Mutex.Lock frame; listenBindDeadline is atomic.
…ess string (v1.6.0, Refs #886) 886 item 20 (decided B, 2026-10-12: keep, add a comment): bindiag lives under internal/engine/internal and cannot be imported from internal/adaptive. Comment only.
|
Caution Review failedThe pull request is closed.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… server stay on io_uring (v1.6.0, Refs #886) Review of the floor (886 item 29, U-14): a count of 3 faults per 1 s interval means a server below 3/p requests a second (p over 0.05, at most 60 rps) that fails never reverts, on the opt-in io_uring start where the revert is the only switch. One 30 s interval after a switch does revert. The tests document this consequence and must be flipped if the maintainer chooses a windowed floor. Rule unchanged.
newRampAdaptive waits for the bind through listenAndAwaitBind890 (the same 3 s poll as s0Bind); the conns-per-worker test is driven through ctrl.evaluate so it is the engine handed to the sampler that is checked; the goroutine-count upper bound of the full-stack test is read on both sides of the dump; wording of two comments; startedLoops notes which thread's affinity it plans on.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lane L17 ADAPTIVE of v1.6.0, the bookkeeping and fault-count logic of the adaptive engine. Refs #886, Refs #890.
Method: every item was checked on main (a5fb8f1) first; all eight were still open. Item ordinals count every checkbox of the issue body (ticked or not), and each row quotes the item's opening words because #1099 counts only unchecked boxes and gives other numbers. Commit 1 is a test fix, commit 2 the failing-on-main tests, commit 3 the behaviour change, commits 4 to 7 test hygiene and a comment.
ioUringViableMetricsleaf lockslotsMu... no test catches droppingslotsMufrom the PRIMARY slot-publish block"Metrics.Workersis the sum of both sub-engines' loops"s0Bind... waits 3 s fore.Addr()"s0BindandnewBoundAdaptiveHinternal/adaptive/porthold.go(about lines 153-155) keeps its ownzoneNamecopy"The four failing tests: a deterministic test assumption, not load, not an engine bug
TestReverseTransplant,TestReverseTransplantAsync,TestBidirectionalFlap,TestBidirectionalFlapAsyncfail on unmodified main when the process has 2 CPUs. golang:1.27.3,-race, each run alone with-count=10(pass counts):--cpus 2--cpus 4--cpus 2 -e GOMAXPROCS=4./internal/adaptivepackage,-race,-count=1--cpus 2: exactly these 4 fail, 175 others pass;--cpus 4: ok--cpus 2: ok (195 PASS lines, 0 FAIL, 0 SKIP)The third row separates load from worker count: same CPU quota as the failing run, GOMAXPROCS 4, and all 40 runs pass. The cause is the worker count. The tests drive every switch with
ForceSwitchand put 64 keep-alive conns on the engine, but the controller's own tick loop runs too (cooldown = 0in the helper). With 2 workers (GOMAXPROCS follows the cgroup quota) 64 conns are 32 conns/worker, over the 24upThreshold; two ticks later the controller promoted the conns back to io_uring (the log showsengine switch recommended from=epoll to=io_uring reason="sustained high load" conns_per_worker=32andoscillation detected), so the tests read the wrong engine. At 4 workers it is 16. It is a hard function of the worker count, so it fails every time at 2 and never at 3 or more; a hosted runner has 4 CPUs, which is why CI is green. Not an engine bug: the controller did what its policy says for 32 conns/worker; production has a 30 s cooldown.Fix (commit 1, test only):
newBoundAdaptiveHsetsctrl.connSwitchEnabled = falsebefore Listen. It is notFreezeSwitching()(thes0Bindidiom) because a freeze also suppresses the error-rate revert, andconnSwitchEnabled = falsekeeps it; every caller of the helper (adopt_carry_async_543,idle_follow_switch_standby_804,error_accounting_live, the two scenarios) switches byforceSwitchToonly.886.29: the floor
faultRatereturns rate 0 when the interval has fewer thanminRevertFaults(3) faults. The divisor is still returned,ErrorDeltastill holds the bucket growth,ErrorRatestays the plain ratio at 3 faults and up. The value 3 is the maintainer's ("for example 3", DECISIONS-RECOMMENDED), not a measurement. Per interval and not cumulative (decision text). max(requests, connection-seconds) is kept as is; a pure connection-seconds divisor or a minimum divisor were not chosen (option A only).Failing on main (commit 2 tree 8374462,
--cpus 2):TestFaultRateFloor8865 subtests (one fault idle, one fault in one and in nineteen requests, two faults in four requests, two faults against 200 connection-seconds),TestOneStrayAcceptErrorDoesNotRevertAnIdleIOUring886(one and two accept errors revert),TestFaultsBelowTheFloorDoNotAddUpAcrossIntervals886(one sampler and one controller over four consecutive intervals: 2, 2, 1 faults do not revert, then 3 do). At commit 3 all pass. ExistingTestPeerGoneIsSubtractedNotTheWholeErrorCount856expected 0.01 for one real fault and now expects 0 (updated in commit 3, with a new case at the floor).Mutant (floor arm deleted):
TestFaultRateFloor886,TestFaultsBelowTheFloorDoNotAddUpAcrossIntervals886,TestOneStrayAcceptErrorDoesNotRevertAnIdleIOUring886andTestPeerGoneIsSubtractedNotTheWholeErrorCount856go red.886.30 part 1:
ioUringViablewantWorkerswascfg.Resources.Resolve().Workers; it is nowstartedLoops(...), the length ofplatform.PlanWorkerCPUsFor(requested, explicit, ""), the plan the sub-engines start from (iouring.planWithinMemlockcaps the plan, not the request). On a big.LITTLE host with 12 requested and 8 planned, a ceiling of 8 to 11 no longer keeps the engine off io_uring. A host of alike CPUs is unchanged (plan length = request). The plan is made without the listen address, so the rx-port rule, which can only lower the count, is not applied: the count is an upper bound, never lower than what the engines start.Commit 2 carries the seam
planLoopCPUs(a var over the platform planner, likemaxWorkersForMemlock) with nothing reading it yet, so the tests can stand in a big.LITTLE host and compile; they fail on it (TestIOUringViableCountsTheLoopsTheEnginesStart886,...PlansAUserSetWorkerCountAsExplicit886,TestChooseStartEngineUsesThePlannedLoops886: the WorkloadHint=HighConcurrency start stays on epoll). Commit 3 reads it. Mutant (Resolve().Workersrestored): the same three go red.886.30 part 2: not changed, and what data is missing
The thresholds (
upThreshold24,downThreshold12,highWatermark48) are already fields of the controller, so they are parameterised; I did not add a scale factor because no data says which way it should go. What the existing evidence (probatoriumcluster-reads-20261025/p2-read/tables.tsv, msr1, h1 async, 8 loops after #1049, 12 before) brackets:Only one point has both engines at the same load: at 512 conns io_uring is ahead on both trees (+15.7% at 8 loops, +11.7% at 12 loops). There is no epoll cell below 512 and no io_uring cell at 128, and the adaptive cell at 128 sits at cpw 16 (10.7 at 12 loops), under the 24 threshold either way, so it never switched. The crossover the 24 comes from is not located on this host. Missing run: an msr1 sweep of epoll and io_uring, 8 pinned loops, at about 96, 128, 192, 256 and 384 conns (cpw 12 to 48), several repetitions, to see whether the crossover sits at a conns-per-loop or at a connection count. That is the P2/F5a cluster run; the cluster was not touched. Until then: on a host with 8 loops the thresholds act at 2/3 of the connection count they have at 12.
TestControllerDividesByTheActiveEnginesOwnLoops886pins the arithmetic (192 conns on 8 loops is 24.0, divided by the active engine's loops, not the sum).886.26, 886.31, 890.98, 886.27, 886.20
These pass on main by nature (a test that adds coverage, a helper, comments), so they cannot be failing-first; the evidence is a mutant that turns the named test red.
TestPerformSwitchPublishesTheBuiltStandbyUnderSlotsMu886holdsslotsMuitself, runs a switch that builds and publishes the lazy standby, and requiresperformSwitchto park on a mutex (stack read) before it commits; a publish without the lock commits instead. It needs neither-racenor an overlap. Both blocks: the io_uring standby of an epoll start and the epoll standby of an io_uring start (the one no test caught). Mutants: lock dropped from the primary block fails the primary subtest only; dropped from the secondary block fails the secondary subtest only.TestMetricsWorkersSumsUnequalSubEngines886(8 + 10 = 18, lazy shape adds none). The other half (the NIC-interrupt Warn is logged by the active plan and again by the lazy standby's plan, with nothing de-duplicating it) is ininternal/platform/the engines, outside this lane's files, behaviour-neutral: not touched.awaitBind890replaces the 3 s poll ins0BindandnewBoundAdaptiveH: it waits until the address is published,Listenreturns (its own error is reported) or 30 s, and logs how long the bind took. It does not tell whether the 1-in-400 miss was a slow Listen or a starved poll; the next occurrence will say.TestAwaitBindOutwaitsTheOldThreeSeconds890and four more drive it with a fake address source; mutant (limit back to 3 s) fails the first.newRampAdaptive(ramp_transplant_test.go, 5 callers) had the same 3 s poll with the same message and goes through the helper too (review round 1). Left as they are:async_abort_791_linux_test.go:161anddriver_provider_test.go:57(3 s bind polls; the item names the helpers), and the longer ones,detect_bound_974(10 s),prebound_listener_test.go:57(10 s),iouring_floor(25 s) and, in another package,internal/engine/iouring/adaptive_abort_844_linux_test.go:266(5 s). (driver_provider_test.go:327is a ForceSwitch loop, not a bind poll.)internal/adaptive): theassertSettledRetimed592doc describes the stack read, not the replaced latency bar; both /ping windows probe through onepingWatched(goroutine and timer per probe, the reference window passes no stall hook); the early-return test doc lists its three ways;fullStack589grows its buffer until the dump fits;parkedOnMutex589reads RWMutex frames and[sync.Mutex.Lock/[sync.RWMutex.*Lockwait reasons as well as async.(*Mutex).Lockframe;listenBindDeadlineis an atomic. Mutants: classifier restored failsTestClassifyLoopStackReadsEveryMutexWait589; fixed 1 MiB buffer restored failsTestFullStackIsNotCutAtItsFirstBuffer589(20000 parked goroutines, over 1 MiB, run in 0.27 s under -race). Nit 7 ("most fixed-tree arms record 0 captures") is an observation about the observable (it bites only when a probe goes unanswered; the rig logscaptures=0on the fixed tree) and is not changed. The rig itself,TestAdaptiveSettledRouteRetime592as the CI step runs it (--cpus 2,-race, memlock unlimited,CELERIS_REQUIRE_IOURING_WORKERS=1): 2 of 2 runs pass, all six arms, no SKIP.bindHoldcall and onzoneNamesays bindiag lives atinternal/engine/internal/bindiagand cannot be imported frominternal/adaptive, that the zone rule is the same, and that a change has to be made in both. engines: OnDetach rollback first, io_uring pause wake, zoned bind address, one-dump NIC table, MaxEvents by allowed nodes, upgrade-carry count, backlog hook (v1.6.0, Refs #885 #886 #889 #890) #1127 addsbindiag.ZoneIDonly; the zone-name rule is untouched there.Decision owed: the floor at low traffic
Found by the Opus review of this PR; the rule is unchanged here and is for the maintainer to confirm. The 3-fault floor matches the decision's words (U-14, "a minimum fault count (for example >= 3) per interval"), but it has a consequence the decision text did not weigh: it is a count per interval, so low traffic that fails in full never reaches it. Measured with a deterministic mock test over 600 one-second intervals on the opt-in io_uring start (
connSwitchEnabledfalse, where the revert is the only switch the controller can make):In general: any fault ratio p over 0.05 at fewer than 3/p requests a second (at most 60 rps) never reverts. A low-traffic server whose io_uring fails every response keeps failing for good; before the floor it went back to epoll within a second.
Interval length: the floor is a count, not a rate, so the same steady load behaves differently by interval length. 2 faults/s at 2 rps never reverts on 1 s intervals, but reverts on the first sample after a switch (one 30 s interval: 60 faults over 60 requests).
The alternative is a windowed floor (3 faults accumulated over the last N samples, N about 10, reset on a switch, with the rate taken over the same window; or a floor scaled by elapsed time). It keeps the stray-accept-error case (
TestOneStrayAcceptErrorDoesNotRevertAnIdleIOUring886,TestFaultRateFloor886) and closes the hole, and it invertsTestFaultsBelowTheFloorDoNotAddUpAcrossIntervals886(2 + 2 faults would then revert).TestPinFloorLetsALowTrafficFailingServerStayOnIOUring886andTestPinFloorIsACountSoALongIntervalReverts886pin today's behaviour with the table above (mocks only, 0.00 s); they say in their comment that they document this consequence and must be flipped if a windowed floor is chosen.Review round 1 (Opus, APPROVE-WITH-FIXES)
New commits on top, plus a merge of main (789d88d), no rebase:
startedLoopsdirection corrected; the leftover bind-poll list corrected andnewRampAdaptiveconverted tolistenAndAwaitBind890; the 886.12/886.28 follow-ups recorded; the upper bound ofTestFullStackIsNotCutAtItsFirstBuffer589is now read on both sides of the dump (before and after:max(before, after) + 50);TestControllerDividesByTheActiveEnginesOwnLoops886is driven throughctrl.evaluateso the engine the controller hands to the sampler is what is checked; two comments reworded (the floor removes the cases of one and two faults; the constant pin test says it pins a constant);startedLoopsnotes the thread-affinity point.listenGapHookbeing a plain var (not in the item).Local results
After review round 1 and the merge of main (789d88d),
--cpus 2: the new and changed adaptive tests-race -count=3ok (66 PASS lines, 0 FAIL); the root classifier, bar and full-stack tests-race -count=3ok (15 PASS); whole./internal/adaptive-race -count=1ok in 333 s (197 PASS, 0 FAIL, 0 SKIP);go vet(GOOS=linux) clean;golangci-lint(GOOS=linux) no finding in a touched file. The figures below are those of the first head.golang:1.27.3,
--cpus 2unless stated, seccomp unconfined, memlock unlimited. Whole./internal/adaptiveat HEAD,-race -count=1: ok in 332 s (195 PASS, 0 FAIL, 0 SKIP). New root tests under-race: pass. Every commitgo vets clean for linux (checked one by one).golangci-lint(GOOS=linux): no finding in a touched file; 3 errcheck findings in untouched files (async_abort_791_linux_test.go:288,detect_bound_974_linux_test.go:163,driver_provider_test.go:63), present on main. No exported API changed, somage apiCheckis not needed. No new test skips, so the CI race-step allow-list is unchanged; no workflow file is touched.Release notes
Findings, not fixed here
engines.md) is unblocked. Sentence to add to its rewording: "An interval with fewer than 3 faults reads an error rate of 0, so the io_uring-to-epoll safety revert needs at least 3 faults in one sampling interval; a server below about 3 faults per second never reverts, even when every request fails (see the decision owed in adaptive: error-rate revert floor of 3 faults, ioUringViable counts the started loops, slotsMu/Workers/bind-wait tests, the four 2-CPU test failures (v1.6.0) #1128)."controller.go:154,:162return before:184): pre-existing on main, 30-minute simulation at production cooldown with io_uring failing 50% whenever active: 15 switches, io_uring active 1042 of 1800 s, 892 s of them under the lock. It is the production form of the four 2-CPU test failures. Not fixed here; issue draft (not filed):/Users/fuming/.claude/projects/-Users-fuming-Documents-github-celeris-probatorium/evidence/lanes-20261025/L17ADAPTIVE/m2-issue-draft.md.For the reviewer to attack
startedLoopsskips the rx-port rule, so it is an upper bound on the loops the engines start. An over-count makesioUringViablesay "not viable" in the band between the loops left after the rx-port rule and the planned loops: it narrows viability, which is the safe direction (never "viable" for fewer loops than they start). It also plans on the affinity of the thread that callsNew, the sub-engines on the one that callsListen; they differ only if an application narrows one thread's mask (noted in a comment).TestPerformSwitchPublishesTheBuiltStandbyUnderSlotsMu886reads a goroutine stack for async.Mutexpark insideperformSwitch; it relies on the test holding onlyslotsMu. IfperformSwitchever takes another uncontended-but-held lock before the publish, the park check becomes ambiguous.connSwitchEnabled = falseinnewBoundAdaptiveH: any future caller that wants the controller's load policy must not use this helper.planLoopCPUsin commit 2 is dead code on purpose; confirm the red-first split is worth it.