From 18b1c344e662eea02decaf4dfee45c9a2e34e8c8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 13 Apr 2026 06:34:58 +0000 Subject: [PATCH] fix: serialize batched GraphQL queries for coroutine safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Swoole coroutine hooks are enabled (SWOOLE_HOOK_ALL), batched GraphQL queries execute in parallel coroutines that share a single Response object. Concurrent coroutines interleave writes to the shared response payload, causing data mixing between queries. Cloning the response is not viable because cookies/headers written by the action (e.g. session tokens) must reach the real HTTP response. Instead, serialize the critical section (execute → getPayload) using a Swoole Channel as a coroutine-safe mutex. This ensures only one batched query writes to the Response at a time while preserving cookie/header propagation. The lock is released before resolve/reject callbacks so downstream processing remains concurrent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Appwrite/GraphQL/Resolvers.php | 44 ++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 65f8a64d68..8e1da6d493 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,6 +6,7 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; +use Swoole\Coroutine\Channel; use Utopia\Http\Exception; use Utopia\Http\Http; use Utopia\Http\Route; @@ -13,6 +14,29 @@ use Utopia\System\System; class Resolvers { + /** + * Per-request channel used to serialize batched query execution so + * concurrent coroutines don't interleave writes on the shared Response. + */ + private static ?Channel $lock = null; + + /** + * Acquire a coroutine-safe lock for the current request. + * Creates the channel lazily and pushes a token; the channel + * capacity of 1 ensures only one resolve() runs at a time. + */ + private static function acquireLock(): void + { + if (self::$lock === null) { + self::$lock = new Channel(1); + } + self::$lock->push(true); + } + + private static function releaseLock(): void + { + self::$lock?->pop(); + } /** * Create a resolver for a given API {@see Route}. * @@ -261,30 +285,38 @@ class Resolvers $request = clone $request; $utopia->setResource('request', static fn () => $request); - $response->setContentType(Response::CONTENT_TYPE_NULL); - $response->clearSent(); + // Serialize execution: when Swoole coroutine hooks are active, + // batched queries run in parallel coroutines sharing one Response. + // The lock ensures only one query writes to the Response at a time. + self::acquireLock(); try { + $response->setContentType(Response::CONTENT_TYPE_NULL); + $response->clearSent(); + $route = $utopia->match($request, fresh: true); $utopia->execute($route, $request, $response); + + $payload = $response->getPayload(); + $statusCode = $response->getStatusCode(); } catch (\Throwable $e) { + self::releaseLock(); if ($beforeReject) { $e = $beforeReject($e); } $reject($e); return; } + self::releaseLock(); - $payload = $response->getPayload(); - - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) { + if ($statusCode < 200 || $statusCode >= 400) { if ($beforeReject) { $payload = $beforeReject($payload); } $reject(new GQLException( message: $payload['message'], - code: $response->getStatusCode() + code: $statusCode )); return; }