Release idle database sessions during external waits and improve HTTP reuse

Merged
BI
5d ago
+2895-108

This PR lets concurrent work make better use of database and HTTP connections. A request that has finished its database work shouldn't occupy a database slot while waiting for mail, a lock, a process, or an external API. Likewise, a burst of HTTP requests shouldn't discard most of its keep-alive connections before the next burst.

The changes reuse the existing idle-session release mechanism, preserve active queries, transactions and explicit pins, and keep public method signatures unchanged. They also reduce copying when an HTTP stream is consumed a few bytes at a time.

Measurement setup

The tables below show before/after medians from ten alternating fresh-process pairs, with warmup and two measured samples per process for pooled-wait and HTTP-buffer cases. The Pusher connection-retention comparison uses five samples per process. Both sides use the same installed dependencies, PHP 8.4.25 and Swoole 6.3.0RC1, with CLI OPcache enabled and JIT disabled. Historical source snapshots isolate the changed classes without changing the checkout between runs.

Most pool-pressure cases use eight concurrent operations and two database slots. Lock/retry cases use sixteen and four; mail uses thirty-two and four. Each operation queries before its external wait. Separate comparisons give every caller a database slot and query again afterward to expose the cost of borrowing another session. Databases and services are local; no paid provider accounts are used.

CPU means CPU in the calling PHP process, excluding external servers and child processes. Database hold time is the total time a physical session remains borrowed per operation. These are controlled comparisons, not production throughput promises. Ranges and paired comparisons were checked as well as the medians shown here.

HTTP stream reads

CurlStreamingBody drains each received buffer once and keeps an offset into that string. Small reads no longer copy the entire unread remainder each time. EOF and close account for the pending bytes.

The existing HTTP transport harness measures this change independently of any AI response handling.

Bytewise stream consumptionCPU per request, beforeAfterRequests/s, beforeAfter
Small paced stream: 32 × 128 bytes, 1 ms spacing3.158 ms2.925 ms55.8355.36
Unpaced 1 MiB: 256 × 4096 bytes394.92 ms219.61 ms2.534.54

The large case reduces CPU by about 44% in paired comparisons. It deliberately stresses bytewise reads; it isn't a claim that all HTTP requests become that much faster. Heap peaks and connection counts are effectively unchanged, and the paced case remains limited by the sender.

HTTP connection reuse

The HTTP client's bounded synchronous cURL handler is extracted into a shared factory and used by framework-created Pusher, Scout and Slack clients. It retains up to 256 idle handles. Guzzle still selects the transport, and asynchronous, streamed and fallback requests retain their existing paths. Application-supplied clients remain application-owned.

The Pusher comparison uses sixteen simultaneous streams producing 576 individual events, with batching disabled on both sides:

Pusher receiver delayCalling-process CPU, beforeAfterNew warm-run HTTPS connections, beforeAfter
5 ms379.19 ms311.82 ms440
50 ms350.42 ms325.26 ms130

This removes repeated connection setup. It doesn't make the receiver faster: elapsed time in the 50 ms case was 1958.65 → 1989.36 ms, with overlapping ranges. There is no broadcast batching in this PR.

Broadcast database sessions

Pusher, Ably and Mercure release idle database sessions before their transport waits. Each driver does this at its owning send boundary; Redis broadcasting isn't given an extra release step.

These cases exercise actual framework dispatch with a controlled 20 ms transport wait. They isolate database occupancy, rather than measuring provider throughput or HTTP reuse.

Dispatch under database pool pressureOperations/s, beforeAfterDatabase hold per operation, beforeAfter
Pusher95.83365.0120.79 ms0.33 ms
Ably95.54362.5220.79 ms0.38 ms
Mercure95.21357.7120.92 ms0.39 ms

With spare capacity and a later query, throughput stays within 1% in paired comparisons. CPU per operation changes from 307 → 335 µs for Pusher, 312 → 360 µs for Ably, and 374 → 386 µs for Mercure. Database hold per operation falls from 21.35 → 1.13 ms, 21.51 → 1.16 ms, and 21.74 → 1.25 ms respectively, with one extra checkout.

Scout clients and import waits

Scout's default Algolia, Meilisearch and Typesense clients use the shared retaining handler and idle-session release middleware. A custom Typesense client is left alone. The import runner returns each child's idle session before making its concurrency slot available, and returns the parent's idle session before joining the remaining children. Existing failure and cancellation ownership is preserved.

The transport measurements use the actual Guzzle clients configured by Scout against a local HTTPS origin with a 20 ms response delay. They don't include provider serialization or full indexing. The import measurement isolates the parent joining two 20 ms tasks.

Pool-pressure caseOperations/s, beforeAfterDatabase hold per operation, beforeAfter
Algolia transport87.17323.2022.83 ms0.61 ms
Meilisearch transport87.42323.0122.79 ms0.60 ms
Typesense transport87.03324.9322.89 ms0.61 ms
Import parent join95.93360.8820.76 ms0.37 ms

