mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ec28f02a | ||
|
|
18f3280bad | ||
|
|
bae328efa1 | ||
|
|
03575e68c4 | ||
|
|
2762e84974 | ||
|
|
7b2f6ac692 | ||
|
|
542aac7fda | ||
|
|
7430de293e | ||
|
|
2f2a124a06 | ||
|
|
c2a249c48b | ||
|
|
b2b9ac5b4d | ||
|
|
18a67e00d3 | ||
|
|
e634145612 | ||
|
|
ce15eeb722 | ||
|
|
b15457bcca | ||
|
|
fce2abfd4c | ||
|
|
da5382d58a | ||
|
|
380cc3eb27 | ||
|
|
b29f9f4a45 | ||
|
|
29a0d6c2bf | ||
|
|
752df21007 | ||
|
|
92b5f0dcd6 | ||
|
|
77982f4b90 | ||
|
|
784babcf45 | ||
|
|
fb0d43daf3 |
@@ -54,6 +54,11 @@ return [
|
||||
'description' => 'Rate limit for the current endpoint has been exceeded. Please try again after some time.',
|
||||
'code' => 429,
|
||||
],
|
||||
Exception::GENERAL_RESOURCE_LOCKED => [
|
||||
'name' => Exception::GENERAL_RESOURCE_LOCKED,
|
||||
'description' => 'The requested resource is currently being modified by another request. Please retry after a brief delay.',
|
||||
'code' => 409,
|
||||
],
|
||||
Exception::GENERAL_SMTP_DISABLED => [
|
||||
'name' => Exception::GENERAL_SMTP_DISABLED,
|
||||
'description' => 'SMTP is disabled on your Appwrite instance. You can <a href="/docs/email-delivery">learn more about setting up SMTP</a> in our docs.',
|
||||
|
||||
@@ -34,6 +34,15 @@ return [
|
||||
'question' => '',
|
||||
'filter' => ''
|
||||
],
|
||||
[
|
||||
'name' => '_APP_LOCKING_ENABLED',
|
||||
'description' => 'Enable distributed locking for platform writes. Locks coordinate concurrent updates across API pods so that read-modify-write operations on shared documents do not lose updates. By default, set to \'enabled\'. Set to \'disabled\' as an emergency kill switch — locks become no-ops (fail-open) and concurrent writes will race.',
|
||||
'introduction' => '1.9.3',
|
||||
'default' => 'enabled',
|
||||
'required' => false,
|
||||
'question' => '',
|
||||
'filter' => ''
|
||||
],
|
||||
[
|
||||
'name' => '_APP_OPTIONS_FORCE_HTTPS',
|
||||
'description' => 'Allows you to force HTTPS connection to your API. This feature redirects any HTTP call to HTTPS and adds the \'Strict-Transport-Security\' header to all HTTP responses. By default, set to \'enabled\'. To disable, set to \'disabled\'. This feature will work only when your ports are set to default 80 and 443, and you have set up wildcard certificates with DNS challenge.',
|
||||
|
||||
+15
-12
@@ -11,6 +11,7 @@ use Appwrite\Event\Delete as DeleteEvent;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Publisher\Certificate;
|
||||
use Appwrite\Extend\Exception as AppwriteException;
|
||||
use Appwrite\Locking\Lock;
|
||||
use Appwrite\Network\Cors;
|
||||
use Appwrite\Platform\Appwrite;
|
||||
use Appwrite\SDK\Method;
|
||||
@@ -69,7 +70,7 @@ use Utopia\Validator\Text;
|
||||
|
||||
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
|
||||
|
||||
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
|
||||
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock)
|
||||
{
|
||||
$host = $request->getHostname();
|
||||
if (!empty($previewHostname)) {
|
||||
@@ -138,9 +139,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
|
||||
if (!$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$accessedAt = $project->getAttribute('accessedAt', 0);
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
|
||||
'accessedAt' => DateTime::now()
|
||||
])));
|
||||
$lock->set('projects', $project->getId(), 'accessedAt', DateTime::now());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -849,7 +848,8 @@ Http::init()
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->inject('lock')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
|
||||
/*
|
||||
* Appwrite Router
|
||||
*/
|
||||
@@ -857,7 +857,7 @@ Http::init()
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
// Only run Router when external domain
|
||||
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
@@ -1148,14 +1148,15 @@ Http::options()
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->inject('lock')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
|
||||
/*
|
||||
* Appwrite Router
|
||||
*/
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
// Only run Router when external domain
|
||||
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
@@ -1550,13 +1551,14 @@ Http::get('/robots.txt')
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->inject('lock')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
|
||||
$template = new View(__DIR__ . '/../views/general/robots.phtml');
|
||||
$response->text($template->render(false));
|
||||
} else {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
@@ -1584,13 +1586,14 @@ Http::get('/humans.txt')
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->inject('lock')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
|
||||
$template = new View(__DIR__ . '/../views/general/humans.phtml');
|
||||
$response->text($template->render(false));
|
||||
} else {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use Appwrite\Event\Webhook;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Extend\Exception as AppwriteException;
|
||||
use Appwrite\Functions\EventProcessor;
|
||||
use Appwrite\Locking\Lock;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\Usage\Context;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
@@ -100,7 +101,8 @@ Http::init()
|
||||
->inject('team')
|
||||
->inject('apiKey')
|
||||
->inject('authorization')
|
||||
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
|
||||
->inject('lock')
|
||||
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization, Lock $lock) {
|
||||
$route = $utopia->getRoute();
|
||||
if ($route === null) {
|
||||
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
|
||||
@@ -245,20 +247,22 @@ Http::init()
|
||||
$sdks[] = $sdk;
|
||||
|
||||
$updates->setAttribute('sdks', $sdks);
|
||||
$updates->setAttribute('accessedAt', Datetime::now());
|
||||
$updates->setAttribute('accessedAt', DateTime::now());
|
||||
}
|
||||
}
|
||||
|
||||
if (! $updates->isEmpty()) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates));
|
||||
$lock->run('keys', $dbKey->getId(), function () use ($dbForPlatform, $dbKey, $updates, $apiKey, $project, $user, $team) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates));
|
||||
|
||||
if (! empty($apiKey->getProjectId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
|
||||
} elseif (! empty($apiKey->getUserId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId()));
|
||||
} elseif (! empty($apiKey->getTeamId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId()));
|
||||
}
|
||||
if (! empty($apiKey->getProjectId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
|
||||
} elseif (! empty($apiKey->getUserId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId()));
|
||||
} elseif (! empty($apiKey->getTeamId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$userClone = clone $user;
|
||||
@@ -389,9 +393,7 @@ Http::init()
|
||||
if ($project->getId() !== 'console') {
|
||||
$accessedAt = $project->getAttribute('accessedAt', 0);
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
|
||||
'accessedAt' => DateTime::now()
|
||||
])));
|
||||
$lock->set('projects', $project->getId(), 'accessedAt', DateTime::now());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,9 +410,7 @@ Http::init()
|
||||
'accessedAt' => $user->getAttribute('accessedAt')
|
||||
]));
|
||||
} else {
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), new Document([
|
||||
'accessedAt' => $user->getAttribute('accessedAt')
|
||||
])));
|
||||
$lock->set('users', $user->getId(), 'accessedAt', $user->getAttribute('accessedAt'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use Appwrite\Event\Webhook;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Functions\EventProcessor;
|
||||
use Appwrite\GraphQL\Schema;
|
||||
use Appwrite\Locking\Lock;
|
||||
use Appwrite\Network\Cors;
|
||||
use Appwrite\Network\Platform;
|
||||
use Appwrite\Network\Validator\Origin;
|
||||
@@ -49,6 +50,7 @@ use Utopia\DSN\DSN;
|
||||
use Utopia\Http\Http;
|
||||
use Utopia\Locale\Locale;
|
||||
use Utopia\Logger\Log;
|
||||
use Utopia\Logger\Logger;
|
||||
use Utopia\Pools\Group;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\Storage\Device;
|
||||
@@ -73,6 +75,10 @@ return function (Container $container): void {
|
||||
return $register->get('logger');
|
||||
}, ['register']);
|
||||
|
||||
$container->set('lock', function (\Redis $redis, Telemetry $telemetry, Database $dbForPlatform, Authorization $authorization, Log $log, ?Logger $logger, Document $project): Lock {
|
||||
return new Lock($redis, $telemetry, $dbForPlatform, $authorization, $log, $logger, $project);
|
||||
}, ['redis', 'telemetry', 'dbForPlatform', 'authorization', 'log', 'logger', 'project']);
|
||||
|
||||
$container->set('authorization', function () {
|
||||
return new Authorization();
|
||||
}, []);
|
||||
|
||||
+8
-1
@@ -95,8 +95,15 @@
|
||||
"spomky-labs/otphp": "11.*",
|
||||
"webonyx/graphql-php": "15.31.*",
|
||||
"league/csv": "9.14.*",
|
||||
"enshrined/svg-sanitize": "0.22.*"
|
||||
"enshrined/svg-sanitize": "0.22.*",
|
||||
"utopia-php/lock": "0.2.*"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/utopia-php/lock"
|
||||
}
|
||||
],
|
||||
"require-dev": {
|
||||
"ext-fileinfo": "*",
|
||||
"appwrite/sdk-generator": "*",
|
||||
|
||||
Generated
+107
-35
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "4bee36b21a57e754d2b3417e72dc9599",
|
||||
"content-hash": "a9d25a0518aee3fc29ea57498cb95744",
|
||||
"packages": [
|
||||
{
|
||||
"name": "adhocore/jwt",
|
||||
@@ -3351,16 +3351,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/abuse",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/abuse.git",
|
||||
"reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152"
|
||||
"reference": "53f4274939353522ba331f55bcff6e6011ffc56c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152",
|
||||
"reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152",
|
||||
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c",
|
||||
"reference": "53f4274939353522ba331f55bcff6e6011ffc56c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3397,9 +3397,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/abuse/issues",
|
||||
"source": "https://github.com/utopia-php/abuse/tree/1.2.2"
|
||||
"source": "https://github.com/utopia-php/abuse/tree/1.2.3"
|
||||
},
|
||||
"time": "2026-02-02T10:43:10+00:00"
|
||||
"time": "2026-04-29T11:19:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/agents",
|
||||
@@ -3850,16 +3850,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/database",
|
||||
"version": "5.3.22",
|
||||
"version": "5.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/database.git",
|
||||
"reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7"
|
||||
"reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7",
|
||||
"reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7",
|
||||
"url": "https://api.github.com/repos/utopia-php/database/zipball/688d9422b5ff42ac2ecc29397d94891cfd772e93",
|
||||
"reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3903,9 +3903,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/database/issues",
|
||||
"source": "https://github.com/utopia-php/database/tree/5.3.22"
|
||||
"source": "https://github.com/utopia-php/database/tree/5.4.1"
|
||||
},
|
||||
"time": "2026-04-20T07:12:46+00:00"
|
||||
"time": "2026-04-29T07:32:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/detector",
|
||||
@@ -4062,16 +4062,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/domains",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/domains.git",
|
||||
"reference": "0edf6bb2b07f30db849a267027077bf5abb994c6"
|
||||
"reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6",
|
||||
"reference": "0edf6bb2b07f30db849a267027077bf5abb994c6",
|
||||
"url": "https://api.github.com/repos/utopia-php/domains/zipball/c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
|
||||
"reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4118,9 +4118,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/domains/issues",
|
||||
"source": "https://github.com/utopia-php/domains/tree/1.0.5"
|
||||
"source": "https://github.com/utopia-php/domains/tree/1.0.6"
|
||||
},
|
||||
"time": "2026-03-03T09:20:50+00:00"
|
||||
"time": "2026-04-29T11:08:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/dsn",
|
||||
@@ -4423,6 +4423,78 @@
|
||||
},
|
||||
"time": "2025-08-12T12:58:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/lock",
|
||||
"version": "0.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/lock.git",
|
||||
"reference": "49317c9493d8f747e4299aa24c22862aa5f6e106"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/lock/zipball/49317c9493d8f747e4299aa24c22862aa5f6e106",
|
||||
"reference": "49317c9493d8f747e4299aa24c22862aa5f6e106",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "1.*",
|
||||
"phpstan/phpstan": "2.*",
|
||||
"phpunit/phpunit": "11.*",
|
||||
"swoole/ide-helper": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcntl": "Required to run the File lock tests",
|
||||
"ext-redis": "Required for the Distributed lock",
|
||||
"ext-swoole": "Required for the Mutex and Semaphore locks (>=6.0)"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Utopia\\Lock\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Utopia\\Lock\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": [
|
||||
"vendor/bin/phpunit"
|
||||
],
|
||||
"lint": [
|
||||
"vendor/bin/pint --test"
|
||||
],
|
||||
"format": [
|
||||
"vendor/bin/pint"
|
||||
],
|
||||
"format:check": [
|
||||
"vendor/bin/pint --test"
|
||||
],
|
||||
"analyze": [
|
||||
"vendor/bin/phpstan analyse --memory-limit=512M"
|
||||
]
|
||||
},
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Appwrite Team",
|
||||
"email": "team@appwrite.io"
|
||||
}
|
||||
],
|
||||
"description": "Mutex, semaphore, file and distributed locks for PHP — one interface, four backends.",
|
||||
"support": {
|
||||
"source": "https://github.com/utopia-php/lock/tree/0.2.0",
|
||||
"issues": "https://github.com/utopia-php/lock/issues"
|
||||
},
|
||||
"time": "2026-04-24T10:47:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/logger",
|
||||
"version": "0.6.2",
|
||||
@@ -4530,16 +4602,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/migration",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/migration.git",
|
||||
"reference": "969dc9477ea962f16da9254facdbd8944cf13477"
|
||||
"reference": "952a4dfe232702f80e45c35129466a8d8cb4c599"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/migration/zipball/969dc9477ea962f16da9254facdbd8944cf13477",
|
||||
"reference": "969dc9477ea962f16da9254facdbd8944cf13477",
|
||||
"url": "https://api.github.com/repos/utopia-php/migration/zipball/952a4dfe232702f80e45c35129466a8d8cb4c599",
|
||||
"reference": "952a4dfe232702f80e45c35129466a8d8cb4c599",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4579,9 +4651,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/migration/issues",
|
||||
"source": "https://github.com/utopia-php/migration/tree/1.9.4"
|
||||
"source": "https://github.com/utopia-php/migration/tree/1.9.5"
|
||||
},
|
||||
"time": "2026-04-27T12:42:51+00:00"
|
||||
"time": "2026-04-29T11:19:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/mongo",
|
||||
@@ -5020,16 +5092,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/storage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/storage.git",
|
||||
"reference": "52d1f89a47165ef0d3deff63043cda182175adfb"
|
||||
"reference": "8a2e3a86fd01aaed675884146665308c2122264e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/storage/zipball/52d1f89a47165ef0d3deff63043cda182175adfb",
|
||||
"reference": "52d1f89a47165ef0d3deff63043cda182175adfb",
|
||||
"url": "https://api.github.com/repos/utopia-php/storage/zipball/8a2e3a86fd01aaed675884146665308c2122264e",
|
||||
"reference": "8a2e3a86fd01aaed675884146665308c2122264e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5066,9 +5138,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/storage/issues",
|
||||
"source": "https://github.com/utopia-php/storage/tree/2.0.0"
|
||||
"source": "https://github.com/utopia-php/storage/tree/2.0.1"
|
||||
},
|
||||
"time": "2026-04-27T11:39:32+00:00"
|
||||
"time": "2026-04-29T09:05:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/system",
|
||||
@@ -6221,11 +6293,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.52",
|
||||
"version": "2.1.54",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/08a34f8db7ca4daabff74a474fe13c0e56e2b4e5",
|
||||
"reference": "08a34f8db7ca4daabff74a474fe13c0e56e2b4e5",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||
"reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6270,7 +6342,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-28T12:17:53+00:00"
|
||||
"time": "2026-04-29T13:31:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
|
||||
@@ -212,6 +212,7 @@ services:
|
||||
- _APP_EXECUTOR_SECRET
|
||||
- _APP_EXECUTOR_HOST
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_LOCKING_ENABLED
|
||||
- _APP_MAINTENANCE_INTERVAL
|
||||
- _APP_MAINTENANCE_RETENTION_EXECUTION
|
||||
- _APP_MAINTENANCE_RETENTION_CACHE
|
||||
|
||||
@@ -45,6 +45,7 @@ class Exception extends \Exception
|
||||
public const string GENERAL_SERVICE_DISABLED = 'general_service_disabled';
|
||||
public const string GENERAL_UNAUTHORIZED_SCOPE = 'general_unauthorized_scope';
|
||||
public const string GENERAL_RATE_LIMIT_EXCEEDED = 'general_rate_limit_exceeded';
|
||||
public const string GENERAL_RESOURCE_LOCKED = 'general_resource_locked';
|
||||
public const string GENERAL_SMTP_DISABLED = 'general_smtp_disabled';
|
||||
public const string GENERAL_PHONE_DISABLED = 'general_phone_disabled';
|
||||
public const string GENERAL_ARGUMENT_INVALID = 'general_argument_invalid';
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Locking;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Closure;
|
||||
use Throwable;
|
||||
use Utopia\Console;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Lock\Distributed as DistributedLock;
|
||||
use Utopia\Logger\Log;
|
||||
use Utopia\Logger\Logger;
|
||||
use Utopia\System\System;
|
||||
use Utopia\Telemetry\Adapter as Telemetry;
|
||||
|
||||
final class Lock
|
||||
{
|
||||
private const SKIP_TTL_SECONDS = 5;
|
||||
|
||||
private const FAIL_TTL_SECONDS = 10;
|
||||
|
||||
private const FAIL_WAIT_SECONDS = 3.0;
|
||||
|
||||
private const REPORT_RATE_LIMIT_SECONDS = 60;
|
||||
|
||||
private const OUTCOME_ACQUIRED = 'acquired';
|
||||
|
||||
private const OUTCOME_SKIPPED = 'skipped';
|
||||
|
||||
private const OUTCOME_CONTENDED = 'contended';
|
||||
|
||||
private const OUTCOME_BACKEND_ERROR = 'backend_error';
|
||||
|
||||
private const OUTCOME_RELEASE_ERROR = 'release_error';
|
||||
|
||||
private readonly bool $enabled;
|
||||
|
||||
private readonly mixed $attempts;
|
||||
|
||||
private readonly string $projectInternalId;
|
||||
|
||||
/** @var array<string,int> */
|
||||
private static array $lastReportAt = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly \Redis $redis,
|
||||
Telemetry $telemetry,
|
||||
private readonly Database $dbForPlatform,
|
||||
private readonly Authorization $authorization,
|
||||
private readonly Log $log,
|
||||
private readonly ?Logger $logger,
|
||||
Document $project,
|
||||
) {
|
||||
$this->enabled = System::getEnv('_APP_LOCKING_ENABLED', 'enabled') !== 'disabled';
|
||||
$this->attempts = $telemetry->createCounter('lock.attempts', null, 'Distributed lock acquire outcomes');
|
||||
$sequence = $project->getSequence();
|
||||
$this->projectInternalId = ($sequence !== null && $sequence !== '') ? (string) $sequence : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled single-attribute write under a per-attribute skip-on-contention
|
||||
* lock with authorization bypass. For idempotent timestamp-style updates
|
||||
* (accessedAt, mcpAccessedAt) where regional pods writing the same value
|
||||
* would thrash the platform DB.
|
||||
*/
|
||||
public function set(
|
||||
string $collection,
|
||||
string $id,
|
||||
string $attribute,
|
||||
string $value,
|
||||
): void {
|
||||
$key = "lock:platform:{$this->projectInternalId}:{$collection}:{$id}:{$attribute}";
|
||||
$this->execute($key, $collection, function () use ($collection, $id, $attribute, $value) {
|
||||
$this->authorization->skip(fn () => $this->dbForPlatform->updateDocument(
|
||||
$collection,
|
||||
$id,
|
||||
new Document([$attribute => $value])
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip-on-contention lock around an arbitrary callback for a platform
|
||||
* document. For idempotent multi-statement writes that don't fit `set`.
|
||||
*/
|
||||
public function run(string $collection, string $id, Closure $fn): void
|
||||
{
|
||||
$key = "lock:platform:{$this->projectInternalId}:{$collection}:{$id}";
|
||||
$this->execute($key, $collection, $fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block-then-409 lock around an arbitrary callback for a platform document.
|
||||
* For read-modify-write endpoints where silently dropping a concurrent
|
||||
* request would lose user data.
|
||||
*/
|
||||
public function runOrFail(string $collection, string $id, Closure $fn): mixed
|
||||
{
|
||||
$key = "lock:platform:{$this->projectInternalId}:{$collection}:{$id}";
|
||||
|
||||
return $this->execute($key, $collection, $fn, ttl: self::FAIL_TTL_SECONDS, orFail: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic lock primitive with full control over key, TTL, contention
|
||||
* behavior, and wait timeout. Escape hatch for non-platform keys
|
||||
* (cache, queue, edge) and for unusual TTL/timeout requirements.
|
||||
*
|
||||
* Caller may pass `target` for telemetry; otherwise it's extracted by
|
||||
* position from the key (best-effort for keys following the standard
|
||||
* `lock:platform:{project}:{target}:...` shape).
|
||||
*/
|
||||
public function withKey(
|
||||
string $key,
|
||||
Closure $fn,
|
||||
int $ttl = self::SKIP_TTL_SECONDS,
|
||||
bool $orFail = false,
|
||||
float $waitTimeout = self::FAIL_WAIT_SECONDS,
|
||||
?string $target = null,
|
||||
): mixed {
|
||||
return $this->execute(
|
||||
$key,
|
||||
$target ?? self::inferTargetFromKey($key),
|
||||
$fn,
|
||||
ttl: $ttl,
|
||||
orFail: $orFail,
|
||||
waitTimeout: $waitTimeout,
|
||||
);
|
||||
}
|
||||
|
||||
private function execute(
|
||||
string $key,
|
||||
string $target,
|
||||
Closure $fn,
|
||||
int $ttl = self::SKIP_TTL_SECONDS,
|
||||
bool $orFail = false,
|
||||
float $waitTimeout = self::FAIL_WAIT_SECONDS,
|
||||
): mixed {
|
||||
if (! $this->enabled) {
|
||||
return $fn();
|
||||
}
|
||||
|
||||
$lock = new DistributedLock($this->redis, $key, $ttl);
|
||||
$labels = ['target' => $target, 'project' => $this->projectInternalId];
|
||||
|
||||
try {
|
||||
$acquired = $orFail ? $lock->acquire($waitTimeout) : $lock->tryAcquire();
|
||||
} catch (\RedisException $e) {
|
||||
$this->attempts->add(1, ['outcome' => self::OUTCOME_BACKEND_ERROR, ...$labels]);
|
||||
$this->reportError(self::OUTCOME_BACKEND_ERROR, $key, $target, $e);
|
||||
|
||||
return $fn();
|
||||
}
|
||||
|
||||
if (! $acquired) {
|
||||
if ($orFail) {
|
||||
$this->attempts->add(1, ['outcome' => self::OUTCOME_CONTENDED, ...$labels]);
|
||||
// No custom message — the lock key embeds collection + document id.
|
||||
throw new Exception(Exception::GENERAL_RESOURCE_LOCKED);
|
||||
}
|
||||
$this->attempts->add(1, ['outcome' => self::OUTCOME_SKIPPED, ...$labels]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->attempts->add(1, ['outcome' => self::OUTCOME_ACQUIRED, ...$labels]);
|
||||
try {
|
||||
return $fn();
|
||||
} finally {
|
||||
try {
|
||||
$lock->release();
|
||||
} catch (Throwable $e) {
|
||||
$this->attempts->add(1, ['outcome' => self::OUTCOME_RELEASE_ERROR, ...$labels]);
|
||||
$this->reportError(self::OUTCOME_RELEASE_ERROR, $key, $target, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort target extraction for telemetry. Assumes the standard
|
||||
* `lock:platform:{project}:{target}:...` shape. For non-platform keys
|
||||
* passed via withKey(), callers should pass `target` explicitly.
|
||||
*/
|
||||
private static function inferTargetFromKey(string $key): string
|
||||
{
|
||||
$parts = explode(':', $key, 5);
|
||||
|
||||
return $parts[3] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate-limited to one push per REPORT_RATE_LIMIT_SECONDS per (action, target)
|
||||
* so a sustained backend outage doesn't flood Sentry across the pod fleet.
|
||||
*/
|
||||
private function reportError(string $action, string $key, string $target, Throwable $e): void
|
||||
{
|
||||
Console::warning("Lock {$action} for {$key}: {$e->getMessage()}");
|
||||
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bucket = $action.':'.$target;
|
||||
$now = time();
|
||||
if ((self::$lastReportAt[$bucket] ?? 0) + self::REPORT_RATE_LIMIT_SECONDS > $now) {
|
||||
return;
|
||||
}
|
||||
self::$lastReportAt[$bucket] = $now;
|
||||
|
||||
$this->log->setNamespace('http');
|
||||
$this->log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
|
||||
$this->log->setVersion(APP_VERSION_STABLE);
|
||||
$this->log->setType(Log::TYPE_WARNING);
|
||||
$this->log->setMessage('Distributed lock '.$action.': '.$e->getMessage());
|
||||
$this->log->setAction("lock.{$action}");
|
||||
$this->log->setEnvironment(System::getEnv('_APP_ENV', 'development') === 'production'
|
||||
? Log::ENVIRONMENT_PRODUCTION
|
||||
: Log::ENVIRONMENT_STAGING);
|
||||
$this->log->addTag('lock.target', $target);
|
||||
$this->log->addTag('lock.project', $this->projectInternalId);
|
||||
// Strip trailing document ID to keep aggregator cardinality bounded.
|
||||
$this->log->addTag('lock.key_pattern', preg_replace('/:[^:]+$/', ':*', $key));
|
||||
$this->log->addTag('code', $e->getCode());
|
||||
$this->log->addExtra('file', $e->getFile());
|
||||
$this->log->addExtra('line', $e->getLine());
|
||||
$this->log->addExtra('trace', $e->getTraceAsString());
|
||||
|
||||
try {
|
||||
$this->logger->addLog($this->log);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Locking;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Locking\Lock;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Redis;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Logger\Log;
|
||||
use Utopia\Telemetry\Adapter\None as NoTelemetry;
|
||||
|
||||
class LockTest extends TestCase
|
||||
{
|
||||
private Redis $redis;
|
||||
|
||||
private Document $project;
|
||||
|
||||
private Authorization $authorization;
|
||||
|
||||
private Log $log;
|
||||
|
||||
/**
|
||||
* Project sequence used for every test; lock keys are scoped under it
|
||||
* so cleanup is bounded.
|
||||
*/
|
||||
private const PROJECT_SEQUENCE = '42';
|
||||
|
||||
private const KEY_PREFIX = 'lock:platform:'.self::PROJECT_SEQUENCE.':';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$host = \getenv('_APP_REDIS_HOST') ?: 'redis';
|
||||
$port = (int) (\getenv('_APP_REDIS_PORT') ?: 6379);
|
||||
|
||||
$this->redis = new Redis();
|
||||
$this->redis->connect($host, $port, 1.0);
|
||||
|
||||
$this->project = new Document([
|
||||
'$id' => 'test-project',
|
||||
'$sequence' => self::PROJECT_SEQUENCE,
|
||||
]);
|
||||
$this->authorization = new Authorization();
|
||||
$this->log = new Log();
|
||||
|
||||
$this->cleanupKeys();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->redis) && $this->redis->isConnected()) {
|
||||
$this->cleanupKeys();
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupKeys(): void
|
||||
{
|
||||
foreach ($this->redis->keys(self::KEY_PREFIX.'*') as $key) {
|
||||
$this->redis->del($key);
|
||||
}
|
||||
// also clean keys produced by withKey() tests
|
||||
foreach ($this->redis->keys('lock:test:*') as $key) {
|
||||
$this->redis->del($key);
|
||||
}
|
||||
}
|
||||
|
||||
private function makeLock(?Database $db = null, ?Authorization $auth = null): Lock
|
||||
{
|
||||
return new Lock(
|
||||
$this->redis,
|
||||
new NoTelemetry(),
|
||||
$db ?? $this->createStub(Database::class),
|
||||
$auth ?? $this->authorization,
|
||||
$this->log,
|
||||
null,
|
||||
$this->project,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_set_uses_per_attribute_key_and_auth_skipped_update(): void
|
||||
{
|
||||
$captured = null;
|
||||
$db = $this->createMock(Database::class);
|
||||
$db->expects($this->once())
|
||||
->method('updateDocument')
|
||||
->with('projects', 'p1', $this->callback(function (Document $doc) use (&$captured) {
|
||||
$captured = $doc->getArrayCopy();
|
||||
|
||||
return true;
|
||||
}))
|
||||
->willReturnArgument(2);
|
||||
|
||||
$lock = $this->makeLock($db);
|
||||
$lock->set('projects', 'p1', 'accessedAt', '2024-06-01 12:00:00');
|
||||
|
||||
$this->assertSame(['accessedAt' => '2024-06-01 12:00:00'], $captured);
|
||||
$this->assertSame(0, $this->redis->exists(self::KEY_PREFIX.'projects:p1:accessedAt'));
|
||||
}
|
||||
|
||||
public function test_set_skips_on_contention(): void
|
||||
{
|
||||
$key = self::KEY_PREFIX.'projects:p1:accessedAt';
|
||||
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
|
||||
|
||||
$db = $this->createMock(Database::class);
|
||||
$db->expects($this->never())->method('updateDocument');
|
||||
|
||||
$lock = $this->makeLock($db);
|
||||
$lock->set('projects', 'p1', 'accessedAt', '2024-06-01 12:00:00');
|
||||
|
||||
$this->assertSame('other-owner', $this->redis->get($key));
|
||||
}
|
||||
|
||||
public function test_set_different_attributes_do_not_compete(): void
|
||||
{
|
||||
// Hold accessedAt
|
||||
$heldKey = self::KEY_PREFIX.'projects:p1:accessedAt';
|
||||
$this->redis->set($heldKey, 'other-owner', ['NX', 'EX' => 30]);
|
||||
|
||||
// mcpAccessedAt should still be acquirable
|
||||
$db = $this->createMock(Database::class);
|
||||
$db->expects($this->once())
|
||||
->method('updateDocument')
|
||||
->with('projects', 'p1', $this->isInstanceOf(Document::class))
|
||||
->willReturnArgument(2);
|
||||
|
||||
$lock = $this->makeLock($db);
|
||||
$lock->set('projects', 'p1', 'mcpAccessedAt', '2024-06-01 12:00:00');
|
||||
|
||||
$this->assertSame('other-owner', $this->redis->get($heldKey));
|
||||
}
|
||||
|
||||
public function test_run_uses_per_document_key_and_invokes_callback(): void
|
||||
{
|
||||
$called = false;
|
||||
$lock = $this->makeLock();
|
||||
$lock->run('keys', 'k1', function () use (&$called) {
|
||||
$called = true;
|
||||
});
|
||||
|
||||
$this->assertTrue($called);
|
||||
$this->assertSame(0, $this->redis->exists(self::KEY_PREFIX.'keys:k1'));
|
||||
}
|
||||
|
||||
public function test_run_skips_on_contention(): void
|
||||
{
|
||||
$key = self::KEY_PREFIX.'keys:k1';
|
||||
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
|
||||
|
||||
$called = false;
|
||||
$lock = $this->makeLock();
|
||||
$lock->run('keys', 'k1', function () use (&$called) {
|
||||
$called = true;
|
||||
});
|
||||
|
||||
$this->assertFalse($called);
|
||||
$this->assertSame('other-owner', $this->redis->get($key));
|
||||
}
|
||||
|
||||
public function test_run_or_fail_throws_on_contention(): void
|
||||
{
|
||||
$key = self::KEY_PREFIX.'projects:p1';
|
||||
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
|
||||
|
||||
$lock = $this->makeLock();
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
try {
|
||||
$lock->runOrFail('projects', 'p1', fn () => 'never-runs');
|
||||
} catch (Exception $e) {
|
||||
$this->assertSame(Exception::GENERAL_RESOURCE_LOCKED, $e->getType());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function test_run_or_fail_returns_callback_value_when_uncontended(): void
|
||||
{
|
||||
$lock = $this->makeLock();
|
||||
$result = $lock->runOrFail('projects', 'p1', fn () => 'ok');
|
||||
|
||||
$this->assertSame('ok', $result);
|
||||
$this->assertSame(0, $this->redis->exists(self::KEY_PREFIX.'projects:p1'));
|
||||
}
|
||||
|
||||
public function test_with_key_uses_raw_key(): void
|
||||
{
|
||||
$custom = 'lock:test:custom-key';
|
||||
$called = false;
|
||||
$lock = $this->makeLock();
|
||||
$lock->withKey($custom, function () use (&$called) {
|
||||
$called = true;
|
||||
});
|
||||
|
||||
$this->assertTrue($called);
|
||||
$this->assertSame(0, $this->redis->exists($custom));
|
||||
}
|
||||
|
||||
public function test_with_key_or_fail_flag_throws_on_contention(): void
|
||||
{
|
||||
$custom = 'lock:test:contended';
|
||||
$this->redis->set($custom, 'other', ['NX', 'EX' => 30]);
|
||||
|
||||
$lock = $this->makeLock();
|
||||
$this->expectException(Exception::class);
|
||||
$lock->withKey($custom, fn () => null, ttl: 5, orFail: true, waitTimeout: 0.1);
|
||||
}
|
||||
|
||||
public function test_disabled_mode_runs_callback_unlocked(): void
|
||||
{
|
||||
$previous = \getenv('_APP_LOCKING_ENABLED');
|
||||
\putenv('_APP_LOCKING_ENABLED=disabled');
|
||||
try {
|
||||
// Even when the key is already held, the callback must still run.
|
||||
$key = self::KEY_PREFIX.'keys:k1';
|
||||
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
|
||||
|
||||
$called = false;
|
||||
$lock = $this->makeLock();
|
||||
$lock->run('keys', 'k1', function () use (&$called) {
|
||||
$called = true;
|
||||
});
|
||||
|
||||
$this->assertTrue($called);
|
||||
$this->assertSame('other-owner', $this->redis->get($key));
|
||||
} finally {
|
||||
$previous === false ? \putenv('_APP_LOCKING_ENABLED') : \putenv('_APP_LOCKING_ENABLED='.$previous);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_project_without_sequence_falls_back_to_unknown(): void
|
||||
{
|
||||
$emptyProject = new Document();
|
||||
$lock = new Lock(
|
||||
$this->redis,
|
||||
new NoTelemetry(),
|
||||
$this->createStub(Database::class),
|
||||
$this->authorization,
|
||||
$this->log,
|
||||
null,
|
||||
$emptyProject,
|
||||
);
|
||||
|
||||
// Pre-acquire the lock at the 'unknown' projectInternalId path.
|
||||
$key = 'lock:platform:unknown:keys:k1';
|
||||
$this->redis->set($key, 'held', ['NX', 'EX' => 30]);
|
||||
|
||||
$called = false;
|
||||
$lock->run('keys', 'k1', function () use (&$called) {
|
||||
$called = true;
|
||||
});
|
||||
$this->assertFalse($called, 'Lock without project sequence should hash to the unknown bucket');
|
||||
|
||||
$this->redis->del($key);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user