Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ jobs:
- name: Run ownership and affected consumer tests
run: >-
vendor/bin/paratest -c phpunit.xml.dist
--filter '(Guzzle|PusherClientTest|AwsCredentialConsumerTest|SerializedCredentialProvider|HttpConnectionTest|CurlStreamingHandlerTest|HttpClientStreamingTest|AfterEachTestExtensionTest|GenerateProxiesTest|ServiceProviderDiTest)'
--filter '(Guzzle|PusherClientTest|AwsCredentialConsumerTest|SerializedCredentialProvider|HttpClientTest|HttpConnectionTest|HttpClientDestinationPolicyTest|CurlStreamingHandlerTest|HttpClientStreamingTest|AfterEachTestExtensionTest|GenerateProxiesTest|ServiceProviderDiTest)'

wayfinder_tests:
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@

## HTTP Client

- Before the Hypervel 0.4 release, require Guzzle 8 across the root and split packages, remove Guzzle 7 compatibility branches and its CI job, and update the related Promises/PSR-7 constraints and documentation. Recheck consumer compatibility and retain applicable tests on Guzzle 8.
- If [Guzzle #3935](https://github.com/guzzle/guzzle/pull/3935) merges, once the minimum supported Guzzle versions include it, pass `max_idle_handles` directly to `Utils::chooseHandler()` and remove Hypervel's custom idle-handle composition, preserving the connection option and default of 256.
- Recheck streaming performance once a build containing the Swoole native `curl_multi_select()` fix is available. The affected hook can consume ready response data internally and then wait for another event or its timeout before returning. Hypervel therefore sometimes delays an SSE event or completion by up to one second, even though the data has arrived. This requires no Hypervel workaround.
- Run `Hypervel\Tests\Http\Client\CurlStreamingHandlerTest::testNativeWaitReportsDataArrivingBetweenPerformAndSelect` against the patched extension with its temporary `SWOOLE_VERSION_ID <= 60203` skip removed locally. Verify both mid-stream readiness and completion, plus the existing quiet-provider timeout/cancellation and cleanup tests. Update the committed version gate only after identifying the actual fixed release; the current gate does not establish that a later release is fixed.
- In an idle window, use `tests/Benchmarks/HttpTransport` and its README to repeat alternating paired measurements before/after high-water buffering on the same patched runtime. Keep dependencies, TLS verification, idle-retention bound, PHP/OPcache/JIT/GC settings and request concurrency identical. Cover paced events, unpaced bulk responses and slow consumers, then repeat sustained concurrent traffic. Check first-event and completion latency (including outliers), throughput, client CPU, memory, connection reuse and descriptor cleanup. Confirm the one-second stalls disappear without changing Hypervel's select interval or buffering strategy.
- Record the native PR/build, settings and raw reports before publishing buffering performance claims. If a verified problem remains, investigate its owning layer rather than adding polling, shorter waits or a producer coroutine to hide it.
- Resolve the Guzzle-dependent test skips when the upstream changes are available: adopt configurable idle-handle retention for named buffered requests and unskip `HttpConnectionTest::testNamedBufferedRequestsReuseConnectionsAcrossConcurrentBursts`; once shared StreamHandler request deadlines are fixed, remove the `ClientInterface::MAJOR_VERSION >= 8` skip from `HttpClientStreamingTest::testConcurrentFallbackRequestsKeepIndependentDeadlines`. Enable `HttpClientStreamingTest::testFallbackJsonLinesArriveBeforeTheNextChunk` for versions containing the [filtered-streaming fix](https://github.com/guzzle/guzzle/pull/3936). Verify both supported Guzzle branches when adopting these changes.
- Resolve the Guzzle-dependent test skips when the upstream changes are available: once shared StreamHandler request deadlines are fixed, remove the `ClientInterface::MAJOR_VERSION >= 8` skip from `HttpClientStreamingTest::testConcurrentFallbackRequestsKeepIndependentDeadlines`. Enable `HttpClientStreamingTest::testFallbackJsonLinesArriveBeforeTheNextChunk` for versions containing the [filtered-streaming fix](https://github.com/guzzle/guzzle/pull/3936). Verify both supported Guzzle branches when adopting these changes.
- Once the HTTP client can optionally use Swoole's coroutine HTTP client as its transport instead of curl, explore and benchmark that transport for Inertia SSR requests. Each SSR render posts the page JSON to a local SSR server through the `inertia-ssr` connection (`HttpGateway::CONNECTION`), which makes it a good candidate for the alternative transport. Compare the curl and Swoole transports on that connection against a local SSR server under concurrent load, measuring latency, throughput, client CPU, memory and connection reuse, and use the Swoole transport for the SSR connection if it is a clear improvement.

## HTTP Server
Expand Down
74 changes: 55 additions & 19 deletions src/coroutine/src/Waiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function __construct(float $timeout = self::DEFAULT_POP_TIMEOUT_SECONDS)
* Objects stored directly in context are shared by reference by default. Values implementing
* Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing
* Hypervel\Context\NonCopyableContext are omitted.
* @param bool $waitForChildTermination Wait without a limit when a canceled child exceeds the cleanup allowance
* @param bool $waitForChildTermination Wait without a limit for a canceled child to terminate: after the cleanup allowance on timeout, and before the waiting coroutine's own cancellation propagates
* @return TReturn
* @throws WaitTimeoutException When the wait times out
* @throws ChildTerminationTimeoutException When a canceled child outlives the cleanup allowance in strict mode
Expand Down Expand Up @@ -86,40 +86,47 @@ public function wait(
Coroutine::forkOwned($callable, $wrapper, is_array($copyContext) ? $copyContext : []);
}
} catch (CanceledException $exception) {
$this->cancelChild($childCoroutineId, $childCancellationRequested);
$this->cancelChild($childCoroutineId, $childCancellationRequested, $waitForChildTermination);
throw $exception;
}

try {
$result = $channel->pop($timeout);
} catch (CanceledException $exception) {
$this->cancelChild($childCoroutineId, $childCancellationRequested);
$this->cancelChild($childCoroutineId, $childCancellationRequested, $waitForChildTermination);
throw $exception;
}

if ($result === false && $channel->isCanceled()) {
$exception = new CanceledException('Waiting for a child coroutine was canceled.');
$this->cancelChild($childCoroutineId, $childCancellationRequested);
$this->cancelChild($childCoroutineId, $childCancellationRequested, $waitForChildTermination);
throw $exception;
}

if ($result === false && $channel->isTimeout()) {
// Throw into the operation so an interrupted wait cannot be ignored accidentally,
// then give the child a bounded interval to unwind and run deferred cleanup.
$this->cancelChild($childCoroutineId, $childCancellationRequested);
$joined = Coroutine::join([$childCoroutineId], $this->pushTimeout);
try {
// Throw into the operation so an interrupted wait cannot be ignored accidentally,
// then give the child a bounded interval to unwind and run deferred cleanup.
$this->cancelChild($childCoroutineId, $childCancellationRequested);
$joined = Coroutine::join([$childCoroutineId], $this->pushTimeout);
} catch (CanceledException $exception) {
$this->cancelChild($childCoroutineId, $childCancellationRequested, $waitForChildTermination);
throw $exception;
}

if (! $joined && EngineCoroutine::isCanceled()) {
throw new CanceledException('Waiting for a child coroutine was canceled.');
$exception = new CanceledException('Waiting for a child coroutine was canceled.');
$this->cancelChild($childCoroutineId, $childCancellationRequested, $waitForChildTermination);
throw $exception;
}

// A false join may mean either timeout or a missing coroutine, so only
// existence proves that the child survived the cleanup allowance.
if ($waitForChildTermination && Coroutine::exists($childCoroutineId)) {
$joined = Coroutine::join([$childCoroutineId]);
$cancellation = $this->awaitChildTermination($childCoroutineId);

if (! $joined && EngineCoroutine::isCanceled()) {
throw new CanceledException('Waiting for a child coroutine was canceled.');
if ($cancellation !== null) {
throw $cancellation;
}

// The wait already timed out, so discard any result produced during the extended unwind.
Expand All @@ -136,13 +143,13 @@ public function wait(
try {
$joined = Coroutine::join([$childCoroutineId]);
} catch (CanceledException $exception) {
$this->cancelChild($childCoroutineId, $childCancellationRequested);
$this->cancelChild($childCoroutineId, $childCancellationRequested, $waitForChildTermination);
throw $exception;
}

if (! $joined && EngineCoroutine::isCanceled()) {
$exception = new CanceledException('Waiting for a child coroutine was canceled.');
$this->cancelChild($childCoroutineId, $childCancellationRequested);
$this->cancelChild($childCoroutineId, $childCancellationRequested, $waitForChildTermination);
throw $exception;
}

Expand All @@ -163,15 +170,44 @@ public function wait(
}

/**
* Cancel the owned child once when it is still active.
* Cancel the owned child once when it is still active, and wait for it to terminate when required.
*/
protected function cancelChild(?int $childCoroutineId, bool &$cancellationRequested): void
protected function cancelChild(?int $childCoroutineId, bool &$cancellationRequested, bool $waitForTermination = false): void
{
if ($cancellationRequested || $childCoroutineId === null || ! Coroutine::exists($childCoroutineId)) {
if ($childCoroutineId === null) {
return;
}

$cancellationRequested = true;
EngineCoroutine::cancelById($childCoroutineId, throwException: true);
if (! $cancellationRequested && Coroutine::exists($childCoroutineId)) {
$cancellationRequested = true;
EngineCoroutine::cancelById($childCoroutineId, throwException: true);
}

if ($waitForTermination) {
$this->awaitChildTermination($childCoroutineId);
}
}

/**
* Wait until the child has terminated, however often the waiting coroutine is canceled meanwhile.
*
* @return null|CanceledException the waiting coroutine's first cancellation during the wait
*/
protected function awaitChildTermination(int $childCoroutineId): ?CanceledException
{
$cancellation = null;

// Each cancellation interrupts the join, so join again until the child is gone.
while (Coroutine::exists($childCoroutineId)) {
try {
if (! Coroutine::join([$childCoroutineId]) && EngineCoroutine::isCanceled()) {
$cancellation ??= new CanceledException('Waiting for a child coroutine was canceled.');
}
} catch (CanceledException $exception) {
$cancellation ??= $exception;
}
}

return $cancellation;
}
}
4 changes: 2 additions & 2 deletions src/coroutine/src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ function parallel(array $callables, int $concurrent = 0, bool|array $copyContext
* Objects stored directly in context are shared by reference by default. Values implementing
* Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing
* Hypervel\Context\NonCopyableContext are omitted.
* @param bool $waitForChildTermination Wait without a limit when a cancelled child exceeds the cleanup allowance
* @param bool $waitForChildTermination Wait without a limit for a canceled child to terminate: after the cleanup allowance on timeout, and before the waiting coroutine's own cancellation propagates
* @return TReturn
* @throws WaitTimeoutException When the wait times out
* @throws ChildTerminationTimeoutException When a cancelled child outlives the cleanup allowance in strict mode
* @throws ChildTerminationTimeoutException When a canceled child outlives the cleanup allowance in strict mode
*/
function wait(
Closure $closure,
Expand Down
74 changes: 65 additions & 9 deletions src/database/src/ConnectionResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,7 @@ public function __construct(
*/
public function connection(UnitEnum|string|null $name = null): ConnectionInterface
{
if ($name instanceof UnitEnum) {
$name = (string) enum_value($name);
}

$name = $name === null || $name === ''
? $this->getDefaultConnection()
: $name;

$connectionName = ConnectionName::parse($name);
$connectionName = ConnectionName::parse($this->connectionName($name));
$connectionOwnerName = $connectionName->requested;
$contextKey = $this->getContextKey($connectionOwnerName);

Expand Down Expand Up @@ -142,6 +134,9 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
// deferred release, since a listener failure discards the wrapper.
$pooledConnection->dispatchConnectionEstablishedEvent();

// Recorded after the listeners, which may have customized a shared connection's grammar.
$pooledConnection->recordDateFormat();

if (Coroutine::inCoroutine()) {
Coroutine::defer(function () use ($owner, $contextKey, $leaseContextKey): void {
try {
Expand All @@ -167,6 +162,55 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
return $connection;
}

/**
* Get the query grammar date format of a connection without holding one.
*
* A connection the current coroutine holds answers for itself. Otherwise
* the pool's recorded driver format is used. A cold lookup resolves the
* connection as usual, then returns only its idle physical session;
* connections without session leases retain their normal ownership.
*/
public function connectionDateFormat(UnitEnum|string|null $name = null): string
{
$name = $this->connectionName($name);
$connectionName = ConnectionName::parse($name);
$held = CoroutineContext::get($this->getContextKey($connectionName->requested));

if ($held instanceof Connection) {
return $held->getQueryGrammar()->getDateFormat();
}

$pool = $this->poolManager->existing($connectionName->requested);

if ($pool !== null) {
// Role aliases of a shared in-memory PDO are held under the pool's own name.
$held = $pool->getSharedInMemorySqlitePdo() === null ? null : CoroutineContext::get($this->getContextKey($pool->getName()));

if ($held instanceof Connection) {
return $held->getQueryGrammar()->getDateFormat();
}

if (($format = $pool->recordedDateFormat()) !== null) {
return $format;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/** @var Connection $connection */
$connection = $this->connection($name);

try {
return $connection->getQueryGrammar()->getDateFormat();
} finally {
$pool = $this->poolManager->existing($connectionName->requested);
$owner = $pool?->getSharedInMemorySqlitePdo() !== null ? $pool->getName() : $connectionName->requested;
$lease = CoroutineContext::get($this->getLeaseContextKey($owner));

if ($lease instanceof ConnectionLease) {
$lease->releaseIfIdle();
}
}
}

/**
* Return idle physical sessions owned by the current execution.
*/
Expand Down Expand Up @@ -235,6 +279,18 @@ public function setDefaultConnection(?string $name): void
}
}

/**
* Get the connection name a request resolves to: the given name, or the current default.
*/
protected function connectionName(UnitEnum|string|null $name): string
{
if ($name instanceof UnitEnum) {
$name = (string) enum_value($name);
}

return $name === null || $name === '' ? $this->getDefaultConnection() : $name;
}

/**
* Get the context key for storing a connection.
*/
Expand Down
28 changes: 28 additions & 0 deletions src/database/src/DatabaseManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,34 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
return $this->app->make('db.resolver')->connection($name);
}

/**
* Get the query grammar date format of a connection.
*
* The pooled resolver answers without holding a connection once its pool
* knows the format; other resolvers resolve the connection as usual.
*/
public function connectionDateFormat(UnitEnum|string|null $name = null): string
{
if ($name instanceof UnitEnum) {
$name = (string) enum_value($name);
}

$name = $name === null || $name === ''
? $this->getDefaultConnection()
: $name;

$resolver = $this->app->make('db.resolver');

if ($resolver instanceof ConnectionResolver) {
return $resolver->connectionDateFormat($name);
}

/** @var Connection $connection */
$connection = $resolver->connection($name);

return $connection->getQueryGrammar()->getDateFormat();
}

/**
* Resolve a connection directly without using the connection pool.
*
Expand Down
2 changes: 1 addition & 1 deletion src/database/src/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -1573,7 +1573,7 @@ public function getDates(): array
*/
public function getDateFormat(): string
{
return $this->dateFormat ?: $this->getConnection()->getQueryGrammar()->getDateFormat();
return $this->dateFormat ?: $this->getConnectionDateFormat();
}

/**
Expand Down
Loading
Loading