From 6900ead4da28d393b3bd88e67516b12e1a15342d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 10 Oct 2026 16:35:21 +0100 Subject: [PATCH 1/2] fix: time out stalled source and destination requests A remote that accepts the connection and never answers left curl waiting forever, so the migration worker sat on the job and the migration stayed in processing with nothing logged. Connections now give up after 10s, Target::call abandons a response that sends nothing for 30s, and the Appwrite SDK clients cap each request at 30s, so the stall surfaces as a failed migration. --- src/Migration/Destinations/Appwrite.php | 4 +- src/Migration/Sources/Appwrite.php | 4 +- src/Migration/Target.php | 20 ++++ .../Unit/General/TargetTimeoutTest.php | 108 ++++++++++++++++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 tests/Migration/Unit/General/TargetTimeoutTest.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index a8eb59d0..46ca9c5e 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -222,7 +222,9 @@ public function __construct( $this->client = (new Client()) ->setEndpoint($endpoint) ->setProject($project) - ->setKey($key); + ->setKey($key) + ->setConnectTimeout(static::CONNECT_TIMEOUT) + ->setTimeout(static::REQUEST_TIMEOUT); $this->functions = new Functions($this->client); $this->messaging = new Messaging($this->client); diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 61443459..4bbee7d3 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -140,7 +140,9 @@ public function __construct( $this->client = (new Client()) ->setEndpoint($endpoint) ->setProject($projectId) - ->setKey($key); + ->setKey($key) + ->setConnectTimeout(static::CONNECT_TIMEOUT) + ->setTimeout(static::REQUEST_TIMEOUT); $this->users = new Users($this->client); $this->teams = new Teams($this->client); diff --git a/src/Migration/Target.php b/src/Migration/Target.php index 777609f2..d3332cf7 100644 --- a/src/Migration/Target.php +++ b/src/Migration/Target.php @@ -4,6 +4,23 @@ abstract class Target { + /** + * Seconds to wait for a remote to accept the connection. + */ + public const int CONNECT_TIMEOUT = 10; + + /** + * Seconds a request may go without receiving a byte before it is abandoned. + * Transfers of any length stay alive as long as data keeps arriving. + */ + public const int STALL_TIMEOUT = 30; + + /** + * Seconds an Appwrite SDK request may take in total. The SDK transport only + * takes a total timeout; its requests are single API pages and file chunks. + */ + public const int REQUEST_TIMEOUT = 30; + /** * Global Headers * @@ -118,6 +135,9 @@ protected function call( \curl_setopt($ch, CURLOPT_USERAGENT, php_uname('s').'-'.php_uname('r').':php-'.phpversion()); \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); \curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + \curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, static::CONNECT_TIMEOUT); + \curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1); + \curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, static::STALL_TIMEOUT); \curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { $len = strlen($header); $header = explode(':', strtolower($header), 2); diff --git a/tests/Migration/Unit/General/TargetTimeoutTest.php b/tests/Migration/Unit/General/TargetTimeoutTest.php new file mode 100644 index 00000000..5c371046 --- /dev/null +++ b/tests/Migration/Unit/General/TargetTimeoutTest.php @@ -0,0 +1,108 @@ +assertNotFalse($server, $errorMessage); + $this->server = $server; + $this->endpoint = 'http://' . \stream_socket_get_name($server, false) . '/v1'; + } + + #[Override] + protected function tearDown(): void + { + if (\is_resource($this->server)) { + \fclose($this->server); + } + } + + public function testCallAbandonsAStalledResponse(): void + { + $target = new class ($this->endpoint) extends Target { + public const int STALL_TIMEOUT = 1; + + public function __construct(string $endpoint) + { + $this->endpoint = $endpoint; + } + + #[Override] + public static function getName(): string + { + return 'Stalled'; + } + + #[Override] + public static function getSupportedResources(): array + { + return []; + } + + #[Override] + public function run(array $resources, callable $callback, string $rootResourceId = ''): void + { + } + + #[Override] + public function report(array $resources = [], array $resourceIds = []): array + { + return $this->call('GET', '/health/version'); + } + }; + + $started = \microtime(true); + + try { + $target->report(); + $this->fail('A request to a remote that never answers returned.'); + } catch (\Exception $error) { + $this->assertSame(\Utopia\Migration\Exception::CODE_INTERNAL, $error->getCode()); + } + + $this->assertLessThan(10, \microtime(true) - $started); + } + + public function testSourceReportFailsWhenTheApiStalls(): void + { + $source = new class ($this->endpoint) extends Appwrite { + public const int REQUEST_TIMEOUT = 1; + + public function __construct(string $endpoint) + { + parent::__construct('project', $endpoint, 'key', fn () => null); + } + }; + + $started = \microtime(true); + + try { + $source->report(['user']); + $this->fail('A report against a source that never answers returned.'); + } catch (\Exception $error) { + $this->assertStringContainsString('timed out', $error->getMessage()); + } + + $this->assertLessThan(10, \microtime(true) - $started); + } +} From db89ecee9f265c6181dd1d2746482c37ab4d4248 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 10 Oct 2026 20:49:17 +0100 Subject: [PATCH 2/2] test: remove the stalled-request unit test --- .../Unit/General/TargetTimeoutTest.php | 108 ------------------ 1 file changed, 108 deletions(-) delete mode 100644 tests/Migration/Unit/General/TargetTimeoutTest.php diff --git a/tests/Migration/Unit/General/TargetTimeoutTest.php b/tests/Migration/Unit/General/TargetTimeoutTest.php deleted file mode 100644 index 5c371046..00000000 --- a/tests/Migration/Unit/General/TargetTimeoutTest.php +++ /dev/null @@ -1,108 +0,0 @@ -assertNotFalse($server, $errorMessage); - $this->server = $server; - $this->endpoint = 'http://' . \stream_socket_get_name($server, false) . '/v1'; - } - - #[Override] - protected function tearDown(): void - { - if (\is_resource($this->server)) { - \fclose($this->server); - } - } - - public function testCallAbandonsAStalledResponse(): void - { - $target = new class ($this->endpoint) extends Target { - public const int STALL_TIMEOUT = 1; - - public function __construct(string $endpoint) - { - $this->endpoint = $endpoint; - } - - #[Override] - public static function getName(): string - { - return 'Stalled'; - } - - #[Override] - public static function getSupportedResources(): array - { - return []; - } - - #[Override] - public function run(array $resources, callable $callback, string $rootResourceId = ''): void - { - } - - #[Override] - public function report(array $resources = [], array $resourceIds = []): array - { - return $this->call('GET', '/health/version'); - } - }; - - $started = \microtime(true); - - try { - $target->report(); - $this->fail('A request to a remote that never answers returned.'); - } catch (\Exception $error) { - $this->assertSame(\Utopia\Migration\Exception::CODE_INTERNAL, $error->getCode()); - } - - $this->assertLessThan(10, \microtime(true) - $started); - } - - public function testSourceReportFailsWhenTheApiStalls(): void - { - $source = new class ($this->endpoint) extends Appwrite { - public const int REQUEST_TIMEOUT = 1; - - public function __construct(string $endpoint) - { - parent::__construct('project', $endpoint, 'key', fn () => null); - } - }; - - $started = \microtime(true); - - try { - $source->report(['user']); - $this->fail('A report against a source that never answers returned.'); - } catch (\Exception $error) { - $this->assertStringContainsString('timed out', $error->getMessage()); - } - - $this->assertLessThan(10, \microtime(true) - $started); - } -}