Compare commits

..
Author SHA1 Message Date
fogelito b46fc77053 paramQueries 2026-02-26 13:07:44 +02:00
19 changed files with 68 additions and 552 deletions
-10
View File
@@ -630,11 +630,6 @@ return [
'description' => 'Site with the requested ID could not be found.',
'code' => 404,
],
Exception::SITE_ALREADY_EXISTS => [
'name' => Exception::SITE_ALREADY_EXISTS,
'description' => 'Site with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::SITE_TEMPLATE_NOT_FOUND => [
'name' => Exception::SITE_TEMPLATE_NOT_FOUND,
'description' => 'Site Template with the requested ID could not be found.',
@@ -1296,11 +1291,6 @@ return [
'description' => 'Message with the requested ID could not be found.',
'code' => 404,
],
Exception::MESSAGE_ALREADY_EXISTS => [
'name' => Exception::MESSAGE_ALREADY_EXISTS,
'description' => 'Message with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::MESSAGE_MISSING_TARGET => [
'name' => Exception::MESSAGE_MISSING_TARGET,
'description' => 'Message with the requested ID has no recipients (topics or users or targets).',
+6 -21
View File
@@ -3251,7 +3251,7 @@ Http::post('/v1/messaging/messages/email')
}
}
$message = new Document([
$message = $dbForProject->createDocument('messages', new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_EMAIL,
'topics' => $topics,
@@ -3267,12 +3267,7 @@ Http::post('/v1/messaging/messages/email')
'attachments' => $attachments,
],
'status' => $status,
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
]));
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3405,7 +3400,7 @@ Http::post('/v1/messaging/messages/sms')
}
}
$message = new Document([
$message = $dbForProject->createDocument('messages', new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_SMS,
'topics' => $topics,
@@ -3415,12 +3410,7 @@ Http::post('/v1/messaging/messages/sms')
'content' => $content,
],
'status' => $status,
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
]));
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3630,7 +3620,7 @@ Http::post('/v1/messaging/messages/push')
$pushData['priority'] = $priority;
}
$message = new Document([
$message = $dbForProject->createDocument('messages', new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_PUSH,
'topics' => $topics,
@@ -3639,12 +3629,7 @@ Http::post('/v1/messaging/messages/push')
'scheduledAt' => $scheduledAt,
'data' => $pushData,
'status' => $status,
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
]));
switch ($status) {
case MessageStatus::PROCESSING:
+1 -41
View File
@@ -560,29 +560,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'site' => '',
};
$streamingDetected = false;
$streamCallback = function (?string $streamData, ?array $streamHeaders) use ($response, &$streamingDetected, $execution, $deployment): void {
if ($streamHeaders !== null) {
// Headers signal — fired once before body, only when SSE detected
$streamingDetected = true;
$statusCode = \intval($streamHeaders['x-open-runtimes-status-code'] ?? 200);
$response->setStatusCode($statusCode);
foreach ($streamHeaders as $key => $value) {
if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) {
$response->addHeader($key, \is_array($value) ? \implode(', ', $value) : $value);
}
}
if ($deployment->getAttribute('resourceType') === 'functions') {
$response->addHeader('x-appwrite-execution-id', $execution->getId());
} elseif ($deployment->getAttribute('resourceType') === 'sites') {
$response->addHeader('x-appwrite-log-id', $execution->getId());
}
}
if ($streamData !== null) {
$response->chunk($streamData);
}
};
$executionResponse = $executor->createExecution(
projectId: $project->getId(),
deploymentId: $deployment->getId(),
@@ -601,26 +578,9 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
logging: $resource->getAttribute('logging', true),
requestTimeout: 30,
responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS,
streamCallback: $streamCallback,
responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS
);
// If SSE streaming was detected, body was already forwarded chunk by chunk.
// Close the response and populate execution metadata from what we have.
// Note: logs/errors are not available for streaming responses (the executor
// streams the body and does not send metadata back over the same channel).
if ($streamingDetected) {
$response->chunk('', true);
$execution->setAttribute('status', 'completed');
$execution->setAttribute('logs', '');
$execution->setAttribute('errors', '');
$execution->setAttribute('responseStatusCode', 200);
$execution->setAttribute('responseHeaders', []);
$execution->setAttribute('duration', \microtime(true) - $durationStart);
return true;
}
$headerOverrides = [];
// Branded 404 override
+8 -14
View File
@@ -27,7 +27,6 @@ use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
@@ -930,19 +929,14 @@ Http::shutdown()
$accessedAt = $cacheLog->getAttribute('accessedAt', 0);
$now = DateTime::now();
if ($cacheLog->isEmpty()) {
try {
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
} catch (DuplicateException) {
// Race condition: another concurrent request already created the cache document
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
}
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
} elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
$cacheLog->setAttribute('accessedAt', $now);
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog));
+5
View File
@@ -562,6 +562,11 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
$log->addTag('method', $route?->getMethod() ?? $request->getMethod());
$log->addTag('url', $route?->getPath() ?? $request->getURI());
if (str_contains($th->getMessage(), 'FTS_TERM or FTS_NUMB')) {
$log->addTag('paramQueries', json_encode($request->getParam('queries')));
}
$log->addTag('verboseType', get_class($th));
$log->addTag('code', $th->getCode());
// $log->addTag('projectId', $project->getId()); // TODO: Figure out how to get ProjectID, if it becomes relevant
Generated
+24 -24
View File
@@ -4517,16 +4517,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.6.2",
"version": "1.6.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca"
"reference": "c5c7544d02d2418536d41050794050132f247d62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/037bf4b3813d44f1b0990bc124e35b501ed27fca",
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c5c7544d02d2418536d41050794050132f247d62",
"reference": "c5c7544d02d2418536d41050794050132f247d62",
"shasum": ""
},
"require": {
@@ -4566,9 +4566,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.6.2"
"source": "https://github.com/utopia-php/migration/tree/1.6.1"
},
"time": "2026-02-25T12:00:11+00:00"
"time": "2026-02-17T05:49:48+00:00"
},
{
"name": "utopia-php/mongo",
@@ -4684,16 +4684,16 @@
},
{
"name": "utopia-php/pools",
"version": "1.0.3",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/pools.git",
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10"
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"shasum": ""
},
"require": {
@@ -4731,9 +4731,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/pools/issues",
"source": "https://github.com/utopia-php/pools/tree/1.0.3"
"source": "https://github.com/utopia-php/pools/tree/1.0.2"
},
"time": "2026-02-26T08:42:40+00:00"
"time": "2026-01-28T13:12:36+00:00"
},
{
"name": "utopia-php/preloader",
@@ -5489,16 +5489,16 @@
},
{
"name": "brianium/paratest",
"version": "v7.19.1",
"version": "v7.19.0",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7"
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/95b03194f4cdf5c83175ceead673e21cb66465e7",
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
"shasum": ""
},
"require": {
@@ -5512,7 +5512,7 @@
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
"phpunit/php-file-iterator": "^6.0.1 || ^7",
"phpunit/php-timer": "^8 || ^9",
"phpunit/phpunit": "^12.5.14 || ^13.0.5",
"phpunit/phpunit": "^12.5.9 || ^13",
"sebastian/environment": "^8.0.3 || ^9",
"symfony/console": "^7.4.4 || ^8.0.4",
"symfony/process": "^7.4.5 || ^8.0.5"
@@ -5522,10 +5522,10 @@
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
"phpstan/phpstan": "^2.1.40",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
"phpstan/phpstan": "^2.1.38",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.12",
"phpstan/phpstan-strict-rules": "^2.0.8",
"symfony/filesystem": "^7.4.0 || ^8.0.1"
},
"bin": [
@@ -5566,7 +5566,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.19.1"
"source": "https://github.com/paratestphp/paratest/tree/v7.19.0"
},
"funding": [
{
@@ -5578,7 +5578,7 @@
"type": "paypal"
}
],
"time": "2026-02-25T14:53:45+00:00"
"time": "2026-02-06T10:53:26+00:00"
},
{
"name": "doctrine/annotations",
@@ -9067,5 +9067,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
-2
View File
@@ -166,7 +166,6 @@ class Exception extends \Exception
/** Sites */
public const string SITE_NOT_FOUND = 'site_not_found';
public const string SITE_ALREADY_EXISTS = 'site_already_exists';
public const string SITE_TEMPLATE_NOT_FOUND = 'site_template_not_found';
/** Functions */
@@ -366,7 +365,6 @@ class Exception extends \Exception
/** Message */
public const string MESSAGE_NOT_FOUND = 'message_not_found';
public const string MESSAGE_ALREADY_EXISTS = 'message_already_exists';
public const string MESSAGE_MISSING_TARGET = 'message_missing_target';
public const string MESSAGE_ALREADY_SENT = 'message_already_sent';
public const string MESSAGE_ALREADY_PROCESSING = 'message_already_processing';
@@ -24,7 +24,6 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends Action
@@ -71,17 +70,15 @@ class XList extends Action
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('queueForStatsUsage')
->inject('transactionState')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
@@ -132,73 +129,9 @@ class XList extends Action
$documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries);
$total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0;
} elseif (! empty($selectQueries)) {
if ((int)$ttl > 0) {
$serializedQueries = [];
foreach ($queries as $query) {
$serializedQueries[] = $query instanceof Query ? $query->toArray() : $query;
}
$hostname = $dbForProject->getAdapter()->getHostname();
$roles = $dbForProject->getAuthorization()->getRoles();
$schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', [])));
$cacheKeyBase = \sprintf(
'%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s',
$dbForProject->getCacheName(),
$hostname ?? '',
$dbForProject->getNamespace(),
$dbForProject->getTenant(),
$collectionId,
$schemaHash,
\md5(\json_encode($roles)),
\md5(\json_encode($serializedQueries))
);
$documentsCacheKey = $cacheKeyBase . ':documents';
$totalCacheKey = $cacheKeyBase . ':total';
$documentsCacheHit = $totalDocumentsCacheHit = false;
$cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl);
if ($cachedDocuments !== null &&
$cachedDocuments !== false &&
\is_array($cachedDocuments)) {
$documents = \array_map(function ($doc) {
return new Document($doc);
}, $cachedDocuments);
$documentsCacheHit = true;
} else {
$documents = $dbForProject->find($collectionTableId, $queries);
// Convert Document objects to arrays for caching
$documentsArray = \array_map(function ($doc) {
return $doc->getArrayCopy();
}, $documents);
$dbForProject->getCache()->save($documentsCacheKey, $documentsArray);
}
if ($includeTotal) {
$cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl);
if ($cachedTotal !== null && $cachedTotal !== false) {
$total = $cachedTotal;
$totalDocumentsCacheHit = true;
} else {
$total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT);
$dbForProject->getCache()->save($totalCacheKey, $total);
}
} else {
$total = 0;
}
$response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss');
} else {
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
}
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
} else {
// has no selects, disable relationship loading on documents
/* @type Document[] $documents */
@@ -14,7 +14,6 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends DocumentXList
@@ -57,10 +56,8 @@ class XList extends DocumentXList
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('queueForStatsUsage')
->inject('transactionState')
->inject('authorization')
@@ -90,15 +90,6 @@ class Delete extends Base
}
$status = $execution->getAttribute('status');
// Treat timed-out executions as failed so they can be deleted.
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$status = 'failed';
}
}
if (!in_array($status, ['completed', 'failed', 'scheduled'])) {
throw new Exception(Exception::EXECUTION_IN_PROGRESS);
}
@@ -82,16 +82,6 @@ class Get extends Base
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
// Override status in response if the execution is stuck in waiting/processing beyond the function timeout.
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
$response->dynamic($execution, Response::MODEL_EXECUTION);
}
}
@@ -11,7 +11,6 @@ use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -111,35 +110,6 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing execution is considered timed out.
$timeout = $function->getAttribute('timeout', 900);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing executions created before the timeout threshold, so timed-out
// executions that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -149,20 +119,6 @@ class XList extends Base
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
// Override status in response for timed-out executions, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $execution) {
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -71,16 +71,6 @@ class Get extends Base
throw new Exception(Exception::LOG_NOT_FOUND);
}
// Override status in response if the log is stuck in waiting/processing beyond the site timeout.
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $site->getAttribute('timeout', 30);
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
$response->dynamic($log, Response::MODEL_EXECUTION); //TODO: Change to model log, but model log already exists - decide what to do
}
}
@@ -11,7 +11,6 @@ use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Database\Validator\Queries\Logs;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -100,35 +99,6 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing log is considered timed out.
$timeout = $site->getAttribute('timeout', 30);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing logs created before the timeout threshold, so timed-out
// logs that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -138,20 +108,6 @@ class XList extends Base
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
// Override status in response for timed-out logs, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $log) {
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -14,7 +14,6 @@ use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -137,7 +136,7 @@ class Create extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
$site = new Document([
$site = $dbForProject->createDocument('sites', new Document([
'$id' => $siteId,
'enabled' => $enabled,
'live' => true,
@@ -167,17 +166,13 @@ class Create extends Base
'runtimeSpecification' => $specification,
'buildRuntime' => $buildRuntime,
'adapter' => $adapter,
]);
try {
$site = $dbForProject->createDocument('sites', $site);
} catch (DuplicateException) {
throw new Exception(Exception::SITE_ALREADY_EXISTS);
}
]));
// Git connect logic
if (!empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = new Document([
$repository = $dbForPlatform->createDocument('repositories', new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -189,8 +184,8 @@ class Create extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
]));
$site->setAttribute('repositoryId', $repository->getId());
$site->setAttribute('repositoryInternalId', $repository->getSequence());
}
@@ -190,9 +190,11 @@ class Update extends Base
$repositoryInternalId = '';
}
// Git connect logic
if (!$isConnected && !empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = new Document([
$repository = $dbForPlatform->createDocument('repositories', new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -204,8 +206,8 @@ class Update extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
]));
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
}
+4 -9
View File
@@ -11,6 +11,7 @@ use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Utopia\Response\Model\Execution;
use Exception;
use Executor\Executor;
use Utopia\Config\Config;
use Utopia\Console;
@@ -72,10 +73,7 @@ class Functions extends Action
$payload = $message->getPayload() ?? [];
if (empty($payload)) {
throw new AppwriteException(
AppwriteException::GENERAL_ARGUMENT_INVALID,
'Functions worker: missing payload in schedule execution'
);
throw new Exception('Missing payload');
}
$type = $payload['type'] ?? '';
@@ -394,10 +392,7 @@ class Functions extends Action
$runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []);
if (!\array_key_exists($function->getAttribute('runtime'), $runtimes)) {
throw new AppwriteException(
AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED,
\sprintf('Runtime "%s" is not supported', $function->getAttribute('runtime', '')),
);
throw new Exception('Runtime "' . $function->getAttribute('runtime', '') . '" is not supported');
}
$runtime = $runtimes[$function->getAttribute('runtime')];
@@ -645,7 +640,7 @@ class Functions extends Action
if (!empty($error)) {
throw new AppwriteException(
AppwriteException::GENERAL_SERVER_ERROR,
'Function execution failed: ' . ($error ?: 'No error message provided'),
$error ?: 'Function execution failed with no error message',
$errorCode
);
}
+4 -26
View File
@@ -129,15 +129,7 @@ class Executor
'timeout' => $timeout
];
// Wrap callback to match two-arg signature (?string $data, ?array $headers)
// getLogs only cares about data chunks, not headers signal
$wrappedCallback = function (?string $data, ?array $headers) use ($callback): void {
if ($data !== null) {
$callback($data);
}
};
$this->call($this->endpoint, self::METHOD_GET, $route, [ 'x-opr-runtime-id' => $runtimeId ], $params, true, $timeout, $wrappedCallback);
$this->call($this->endpoint, self::METHOD_GET, $route, [ 'x-opr-runtime-id' => $runtimeId ], $params, true, $timeout, $callback);
}
/**
@@ -212,8 +204,7 @@ class Executor
bool $logging,
string $runtimeEntrypoint = '',
?int $requestTimeout = null,
string $responseFormat = self::RESPONSE_FORMAT_OBJECT_HEADERS,
?callable $streamCallback = null,
string $responseFormat = self::RESPONSE_FORMAT_OBJECT_HEADERS
) {
$runtimeId = "$projectId-$deploymentId";
$route = '/runtimes/' . $runtimeId . '/executions';
@@ -251,13 +242,6 @@ class Executor
$requestTimeout = $timeout + 15;
}
// Streaming path: tell executor to stream and forward chunks via callback
if ($streamCallback !== null) {
$params['stream'] = 'true';
$this->call($this->endpoint, self::METHOD_POST, $route, [ 'x-opr-runtime-id' => $runtimeId, 'content-type' => 'multipart/form-data', 'x-executor-response-format' => $responseFormat ], $params, false, $requestTimeout, $streamCallback);
return [];
}
$response = $this->call($this->endpoint, self::METHOD_POST, $route, [ 'x-opr-runtime-id' => $runtimeId, 'content-type' => 'multipart/form-data', 'accept' => 'multipart/form-data', 'x-executor-response-format' => $responseFormat ], $params, true, $requestTimeout);
$status = $response['headers']['status-code'];
@@ -353,14 +337,8 @@ class Executor
if (isset($callback)) {
$headers[] = 'accept: text/event-stream';
$callbackHeadersFired = false;
$handleEvent = function ($ch, $data) use ($callback, &$callbackHeadersFired, &$responseHeaders) {
if (!$callbackHeadersFired) {
// Fire headers signal once before the first data chunk
$callback(null, $responseHeaders);
$callbackHeadersFired = true;
}
$callback($data, null);
$handleEvent = function ($ch, $data) use ($callback) {
$callback($data);
return \strlen($data);
};
@@ -3267,205 +3267,6 @@ trait DatabasesBase
], $this->getHeaders()));
}
public function testListDocumentsWithCache(): void
{
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
$docIds = $data['documentIds'];
// Filter to setup documents only, since other tests may have created additional docs in this collection.
$baseQueries = [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', 'releaseYear', '$id'])->toString(),
Query::orderAsc('releaseYear')->toString(),
];
// 1. Using cache with select queries, first request should miss cache.
$documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => $baseQueries,
'ttl' => 30,
]);
$this->assertEquals(200, $documents1['headers']['status-code']);
$this->assertEquals(3, $documents1['body']['total']);
$this->assertCount(3, $documents1['body'][$this->getRecordResource()]);
$this->assertEquals(1944, $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertEquals(2017, $documents1['body'][$this->getRecordResource()][1]['releaseYear']);
$this->assertEquals(2019, $documents1['body'][$this->getRecordResource()][2]['releaseYear']);
$this->assertArrayHasKey('title', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('releaseYear', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('$id', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
// 2. Using cache with same select queries, should return cached results.
$documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => $baseQueries,
'ttl' => 30,
]);
$this->assertEquals(200, $documents2['headers']['status-code']);
$this->assertEquals(3, $documents2['body']['total']);
$this->assertCount(3, $documents2['body'][$this->getRecordResource()]);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['releaseYear'], $documents2['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
// 3. Using cache with same select queries but total is false, should return cached results just for documents.
$documents3 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => $baseQueries,
'ttl' => 30,
'total' => false,
]);
$this->assertEquals(200, $documents3['headers']['status-code']);
$this->assertCount(3, $documents3['body'][$this->getRecordResource()]);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['$id'], $documents1['body'][$this->getRecordResource()][0]['$id']);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['releaseYear'], $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertEquals(0, $documents3['body']['total']);
$this->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
$this->assertEquals('hit', $documents3['headers']['x-appwrite-cache']);
// 4. Using cache with different select queries, should miss cache.
$documents4 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('$id', $docIds)->toString(),
Query::select(['title'])->toString(),
Query::orderAsc('releaseYear')->toString(),
],
'ttl' => 10,
]);
$this->assertEquals(200, $documents4['headers']['status-code']);
$this->assertEquals(3, $documents4['body']['total']);
$this->assertCount(3, $documents4['body'][$this->getRecordResource()]);
$this->assertEquals($documents4['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents4['body'][$this->getRecordResource()][1]['title'], $documents1['body'][$this->getRecordResource()][1]['title']);
$this->assertEquals($documents4['body'][$this->getRecordResource()][2]['title'], $documents1['body'][$this->getRecordResource()][2]['title']);
$this->assertArrayHasKey('x-appwrite-cache', $documents4['headers']);
$this->assertEquals('miss', $documents4['headers']['x-appwrite-cache']);
// 5. Not using cache at all
$documents5 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', 'releaseYear', '$id'])->toString(),
Query::orderAsc('releaseYear')->toString(),
],
]);
$this->assertEquals(200, $documents5['headers']['status-code']);
$this->assertCount(3, $documents5['body'][$this->getRecordResource()]);
$this->assertEquals(1944, $documents5['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertArrayNotHasKey('x-appwrite-cache', $documents5['headers']);
sleep(10);
// 6. Using cache with same select queries but passed ttl time, should miss cache.
$documents6 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => $baseQueries,
'ttl' => 10,
]);
$this->assertEquals(200, $documents6['headers']['status-code']);
$this->assertCount(3, $documents6['body'][$this->getRecordResource()]);
$this->assertArrayHasKey('title', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('releaseYear', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('$id', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('x-appwrite-cache', $documents6['headers']);
$this->assertEquals('miss', $documents6['headers']['x-appwrite-cache']);
}
public function testListDocumentsCacheBustedByAttributeChange(): void
{
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
$docIds = $data['documentIds'];
// Use different select queries from testListDocumentsWithCache to avoid cache key collision.
$queries = [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', '$id'])->toString(),
Query::orderAsc('$createdAt')->toString(),
];
// 1. First request should miss cache.
$documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => $queries,
'ttl' => 300,
]);
$this->assertEquals(200, $documents1['headers']['status-code']);
$this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
// 2. Same request should hit cache.
$documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => $queries,
'ttl' => 300,
]);
$this->assertEquals(200, $documents2['headers']['status-code']);
$this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
// 3. Add a new attribute to the collection, which updates the collection's $updatedAt.
$attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'cacheTestAttr',
'size' => 64,
'required' => false,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
// Wait for the attribute to be ready
$this->waitForAttribute($databaseId, $data['moviesId'], 'cacheTestAttr');
// 4. Same request should now miss cache because collection $updatedAt changed.
$documents3 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => $queries,
'ttl' => 300,
]);
$this->assertEquals(200, $documents3['headers']['status-code']);
$this->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
$this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']);
}
public function testGetDocument(): void
{
$data = $this->getDocumentsList();