feat(pools)!: constructor-only configuration, one acquisition budget - #94
feat(pools)!: constructor-only configuration, one acquisition budget#94loks0n wants to merge 1 commit into
Conversation
Greptile SummaryConstructor-only pool configuration replaces layered retry settings with a single acquisition wait timeout.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (4): Last reviewed commit: "feat(pools)!: constructor-only configura..." | Re-trigger Greptile |
b257d98 to
be10696
Compare
2dde9ce to
4c75d44
Compare
|
All three valid, and one was a bug I introduced. Fixed. Destroy corrupts reserved capacity — real defectCorrect, and the worst of the three. The decrement sat outside the guard: if (isset($this->active[$connection->id])) {
unset($this->active[$connection->id]);
}
--$this->reserved; // unconditionalDestroying the same connection twice, or one belonging to another pool, drove Regression test added, and verified it catches the defect — with the old code a size-2 pool reports: Worth noting the 1.x Creation failures wait unnecessarily — right, and it contradicted my own designAlso correct, and the sharper point is that it was philosophically inconsistent: falling through to wait out the budget after our own create failed is a retry in disguise, which is the exact thing this PR removes. It also contradicted the README.
Two tests asserted the old wrapping and were updated. One of them needed care: widening its catch to Creation bypasses acquisition deadline — real, but not enforceableCorrect that So this is a documentation defect rather than a code one — I claimed a budget the pool cannot enforce. Now stated honestly:
The 80 tests, 326 assertions. |
4c75d44 to
c5df830
Compare
There was a problem hiding this comment.
Greptile has paused reviews on this repository — it used its 100 free open-source review credits for this billing period. Reviews resume automatically on August 8. To continue before then, an organization admin can keep reviews running past the free credits — those bill as normal usage.
Five interacting knobs decided how long pop() could block, and nothing named the total. reconnectAttempts x reconnectSleep inside retryAttempts x retrySleep, plus synchronizedTimeout on each round, at the shipped defaults meant a pool fronting an unreachable backend took around 44 seconds to fail a single acquisition against a 3 second connect timeout. Nobody chose 44 seconds; it was the product of defaults nobody had multiplied out. Consumers each picked their own numbers — one sets reconnectAttempts to 15, giving roughly three minutes — and the pattern drifts because a default plus an optional override always drifts. Replace all five with `timeout`: one required constructor argument that is the whole budget a caller may spend. pop() creates when there is capacity, otherwise waits out the remaining budget on the adapter, then throws. One number, and it is the number a caller experiences. It is the pool's only timeout, and it matches the name Adapter::pop() already used for the same concept. Creation retries are gone with no replacement. Retrying a failed connect belongs to the init callback, because only the caller knows whether a failure is worth retrying and on what schedule, and a circuit breaker composed there carries state between calls in a way an attempt counter cannot. Retrying inside the pool was also actively harmful: the capacity slot is reserved for the whole attempt sequence, so retries turned a shared resource into a private speculative wait. Configuration is now constructor-only readonly state, per the package-design skill. Twelve setters are removed across Pool, Connection and Group, along with the getters that only existed to read them back. Pool drops from 23 public methods to 10, Connection from 9 to 3. Connection is final readonly. Identity, resource and owning pool are fixed at construction, so a connection cannot be repointed while another coroutine holds it, and because the pool is no longer nullable, returning one cannot fail for want of an owner — two exception paths disappear. Other changes that fell out of the review: - destroy() releases capacity only for a connection this pool is actually holding. It decremented unconditionally, so destroying the same connection twice, or one belonging to another pool, drove the count below the truth and let the pool create beyond its size. - A failed create no longer falls through to waiting out the remaining budget. Waiting for another caller's connection after your own create failed is a retry in disguise, which this change exists to remove, and it contradicted the documented behaviour. init's exception now propagates untouched, so callers keep the type they need to act on. - timeout is documented as bounding the wait for an available connection, not the total call. The pool cannot interrupt a blocking connect, so init owns its own connect timeout; claiming otherwise promised something unenforceable. - destroy() no longer creates the replacement itself. It frees capacity and the next pop() creates one, which matches the pool's documented lazy initialisation and stops destroy() blocking on a connect or failing for reasons unrelated to the connection being discarded. - The pool measures use time itself, keyed by connection id, so release() no longer takes a $start timestamp from its caller. Group gets correct per-pool timing without threading timestamps through. - The Swoole adapter clamped a non-positive channel timeout, which Swoole reads as "wait forever" — the exact opposite of a zero budget, and a hang the old retry loop hid. Its docblock claimed 0 meant non-blocking. - Adapter::pop() takes a float timeout, so sub-second budgets survive the seam. The contract's docblocks now say what a non-concurrent adapter honestly does instead of apologising for ignoring arguments. - init is typed Closure rather than callable, so TResource survives into Connection without an inline annotation. - Guards for size and timeout, throwing rather than annotating. - phpstan at level max, clean without a single ignore. Rector extends the root with the remaining stable prepared sets. - README documents the acquisition budget and where retries belong, with an "Upgrading from 1.x" section mapping every removed API. The Swoole suite's six snake_case test methods are renamed to camelCase, matching every other test in the package. Dependents are updated in the same commit. The client and queue test suites construct pools directly, and client's factory helper takes a Closure to match the tightened init type. Messaging built its pool with the adapter passed as `pool:` and sized it `min(count($requests), MAX)`, which is zero for an empty batch and now trips the size guard, so it passes `adapter:`, clamps the size and states its timeout. All three constrain `^2.0` and pass --linked. Per the note in the tests workflow, that leaves the registry combination unbuildable until pools/2.0.0 is tagged, which is why dependents run linked here. Net 386 lines lighter in pools. Its suite runs in 0.4s rather than 47s, because the sleeps it used to spend were the point. BREAKING CHANGE: see "Upgrading from 1.x" in packages/pools/README.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c5df830 to
fc54711
Compare
| * @param int $timeout Ignored by the stack adapter. | ||
| * @param float $timeout Ignored by the stack adapter. | ||
| * @return mixed|null Returns the popped item, or null if the stack is empty. | ||
| */ | ||
| public function pop(int $timeout): mixed | ||
| public function pop(float $timeout): mixed |
There was a problem hiding this comment.
Why float instead of int millis/nanos?
There was a problem hiding this comment.
I'd say because seconds is more human understandable, downsides regarding overflow/arithmetic don't apply as we don't transform it, just passed to sleep
| if ($connection !== null) { | ||
| $this->push($connection); | ||
| return $this; | ||
| if ($connection instanceof \Utopia\Pools\Connection) { | ||
| return $this->push($connection); | ||
| } |
There was a problem hiding this comment.
Can we keep the null check instead of type check?
There was a problem hiding this comment.
Rector rule does this, we can disable the rule monorepo wide
There was a problem hiding this comment.
added some detail here for discussion
Follows the discussion on #93. Reviewed the whole surface against
.claude/skills/package-design.The problem
Five knobs decided how long
pop()could block, and nothing named the total:Against a 3s connect timeout that is ~44 seconds to fail one acquisition. Nobody chose 44 seconds — it is the product of defaults nobody multiplied out. And every consumer picks different numbers:
Appwrite capped cache, lock and timelimit and left the DB pools at defaults — the pools on the critical path for every request. That is what a default plus an optional override does, given time.
What changes
One budget.
timeoutis a required constructor argument and is the entire wait a caller may spend. It is the pool's only timeout, and it matches the nameAdapter::pop()already used for the same concept.pop()creates when there is capacity, otherwise waits out the remainder, then throws. Required rather than defaulted, so it cannot be silently inherited, and it appears at every construction site where a reviewer sees it.No creation retries, no replacement. Retrying a failed connect belongs in
init: only the caller knows whether a failure is worth retrying, and a breaker composed there carries state between calls in a way an attempt counter cannot.packages/circuit-breakeris a sibling.Retrying inside the pool was also anti-pooling — the capacity slot stays reserved for the whole attempt sequence, turning a shared resource into a private speculative wait.
Constructor-only readonly configuration. Twelve setters removed across
Pool,ConnectionandGroup, plus the getters that existed only to read them back.Connectionisfinal readonly. Identity, resource and pool fixed at construction, so it cannot be repointed while another coroutine holds it. The pool is no longer nullable, so returning a connection cannot fail for want of an owner — two exception paths gone.Also found while reviewing
destroy()created the replacement itself, so discarding a connection blocked on a connect and could fail for reasons unrelated to the connection being discarded. It now frees capacity and lets the nextpop()create one, matching the pool's documented lazy initialisation.0meant a non-blocking pop. Swoole reads a non-positive channel timeout as wait forever. With the retry loop removed this surfaced immediately as hung coroutines andchannel is destroyed, consumers will be discardedwarnings — the old loop had been hiding it. Now clamped to a single short poll.release()took a?float $startso callers could hand telemetry back in. The pool measures use time itself now, keyed by connection id, andGroupgets correct per-pool timing without threading timestamps.Adapter::pop()takes afloat, so sub-second budgets survive the seam.$size"ignored",$timeout"ignored",synchronized"does not provide mutual exclusion"). They now state what a non-concurrent adapter honestly does — with one execution context nothing can return a resource while you wait, so returning immediately is the correct implementation. I concluded the contract itself is sound and left its shape alone.sizeandacquireTimeout, throwing rather than annotating.level: max, clean, no ignores. Level max surfaced a real test bug: an untyped$resources[]widened a pool's generic toPool<mixed>; fixed by returning the object directly rather than round-tripping through the array.storage.getters?/setters?added to the Vale vocabulary.Result
The suite got 100x faster because the time it spent was retry sleeps.
78 tests, 318 assertions.
bin/monorepo check pools,test poolsandvalidateall pass. Vale reports the same 138 pre-existing findings elsewhere and none inpackages/pools.Deliberately not changed
Group::use()'s parallel arrays are awkward but correct, and rewriting them is orthogonal to this review.isEmpty()/isFull()naming reads backwards at first (isFull()means nothing is checked out) but is consistent withcount(), which reports obtainable connections. I reimplementedisFull()ascount() === sizefor symmetry rather than renaming.release()stays public despite being@internal, becauseGroupneeds it.Dependents
All three direct dependents are updated in the same commit and pass
--linked.clientandqueueconstruct pools in their test suites.client's factory helper now takes aClosureto match the tightenedinittype. Theirsrc/needed nothing — both wrappers only useuse(), which is unchanged.messagingneeded a source change. It built its pool with the adapter passed aspool:, and sized itmin(count($requests), MAX_CONCURRENT_REQUESTS)— which is zero for an empty batch. The new size guard catches that; a zero-capacity pool could never create a connection, it just failed later and less clearly. It now passesadapter:, clamps the size, and states its timeout (a slot per request, so acquisition never queues).^2.0in all three. Without that, acomposer updateafter release would install pools 1.x against 2.x-shaped source.As the
testsworkflow notes, changing a package and its dependents together leaves the registry combination unbuildable until the sibling releases — which is why the dependents run linked here rather than registry-resolved.Downstream, after release
Four call sites outside the monorepo need mechanical updates once
pools/2.0.0exists:edge/app/init.php(which has thesetReconnectAttempts(15)≈ 3-minute case),cloud/registers.php,cloud/Factory.php, and theutopia-pools-expertskill doc, which currently teaches the removed knobs.🤖 Generated with Claude Code