fix: serialize batched GraphQL queries for coroutine safety

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) <noreply@anthropic.com>
This commit is contained in:
Damodar Lohani
2026-04-13 06:54:15 +00:00
co-authored by Claude Opus 4.6
parent f4d40a1289
commit 18b1c344e6
+38 -6
View File
@@ -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;
}