Skip to content

chore: adopt utopia-php/http concurrency fixes#12169

Open
loks0n wants to merge 9 commits into1.9.xfrom
fix/coroutines
Open

chore: adopt utopia-php/http concurrency fixes#12169
loks0n wants to merge 9 commits into1.9.xfrom
fix/coroutines

Conversation

@loks0n
Copy link
Copy Markdown
Member

@loks0n loks0n commented Apr 28, 2026

Summary

Bumps utopia-php/http to the fix/concurrency-shared-state branch (utopia-php/http#251), which removes per-request mutations on shared singletons (Http::$route, Route::$matchedPath, Http::$wildcardRoute, Hook param writeback). This PR migrates the appwrite codebase to the new public surface.

Changes

  • composer.json: pin utopia-php/http to dev-fix/concurrency-shared-state as 0.34.99 (revert before merge once the upstream PR ships).
  • app/http.php:
    • Use $swooleAdapter->getContext() instead of getContainer() for the per-request container (getContainer() now always returns the global singleton).
    • Read the matched route via $app->getResource('route') instead of getRoute().
  • app/controllers/general.php, app/controllers/mock.php, app/controllers/shared/api.php: replace Http::getRoute() calls with getResource('route'). Replace Route::getMatchedPath() with getResource('matchedPath'). Calls to Request::getRoute() / setRoute() (Appwrite's own subclass) are left alone.
  • app/controllers/shared/api.php: read request params via $request->getParams() in the shutdown hook — Route::getParamsValues() is no longer populated by request handling.
  • src/Appwrite/GraphQL/Resolvers.php: snapshot/restore the parent route via the per-request context container instead of Http::setRoute().
  • src/Appwrite/Promises/Swoole.php: update the coroutine context key constant from __utopia_http_request_container to __utopia_http_context to match the upstream rename.

Verification

  • composer analyze (PHPStan) — clean.
  • composer format (Pint) — clean.
  • e2e suite — needs CI (Docker stack).
  • Manual smoke test of concurrent requests on Swoole-backed deploy.

Test plan

  • CI green
  • Verify concurrent request behavior under Swoole (parameterized routes via different aliases, wildcard route in parallel, route with I/O followed by getResource('route')).
  • Confirm GraphQL resolvers still resolve nested route routes correctly.

🤖 Generated with Claude Code

Adopts the breaking changes from utopia-php/http#251 (concurrency races on
shared Http/Route singletons):

- Replace `Http::getRoute()` / `setRoute()` with `getResource('route')` and
  context container writes.
- Replace `Route::getMatchedPath()` with `getResource('matchedPath')`.
- Use `Adapter::getContext()` for the per-request container in `app/http.php`
  (`getContainer()` now always returns the global singleton).
- Read request params from `$request->getParams()` in the api shutdown hook
  instead of `Route::getParamsValues()`, which is no longer populated.
- Update Swoole promise context key to `__utopia_http_context`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR migrates the Appwrite codebase from per-request singleton mutations (Http::$route, Route::$matchedPath, Http::getRoute()) to the new RouteMatch context-based API introduced in utopia-php/http@fix/concurrency-shared-state, eliminating shared-state races under Swoole coroutines. The mechanical injection of ?RouteMatch $match is consistent across hooks and resource factories, but several null-safety gaps and one new lock-leak were introduced alongside the already-flagged issues.

  • P1 — lock leak in Resolvers.php: $lock->acquire() fires before $utopia->getResource('match'), which is outside the try-finally. The old getRoute() never threw; getResource() does if the key is absent, permanently stranding the lock for that $utopia instance.
  • P1 — $match->arguments TypeError in shutdown hook (api.php:870): PHP 8 throws Error on property access on null; ?? does not suppress it, skipping audit/usage reporting for affected requests.
  • P1 — partially null-safe chain $match?->route->getPath() (http.php:549): a non-null $match with a null route property throws TypeError uncaught outside the try/catch.

Confidence Score: 3/5

Not safe to merge — multiple P1 null-safety and lock-leak defects on core request paths, plus the dev-branch dependency pin.

Three distinct P1 findings (lock leak in GraphQL resolver, TypeError in shutdown hook, partially null-safe chain in Span annotation) plus the temporary dev-branch pin of utopia-php/http lower the score below the P1 ceiling of 4.

src/Appwrite/GraphQL/Resolvers.php (lock leak), app/controllers/shared/api.php (shutdown hook TypeError), app/http.php (partial null-safe chain), app/controllers/mock.php (unconditional route dereference)

Important Files Changed

Filename Overview
src/Appwrite/GraphQL/Resolvers.php Snapshot/restore migrated from Http::setRoute() to setContext(); lock acquired before getResource('match') which is outside the try-finally, risking a permanent lock leak if the resource throws.
app/http.php getContainer() replaced with getContext() for per-request isolation; Span path annotation reads from getResource('match') — partially null-safe chain on match?.route->getPath() can throw TypeError if route property is null.
app/controllers/shared/api.php Init and shutdown hooks migrated to inject 'match'; shutdown uses $match->arguments ?? [] which throws TypeError (not null) when $match is null, and route is read without exception guard.
app/controllers/general.php init/error/options hooks migrated to inject 'match'; router() return value (previously used to set 'router' label) is now discarded — label is unused in codebase so no functional regression.
app/controllers/mock.php Shutdown hook migrated to inject '?RouteMatch $match'; $route is unconditionally dereferenced at lines 302 and 310 without a null guard.
app/init/resources/request.php project and team resource factories migrated from utopia->match($request) to inject '?RouteMatch $match'; null-safe pattern used correctly.
src/Appwrite/Promises/Swoole.php Context key constant renamed from __utopia_http_request_container to __utopia_http_context to match upstream rename — straightforward and correct.
composer.json utopia-php/http pinned to unmerged dev branch dev-fix/concurrency-shared-state; intended to be reverted before merge but poses a stability risk on 1.9.x if landed as-is.

Reviews (7): Last reviewed commit: "chore: use public Http::setContext() in ..." | Re-trigger Greptile

Comment thread composer.json
"utopia-php/dns": "1.6.*",
"utopia-php/dsn": "0.2.1",
"utopia-php/http": "0.34.*",
"utopia-php/http": "dev-fix/concurrency-shared-state as 0.34.99",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dev-branch dependency pinned to 1.9.x

utopia-php/http is locked to dev-fix/concurrency-shared-state as 0.34.99, an unmerged feature branch. The PR description notes this must be reverted before merging, but having it land on the release branch — even temporarily — means any build from 1.9.x pulls a mutable, non-stable reference that could change from under deployments. Until the upstream PR ships and a tagged release exists, merging this to 1.9.x poses a real stability risk.

Comment thread src/Appwrite/GraphQL/Resolvers.php Outdated
@blacksmith-sh

This comment has been minimized.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 28, 2026

✨ Benchmark results

Comparing 1.9.x (before) to fix/coroutines (after).

Before

Scenario P50 (ms) P95 (ms) Requests RPS
API total 14.01 128 185 35.68
Account 27.44 154.61 35 7.39
TablesDB 13.2 19.19 35 8.97
Storage 11.18 53.19 75 18.85
Functions 21.15 32.14 40 10.24

After

Scenario P50 (ms) P95 (ms) Requests RPS
API total 15.03 150.65 185 33.64
Account 24.42 193.59 35 6.94
TablesDB 13.93 19.86 35 8.34
Storage 12.22 52.82 75 17.61
Functions 19.7 35.27 40 9.53

Delta

Scenario P95 delta (ms)
API total +22.66
Account +38.98
TablesDB +0.66
Storage -0.37
Functions +3.13
Top API waits
API request Max wait (ms)
account.sessions.email.create 691.11
account.create 193.39
account.password.update 157.51

…solver

- Use ->inject('route') and ->inject('matchedPath') in actions instead of
  reading via \$utopia->getResource() — this was the cause of the e2e 500s,
  the bus resolver was hitting the global container after the upstream
  Adapter::getContainer() semantics changed.
- Switch the bus resolver in app/http.php to use \$swooleAdapter->getContext()
  so per-request resources (locale, platform, dbForProject) resolve from the
  per-coroutine context container.
- Drop the dead ?->label('router', true) calls in general.php — the label was
  never read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 28, 2026

🔄 PHP-Retry Summary

Flaky tests detected across commits:

Commit b46ede4 - 6 flaky tests
Test Retries Total Time Details
UsageTest::testFunctionsStats 1 10.19s Logs
UsageTest::testPrepareSitesStats 1 7ms Logs
UsageTest::testEmbeddingsTextUsageDoesNotBreakProjectUsage 1 5ms Logs
LegacyConsoleClientTest::testNotBetween 1 240.47s Logs
VectorsDBConsoleClientTest::testGetCollectionLogs 1 4ms Logs
DatabasesConsoleClientTest::testGetCollectionLogs 1 6ms Logs
Commit 2cc1f6a - 5 flaky tests
Test Retries Total Time Details
UsageTest::testFunctionsStats 1 10.17s Logs
UsageTest::testPrepareSitesStats 1 7ms Logs
UsageTest::testEmbeddingsTextUsageDoesNotBreakProjectUsage 1 6ms Logs
VectorsDBConsoleClientTest::testGetCollectionLogs 1 5ms Logs
DatabasesConsoleClientTest::testGetCollectionLogs 1 7ms Logs
Commit 98f6ca3 - 4 flaky tests
Test Retries Total Time Details
UsageTest::testFunctionsStats 1 10.28s Logs
UsageTest::testPrepareSitesStats 1 8ms Logs
UsageTest::testEmbeddingsTextUsageDoesNotBreakProjectUsage 1 6ms Logs
RealtimeConsoleClientTest::testCreateDeployment 1 2.10s Logs
Commit d47cf5a - 4 flaky tests
Test Retries Total Time Details
UsageTest::testFunctionsStats 1 10.24s Logs
UsageTest::testPrepareSitesStats 1 7ms Logs
UsageTest::testEmbeddingsTextUsageDoesNotBreakProjectUsage 1 5ms Logs
AvatarsConsoleClientTest::testGetScreenshot 1 82.20s Logs
Commit 79d2cfc - 5 flaky tests
Test Retries Total Time Details
SitesCustomServerTest::testDomainForFailedDeployment 1 100.45s Logs
SitesCustomServerTest::testErrorPages 1 120.39s Logs
UsageTest::testFunctionsStats 1 10.20s Logs
UsageTest::testPrepareSitesStats 1 8ms Logs
UsageTest::testEmbeddingsTextUsageDoesNotBreakProjectUsage 1 9ms Logs

Note: Flaky test results are tracked for the last 5 commits

loks0n and others added 3 commits April 28, 2026 14:27
The api shutdown hook substitutes route labels like
\`cache.resource = 'file/{request.fileId}'\` against \$requestParams. We
were reading those from \$request->getParams(), which only returns body
or query params — path params (\`fileId\`, \`bucketId\`) were missing,
so cache documents got written with literal \`file/{request.fileId}\`
strings and subsequent cache hits 404'd because the placeholder was
treated as a real fileId.

Merge \$route->getPathValues(\$request) ahead of \$request->getParams() so
path params are available for substitution. Replicates the previous
behaviour of \$route->getParamsValues(), which was populated by the
upstream Hook param writeback that has since been removed.

Also rename the per-request container variable to \$context and the
loader callable to \$registerContext in app/http.php to match the new
upstream terminology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps utopia-php/http to the latest fix/concurrency-shared-state, which
collapses the separate route / matchedPath / arguments context keys into
a single immutable RouteMatch under the 'match' key.

- Replace ->inject('route') / ->inject('matchedPath') with
  ->inject('match'), reading \$match->route / \$match->path.
- Replace the manual array_merge(\$route->getPathValues(), \$request->
  getParams()) workaround in api.php's shutdown hook with the framework-
  provided \$match->arguments — same data the action saw, no path-value
  reconstruction needed.
- Update GraphQL resolver to snapshot/restore the 'match' value instead
  of 'route'.
- Update top-level call sites in app/http.php that read from getResource
  ('route') to read getResource('match')->route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread app/http.php
loks0n and others added 2 commits April 28, 2026 16:00
…rce resolvers

Replaces \$utopia->match(\$request) calls in the project, team, and auth
resolvers with the per-request RouteMatch injected directly via DI:

- app/init/resources/request.php — project / team resource closures now
  declare 'match' as a dependency and read \$match?->route.
- app/controllers/shared/api/auth.php — auth init hook injects 'match'
  instead of resolving via \$utopia.
- app/controllers/shared/api.php — drop the redundant re-match in the
  storage cache init hook; \$route from the action's \$match inject is
  already in scope.

Only Resolvers::resolve still calls \$utopia->match(...) — that path
genuinely matches a synthesized sub-request URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upstream now returns null instead of throwing when the request hasn't
been matched yet, so the defensive try/catch wrappers in app/http.php
and Resolvers.php are no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread app/controllers/shared/api.php
loks0n and others added 2 commits April 28, 2026 16:32
Upstream removed Adapter::getContainer() in favor of a single
public reader Adapter::getContext(): Container that returns the
container for the current execution context (per-request inside
a request, global outside one). Migration is a one-line rename.

The only call site was the installer's bootstrap, which runs at
boot and therefore receives the global container — same semantics
as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setContext() is now public on Http (utopia-php/http#251). The graphql
resolver no longer needs to reach into the per-request container
directly to swap the active match for nested resolution — it can just
ask Http to do it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines +362 to 363
$original = $utopia->getResource('match');
try {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lock leaked when getResource('match') throws

$lock->acquire() fires on line 360, but $utopia->getResource('match') on line 362 is outside the try block. In the old code getRoute() always returned a value (or null) and never threw; getResource() will throw if the 'match' key is absent from the context container (e.g., on a request that errored before routing completed, or under the new upstream API when the key isn't yet populated). When that happens the finally block — which calls $lock->release() — is never entered, permanently blocking any subsequent GraphQL call that maps to the same $utopia instance.

Move the getResource call inside the try block:

$lock->acquire();
$original = null;
try {
    $original = $utopia->getResource('match');
    $request = clone $request;
    ...
} catch (\Throwable $e) {
    $reject($e);
    return;
} finally {
    if ($original !== null) {
        $utopia->setContext('match', static fn () => $original);
    }
    $lock->release();
    unset(self::$locks[\spl_object_hash($utopia)]);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant