diff --git a/composer.lock b/composer.lock index 8a37a9e84..2d1adac29 100644 --- a/composer.lock +++ b/composer.lock @@ -2608,5 +2608,5 @@ "php": ">=8.0" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 422b62bd4..6b574878c 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -34,8 +34,6 @@ abstract class Adapter */ protected array $metadata = []; - protected static ?int $timeout = null; - /** * @param string $key * @param mixed $value @@ -205,7 +203,12 @@ public function before(string $event, string $name = '', ?callable $callback = n if (!isset($this->transformations[$event])) { $this->transformations[$event] = []; } - $this->transformations[$event][$name] = $callback; + + if (\is_null($callback)) { + unset($this->transformations[$event][$name]); + } else { + $this->transformations[$event][$name] = $callback; + } return $this; } @@ -458,11 +461,10 @@ abstract public function deleteDocument(string $collection, string $id): bool; * @param array $orderTypes * @param array $cursor * @param string $cursorDirection - * @param int|null $timeout * * @return array */ - abstract public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, ?int $timeout = null): array; + abstract public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER): array; /** * Sum an attribute @@ -474,7 +476,7 @@ abstract public function find(string $collection, array $queries = [], ?int $lim * * @return int|float */ - abstract public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null, ?int $timeout = null): float|int; + abstract public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int; /** * Count Documents @@ -485,7 +487,7 @@ abstract public function sum(string $collection, string $attribute, array $queri * * @return int */ - abstract public function count(string $collection, array $queries = [], ?int $max = null, ?int $timeout = null): int; + abstract public function count(string $collection, array $queries = [], ?int $max = null): int; /** * Get Collection Size @@ -745,32 +747,28 @@ abstract public function getMaxIndexLength(): int; * Set a global timeout for database queries in milliseconds. * * This function allows you to set a maximum execution time for all database - * queries executed using the library. Once this timeout is set, any database - * query that takes longer than the specified time will be automatically - * terminated by the library, and an appropriate error or exception will be - * raised to handle the timeout condition. + * queries executed using the library, or a specific event specified by the + * event parameter. Once this timeout is set, any database query that takes + * longer than the specified time will be automatically terminated by the library, + * and an appropriate error or exception will be raised to handle the timeout condition. * * @param int $milliseconds The timeout value in milliseconds for database queries. + * @param string $event The event the timeout should fire fore * @return void * - * @throws \Exception The provided timeout value must be greater than or equal to 0. - */ - public static function setTimeout(int $milliseconds): void - { - if ($milliseconds <= 0) { - throw new DatabaseException('Timeout must be greater than 0'); - } - self::$timeout = $milliseconds; - } + * @throws Exception The provided timeout value must be greater than or equal to 0. + */ + abstract public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void; /** * Clears a global timeout for database queries. * + * @param string $event * @return void - * - */ - public static function clearTimeout(): void + */ + public function clearTimeout(string $event): void { - self::$timeout = null; + // Clear existing callback + $this->before($event, 'timeout', null); } } diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 1babd3d13..1e176b0c1 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -5,11 +5,11 @@ use Exception; use PDO; use PDOException; -use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate; -use Utopia\Database\Exception\Timeout; +use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -641,7 +641,7 @@ public function deleteIndex(string $collection, string $id): bool * @return Document * @throws Exception * @throws PDOException - * @throws Duplicate + * @throws DuplicateException */ public function createDocument(string $collection, Document $document): Document { @@ -736,7 +736,7 @@ public function createDocument(string $collection, Document $document): Document switch ($e->getCode()) { case 1062: case 23000: - throw new Duplicate('Duplicated document: ' . $e->getMessage()); + throw new DuplicateException('Duplicated document: ' . $e->getMessage()); default: throw $e; @@ -758,7 +758,7 @@ public function createDocument(string $collection, Document $document): Document * @return Document * @throws Exception * @throws PDOException - * @throws Duplicate + * @throws DuplicateException */ public function updateDocument(string $collection, Document $document): Document { @@ -939,7 +939,7 @@ public function updateDocument(string $collection, Document $document): Document switch ($e->getCode()) { case 1062: case 23000: - throw new Duplicate('Duplicated document: ' . $e->getMessage()); + throw new DuplicateException('Duplicated document: ' . $e->getMessage()); default: throw $e; @@ -1058,12 +1058,11 @@ public function deleteDocument(string $collection, string $id): bool * @param array $orderTypes * @param array $cursor * @param string $cursorDirection - * @param int|null $timeout * @return array * @throws DatabaseException - * @throws Timeout + * @throws TimeoutException */ - public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, ?int $timeout = null): array + public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER): array { $name = $this->filter($collection); $roles = Authorization::getRoles(); @@ -1173,11 +1172,8 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); - if ($timeout || static::$timeout) { - $sql = $this->setTimeoutForQuery($sql, $timeout ? $timeout : static::$timeout); - } - $stmt = $this->getPDO()->prepare($sql); + foreach ($queries as $query) { $this->bindConditionValue($stmt, $query); } @@ -1257,7 +1253,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, * @throws Exception * @throws PDOException */ - public function count(string $collection, array $queries = [], ?int $max = null, ?int $timeout = null): int + public function count(string $collection, array $queries = [], ?int $max = null): int { $name = $this->filter($collection); $roles = Authorization::getRoles(); @@ -1287,11 +1283,8 @@ public function count(string $collection, array $queries = [], ?int $max = null, $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); - if ($timeout || self::$timeout) { - $sql = $this->setTimeoutForQuery($sql, $timeout ? $timeout : self::$timeout); - } - $stmt = $this->getPDO()->prepare($sql); + foreach ($queries as $query) { $this->bindConditionValue($stmt, $query); } @@ -1322,7 +1315,7 @@ public function count(string $collection, array $queries = [], ?int $max = null, * @throws Exception * @throws PDOException */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null, ?int $timeout = null): int|float + public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): int|float { $name = $this->filter($collection); $roles = Authorization::getRoles(); @@ -1352,10 +1345,6 @@ public function sum(string $collection, string $attribute, array $queries = [], $sql = $this->trigger(Database::EVENT_DOCUMENT_SUM, $sql); - if ($timeout || self::$timeout) { - $sql = $this->setTimeoutForQuery($sql, $timeout ? $timeout : self::$timeout); - } - $stmt = $this->getPDO()->prepare($sql); foreach ($queries as $query) { @@ -1583,35 +1572,42 @@ public function getSupportForTimeouts(): bool } /** - * Returns Max Execution Time - * @param string $sql + * Set max execution time * @param int $milliseconds - * @return string + * @param string $event + * @return void + * @throws DatabaseException */ - protected function setTimeoutForQuery(string $sql, int $milliseconds): string + public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void { if (!$this->getSupportForTimeouts()) { - return $sql; + return; + } + if ($milliseconds <= 0) { + throw new DatabaseException('Timeout must be greater than 0'); } $seconds = $milliseconds / 1000; - return "SET STATEMENT max_statement_time = {$seconds} FOR " . $sql; + + $this->before($event, 'timeout', function ($sql) use ($seconds) { + return "SET STATEMENT max_statement_time = {$seconds} FOR " . $sql; + }); } /** * @param PDOException $e - * @throws Timeout + * @throws TimeoutException */ protected function processException(PDOException $e): void { // Regular PDO if ($e->getCode() === '70100' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1969) { - throw new Timeout($e->getMessage()); + throw new TimeoutException($e->getMessage()); } // PDOProxy switches errorInfo PDOProxy.php line 64 if ($e->getCode() === 1969 && isset($e->errorInfo[0]) && $e->errorInfo[0] === '70100') { - throw new Timeout($e->getMessage()); + throw new TimeoutException($e->getMessage()); } throw $e; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index df827a405..ca1ed5f9b 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -42,6 +42,8 @@ class Mongo extends Adapter protected Client $client; + protected ?int $timeout = null; + /** * Constructor. * @@ -812,13 +814,12 @@ public function updateAttribute(string $collection, string $id, string $type, in * @param array $orderTypes * @param array $cursor * @param string $cursorDirection - * @param int|null $timeout * * @return array * @throws Exception * @throws Timeout */ - public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, ?int $timeout = null): array + public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER): array { $name = $this->getNamespace() . '_' . $this->filter($collection); @@ -838,8 +839,8 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $options['skip'] = $offset; } - if ($timeout || self::$timeout) { - $options['maxTimeMS'] = $timeout ? $timeout : self::$timeout; + if ($this->timeout) { + $options['maxTimeMS'] = $this->timeout; } $selections = $this->getAttributeSelections($queries); @@ -1076,7 +1077,7 @@ private function recursiveReplace(array $array, string $from, string $to, array * @return int * @throws Exception */ - public function count(string $collection, array $queries = [], ?int $max = null, ?int $timeout = null): int + public function count(string $collection, array $queries = [], ?int $max = null): int { $name = $this->getNamespace() . '_' . $this->filter($collection); @@ -1088,8 +1089,8 @@ public function count(string $collection, array $queries = [], ?int $max = null, $options['limit'] = $max; } - if ($timeout || self::$timeout) { - $options['maxTimeMS'] = $timeout ? $timeout : self::$timeout; + if ($this->timeout) { + $options['maxTimeMS'] = $this->timeout; } // queries @@ -1115,15 +1116,9 @@ public function count(string $collection, array $queries = [], ?int $max = null, * @return int|float * @throws Exception */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null, ?int $timeout = null): float|int + public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int { $name = $this->getNamespace() . '_' . $this->filter($collection); - $collection = $this->getDatabase()->selectCollection($name); - // todo $collection is not used? - - // todo add $timeout for aggregate in Mongo utopia client - - $filters = []; // queries $filters = $this->buildFilters($queries); @@ -1707,4 +1702,19 @@ public function getMaxIndexLength(): int { return 0; } + + public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + { + if (!$this->getSupportForTimeouts()) { + return; + } + $this->timeout = $milliseconds; + } + + public function clearTimeout(string $event): void + { + parent::clearTimeout($event); + + $this->timeout = null; + } } diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 3260cb412..245683a3d 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -5,7 +5,7 @@ use PDOException; use Utopia\Database\Database; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Timeout; +use Utopia\Database\Exception\Timeout as TimeoutException; class MySQL extends MariaDB { @@ -51,29 +51,43 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr } /** - * Returns Max Execution Time - * @param string $sql + * Set max execution time * @param int $milliseconds - * @return string + * @param string $event + * @return void + * @throws DatabaseException */ - protected function setTimeoutForQuery(string $sql, int $milliseconds): string + public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void { - return preg_replace('/SELECT/', "SELECT /*+ max_execution_time({$milliseconds}) */", $sql, 1); + if (!$this->getSupportForTimeouts()) { + return; + } + if ($milliseconds <= 0) { + throw new DatabaseException('Timeout must be greater than 0'); + } + $this->before($event, 'timeout', function ($sql) use ($milliseconds) { + return \preg_replace( + pattern: '/SELECT/', + replacement: "SELECT /*+ max_execution_time({$milliseconds}) */", + subject: $sql, + limit: 1 + ); + }); } /** * @param PDOException $e - * @throws Timeout + * @throws TimeoutException */ protected function processException(PDOException $e): void { if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3024) { - throw new Timeout($e->getMessage()); + throw new TimeoutException($e->getMessage()); } // PDOProxy which who switches errorInfo if ($e->getCode() === 3024 && isset($e->errorInfo[0]) && $e->errorInfo[0] === "HY000") { - throw new Timeout($e->getMessage()); + throw new TimeoutException($e->getMessage()); } throw $e; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 97b600123..b2e27bda5 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1058,14 +1058,13 @@ public function deleteDocument(string $collection, string $id): bool * @param array $orderTypes * @param array $cursor * @param string $cursorDirection - * @param int|null $timeout * * @return array * @throws Exception * @throws PDOException * @throws Timeout */ - public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, ?int $timeout = null): array + public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER): array { $name = $this->filter($collection); $roles = Authorization::getRoles(); @@ -1168,10 +1167,6 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); - if ($timeout || self::$timeout) { - $sql = $this->setTimeoutForQuery($sql, $timeout ?: self::$timeout); - } - $stmt = $this->getPDO()->prepare($sql); foreach ($queries as $query) { @@ -1254,7 +1249,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, * * @return int */ - public function count(string $collection, array $queries = [], ?int $max = null, ?int $timeout = null): int + public function count(string $collection, array $queries = [], ?int $max = null): int { $name = $this->filter($collection); $roles = Authorization::getRoles(); @@ -1281,10 +1276,6 @@ public function count(string $collection, array $queries = [], ?int $max = null, $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); - if ($timeout || self::$timeout) { - $sql = $this->setTimeoutForQuery($sql, $timeout ?: self::$timeout); - } - $stmt = $this->getPDO()->prepare($sql); foreach ($queries as $query) { @@ -1314,7 +1305,7 @@ public function count(string $collection, array $queries = [], ?int $max = null, * * @return int|float */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null, ?int $timeout = null): int|float + public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): int|float { $name = $this->filter($collection); $roles = Authorization::getRoles(); @@ -1342,10 +1333,6 @@ public function sum(string $collection, string $attribute, array $queries = [], $sql = $this->trigger(Database::EVENT_DOCUMENT_SUM, $sql); - if ($timeout || self::$timeout) { - $sql = $this->setTimeoutForQuery($sql, $timeout ? $timeout : self::$timeout); - } - $stmt = $this->getPDO()->prepare($sql); foreach ($queries as $query) { @@ -1644,13 +1631,26 @@ public function getSupportForTimeouts(): bool /** * Returns Max Execution Time - * @param string $sql * @param int $milliseconds - * @return string + * @param string $event + * @return void + * @throws DatabaseException */ - protected function setTimeoutForQuery(string $sql, int $milliseconds): string + public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void { - return "SET statement_timeout = {$milliseconds};{$sql};SET statement_timeout = 0;"; + if (!$this->getSupportForTimeouts()) { + return; + } + if ($milliseconds <= 0) { + throw new DatabaseException('Timeout must be greater than 0'); + } + $this->before($event, 'timeout', function ($sql) use ($milliseconds) { + return " + SET statement_timeout = {$milliseconds}; + {$sql}; + SET statement_timeout = 0; + "; + }); } /** diff --git a/src/Database/Database.php b/src/Database/Database.php index 8fc1c3dbe..958a0e5db 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -10,9 +10,10 @@ use Utopia\Database\Exception\Conflict as ConflictException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -618,23 +619,24 @@ public function resetMetadata(): void * Set maximum query execution time * * @param int $milliseconds + * @param string $event * @return void * @throws Exception */ - public function setTimeout(int $milliseconds): void + public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void { - $this->adapter->setTimeout($milliseconds); + $this->adapter->setTimeout($milliseconds, $event); } /** * Clear maximum query execution time * + * @param string $event * @return void - * @throws Exception */ - public function clearTimeout(): void + public function clearTimeout(string $event = Database::EVENT_ALL): void { - $this->adapter->clearTimeout(); + $this->adapter->clearTimeout($event); } /** @@ -4127,17 +4129,14 @@ public function deleteCachedDocument(string $collection, string $id): bool * * @param string $collection * @param array $queries - * @param int|null $timeout * * @return array * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException */ - public function find(string $collection, array $queries = [], ?int $timeout = null): array + public function find(string $collection, array $queries = []): array { - if (!\is_null($timeout) && $timeout <= 0) { - throw new DatabaseException('Timeout must be greater than 0'); - } - $originalName = $collection; $collection = $this->silent(fn () => $this->getCollection($collection)); @@ -4241,8 +4240,7 @@ public function find(string $collection, array $queries = [], ?int $timeout = nu $orderAttributes, $orderTypes, $cursor, - $cursorDirection ?? Database::CURSOR_AFTER, - $timeout + $cursorDirection ?? Database::CURSOR_AFTER ); $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); diff --git a/tests/Database/Base.php b/tests/Database/Base.php index 99e7b41d0..df210c2f4 100644 --- a/tests/Database/Base.php +++ b/tests/Database/Base.php @@ -221,13 +221,14 @@ public function testCreatedAtUpdatedAt(): void $this->assertNotNull($document->getInternalId()); } - public function testQueryTimeoutUsingStaticTimeout(): void + + public function testQueryTimeout(): void { if ($this->getDatabase()->getAdapter()->getSupportForTimeouts()) { static::getDatabase()->createCollection('global-timeouts'); $this->assertEquals(true, static::getDatabase()->createAttribute('global-timeouts', 'longtext', Database::VAR_STRING, 100000000, true)); - for ($i = 0 ; $i <= 5 ; $i++) { + for ($i = 0 ; $i <= 20 ; $i++) { static::getDatabase()->createDocument('global-timeouts', new Document([ 'longtext' => file_get_contents(__DIR__ . '/../resources/longtext.txt'), '$permissions' => [ @@ -239,6 +240,7 @@ public function testQueryTimeoutUsingStaticTimeout(): void } $this->expectException(Timeout::class); + static::getDatabase()->setTimeout(1); try { @@ -251,10 +253,10 @@ public function testQueryTimeoutUsingStaticTimeout(): void throw $ex; } } + $this->expectNotToPerformAssertions(); } - /** * @depends testCreateExistsDelete */ @@ -2653,33 +2655,6 @@ public function testFindOrderByAfterException(): void ]); } - public function testTimeout(): void - { - if ($this->getDatabase()->getAdapter()->getSupportForTimeouts()) { - static::getDatabase()->createCollection('timeouts'); - $this->assertEquals(true, static::getDatabase()->createAttribute('timeouts', 'longtext', Database::VAR_STRING, 100000000, true)); - - for ($i = 0 ; $i <= 5 ; $i++) { - static::getDatabase()->createDocument('timeouts', new Document([ - 'longtext' => file_get_contents(__DIR__ . '/../resources/longtext.txt'), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ] - ])); - } - - $this->expectException(Timeout::class); - - static::getDatabase()->find('timeouts', [ - Query::notEqual('longtext', 'appwrite'), - ], 1); - } - - $this->expectNotToPerformAssertions(); - } - /** * @depends testUpdateDocument */