Giving every caller a database slot exposes the HTTP reuse benefit:

Transport with spare capacity and a later queryCPU per operation, beforeAfterNew HTTPS connections per 16 operations, beforeAfter
Algolia1488 µs652 µs100
Meilisearch1418 µs686 µs100
Typesense1460 µs655 µs100

The parent-join comparison has no HTTP reuse benefit. With spare capacity its CPU changes from 364 → 402 µs per operation, while throughput remains effectively unchanged and database hold falls from 21.75 → 1.32 ms.

Mail delivery

Mailer releases idle sessions after message preparation and MessageSending listeners, immediately before transport creation/send. This covers pooled mailers, direct mailers and custom transports without separate provider wrappers. SMTP connections already reuse correctly; this PR doesn't add another retention mechanism to mail.

These measurements use real SMTP delivery to a local receiver, with controlled acknowledgement delays:

Pool-pressure caseMessages/s, beforeAfterDatabase hold per message, beforeAfter
SQLite, 5 ms acknowledgement580.311345.386.75 ms2.03 ms
SQLite, 50 ms acknowledgement76.05284.6452.48 ms1.53 ms
PostgreSQL, 5 ms acknowledgement540.191206.187.25 ms2.45 ms

With spare PostgreSQL capacity and another query after sending, throughput was 1315.71 → 1297.25 messages/s and CPU was 706 → 715 µs/message. Database hold fell from 20.98 → 8.23 ms. Transactions retain their sessions; their measured hold time remains unchanged.

Locks, rate limits and retry delays

Idle sessions are released immediately before positive waits in cache locks/funnels, Redis funnels/throttles, Saloon delays and the shared retry() helper. An operation that doesn't wait doesn't release. Retry callbacks still run in their existing order, so sessions borrowed by a delay callback can be returned before sleeping.

Wait under database pool pressureOperations/s, beforeAfterDatabase hold per operation, beforeAfter
Cache lock170.2571.822.81 ms0.96 ms
Cache funnel169.9574.723.03 ms1.00 ms
Redis funnel165.1562.923.73 ms1.27 ms
Redis throttle166.3567.923.69 ms1.19 ms
Saloon request delay178.2582.022.21 ms0.85 ms
Saloon retry delay172.8524.122.84 ms1.91 ms
Saloon rate limit2.007.961002.43 ms0.60 ms
Database-backed lock1.003.971994.16 ms80.79 ms
HTTP retry delay165.8535.824.04 ms2.92 ms

Cache/Redis contention and ordinary delays use 20 ms waits. Saloon rate limits allow one request per second. The database lock uses a one-second TTL whose rounded expiry can produce waits approaching two seconds. Saloon and HTTP retry use HTTP fakes to isolate their wait logic.

The database lock has the largest measured reacquisition cost: with spare capacity, CPU rises from 18.22 → 19.05 ms/operation and borrows rise from one to about eighteen because each retry queries the database. It performs the same SQL and reduces database hold from 1885.68 → 206.62 ms. HTTP retry adds about 58 µs per operation in its spare-capacity case. These changes improve shared capacity, rather than making every uncontended operation faster.

Child tasks and process waits

The coroutine concurrency driver releases idle sessions before starting its tasks. Saloon releases before admitting requests and before its final join, including when a request generator fails. Process run/wait/waitUntil and positive graceful-stop waits release at entry. Already-finished processes and zero-grace stops don't release unnecessarily.

Pool-pressure caseOperations/s, beforeAfterDatabase hold per operation, beforeAfter
Two concurrent tasks96.22363.9520.68 ms0.32 ms
Saloon fixed requests46.47167.2542.88 ms1.10 ms
Saloon querying generator46.99175.5542.43 ms2.86 ms
Process run26.8658.2973.95 ms4.88 ms
Process start/wait26.9357.6773.77 ms6.34 ms
Process start through graceful stop62.25153.9927.23 ms0.45 ms

Tasks and Saloon requests use controlled 20 ms waits; process cases launch real local processes. Under pool pressure, process run/wait uses more calling-process CPU as more processes run concurrently. With equal capacity, CPU is effectively unchanged; process-run throughput is about 3% lower in paired comparisons. The querying Saloon generator adds about 94 µs per operation with spare capacity because its intermediate queries require fresh borrows.

The recorded stop case resolved its database connection before starting the child, which already borrowed a slot. These figures include startup and shutdown. The reusable harness now defers resolution until readiness; that correction was smoke-tested, not remeasured.

Pooled filesystem operations

Both pooled filesystem wrappers release at shared dispatch boundaries, including stream acquisition and raw-access callbacks. Local path formatting and temporary-URL capability checks are excluded. URL generation remains covered because credentials and custom URL providers can perform remote work.

Pool-pressure case, local HTTPS with 20 ms delayOperations/s, beforeAfterDatabase hold per operation, beforeAfter
S3 SDK read87.98278.2022.51 ms1.64 ms
Custom whole-driver pool87.25323.9922.82 ms0.60 ms

The custom-driver case exercises the shared wrapper; it isn't an FTP/SFTP benchmark. With spare capacity and another query, CPU changes from 1498 → 1516 µs for S3 and 646 → 668 µs for the custom driver, with effectively unchanged throughput.

Socialite and Slack

Socialite adds release middleware to its default Guzzle stack, preserving its transport options and coroutine-owned client lifetime. Custom handlers and supplied clients remain untouched. The default Slack webhook client also gains bounded HTTP connection reuse.

Pool-pressure case, local HTTPS with 20 ms delayOperations/s, beforeAfterDatabase hold per operation, beforeAfter
Socialite77.28236.9325.67 ms2.09 ms
Slack webhook86.87318.5522.87 ms0.71 ms

With spare capacity, Slack opens zero new warm-run connections instead of ten per sixteen messages; CPU falls from 1520 → 699 µs/message. Socialite's connection count remains sixteen on both sides, matching its unchanged ownership model. Its paired spare-capacity CPU change is +0.9%; it doesn't gain cross-coroutine HTTP reuse.

Queue admission and SQS

Pooled queue dispatch releases idle database sessions before borrowing a backend connection. Framework-created SQS clients also release before SDK commands, covering the client used by jobs. Job connection ownership and terminal operations are unchanged.

Real SQS SDK publication to local HTTPS with 20 ms delayOperations/s, beforeAfterDatabase hold per operation, beforeAfter
Database pool pressure89.42294.7422.17 ms1.36 ms
Spare capacity, then another query301.96302.8124.38 ms3.18 ms

This measures publication through the queue proxy, not worker job throughput. Spare-capacity CPU is 1210 → 1211 µs per publication.

gRPC calls and retries

Native gRPC releases before initial and retry transport startup, and before positive retry delays. It doesn't add a release call to every streamed message.

Local native HTTP/2 call under database pool pressureOperations/s, beforeAfterDatabase hold per operation, beforeAfter
Unary call90.73329.0421.97 ms0.41 ms
Retry after an intervening database query91.86306.5721.17 ms0.74 ms

With spare capacity and another query, CPU changes from 483 → 484 µs for unary calls and 708 → 714 µs for retries; throughput remains effectively unchanged.

The recorded retry case includes initial call creation with an already resolved database connection. The reusable harness now resolves at the intervening query. The figures describe the original complete call/retry operation, not a new measurement of that harness correction.

Behavior, coverage and reproduction

Releasing an idle session doesn't add SQL. A later query borrows again, using the existing session configuration and sticky-read behavior. Transactions, active operations and explicit pins keep their sessions. Applications relying on raw session state should use the documented pinning APIs.

Release happens at owning operation boundaries, not every yield or stream chunk. A process output callback or stream consumer that queries the database can explicitly release before its next external wait. The database documentation lists automatic boundaries and custom-transport exceptions in one place.

Regression coverage checks ordinary, transactional and pinned execution, callback ordering, failure cleanup, custom clients and connection reuse. Single-slot database/process handshakes verify that another coroutine can make progress without relying on timing thresholds. Formatting, static analysis and affected package tests pass locally.

tests/Benchmarks/PooledConnections contains the reusable harness and local HTTPS/SMTP fixtures. Its README explains workloads, service setup, repeated comparisons and measurement limits. The existing tests/Benchmarks/HttpTransport harness covers the native stream buffer separately. Historical snapshots and raw measurement output aren't shipped as framework code.

[!NOTE]

Release idle database sessions at external wait boundaries across the framework

Adds idle database-session release (ConnectionResolver::releaseIdleConnections) before external waits and provider calls throughout the framework, so one pooled connection can serve concurrent coroutines. Transactions and pinned sessions keep their borrowed connection.

  • Release boundaries added for HTTP, mail, broadcasting (Pusher, Ably, Mercure), Scout concurrent imports, Saloon waits and pools, queue admission and SQS, gRPC streams and retries, cache/Redis limiters and locks, processes, filesystem pooled operations, Slack, Socialite, and the global retry helper.
  • Moves Guzzle handler construction from Factory into the new HandlerFactory (HandlerFactory.php), which Scout, Socialite, Slack, and broadcasting now use to install the ReleaseIdleDatabaseConnections middleware.
  • Fixes CurlStreamingBody::read to preserve buffered bytes beyond the requested read length; eof stays false until retained bytes are consumed (CurlStreamingBody.php).
  • Adds a pooled-connection benchmark harness under tests/Benchmarks/PooledConnections plus new test suites covering release boundaries, and updates database.md documentation.
  • Behavioral Change: HandlerFactory::create now throws InvalidArgumentException for max_host_connections/max_total_connections, while BroadcastManager's Pusher driver instead accepts those options and passes them to the handler; release failures during Saloon orchestration and Scout import waits propagate through existing failure/cancellation paths.

Macroscope summarized beab2a4.