Compare commits

...
Author SHA1 Message Date
Prem Palanisamy e0ec28f02a revert: drop $log clone and sdks in-lock re-read
Both were defensive against rare edge cases the reviewer flagged but
which don't justify the complexity:

- $log clone: protects against tag pollution on the per-request Log
  if reportError fires AND the request later errors. http.php's
  request-end handler overwrites the core fields (namespace, message,
  action, etc.) anyway; only addTag/addExtra accumulate. Aligns with
  Embeddings/Text/Create.php precedent which mutates $log directly.

- sdks in-lock re-read: closes a sequential-acquire stale-read race on
  the sdks append-list. The race exists but the impact is bounded —
  one SDK registration delayed until the next request from that SDK
  fires. Self-healing on retry. The codebase already accepts this
  exact race for auths, oAuthProviders, services, identities, sessions,
  factors, etc. Special-casing this one site is precision the analytics
  use case doesn't need.
2026-04-30 08:24:56 +01:00
Prem Palanisamy 18f3280bad refactor(lock): extract magic numbers and outcome strings, fix target parse
- TTL defaults (5s skip / 10s fail), 3s wait timeout, and 60s sentry
  rate-limit window are now class constants.
- Telemetry outcome labels (acquired/skipped/contended/backend_error/
  release_error) are class constants — typo-safe across the 9 use sites.
- Fix: inferTargetFromKey was indexing into segment 2 with limit 4,
  which returned the project sequence instead of the collection name
  ever since project-id was added to the key in c2a249c48b. Telemetry
  was tagging 'target' with project sequences instead of 'projects' /
  'users' / etc. Now indexes segment 3 with limit 5 to correctly pick
  the collection segment from lock:platform:{project}:{target}:...
2026-04-30 07:48:04 +01:00
Prem Palanisamy bae328efa1 revert: keep narrow RedisException catch on lock acquire
Reverts the catch widening from earlier — the underlying
Utopia\Lock\Distributed::tryAcquire only calls ext-redis methods,
which throw RedisException exclusively. Catching Throwable was
over-broad and would silently swallow real bugs (TypeError, Error)
into a fail-open path.
2026-04-30 07:42:59 +01:00
Prem Palanisamyandgreptile-apps[bot] 03575e68c4 fix(lock): re-read keys inside lock body to avoid sdks append loss
The sdks attribute is an append-only list, not idempotent. With the
read happening outside the lock, two sequential acquirers could each
read the same stale list and overwrite each other's appends.

Now the lock body re-reads the keys document and re-derives the sdks
array from the fresh state. Skip-on-contention still drops the update
when the lock is held, but a same-SDK retry on the next request picks
the registration up.

Bounded loss only affects the rare 'first-seen' SDK request that
happens to land while the lock is held; sequential traffic from the
same SDK (or any later request from any SDK) re-attempts and writes.

Co-authored-by: greptile-apps[bot]
2026-04-30 07:31:15 +01:00
Prem Palanisamy 2762e84974 fix(lock): broaden acquire catch + clone Log before reportError
Two P1s from review:

- Acquire path caught only \RedisException; any other Throwable from the
  lock library would escape and skip the fail-open callback. Widened to
  \Throwable so any backend failure falls back to running unlocked.
- reportError mutated the per-request $log directly, leaving it in a
  'lock.{action}' state that the request-end handler would then submit
  as the request log. Clones $log into a dedicated $lockLog so the
  shared instance is untouched.
2026-04-30 07:26:45 +01:00
Prem Palanisamy 7b2f6ac692 fix: add explicit parens to new ClassName calls in LockTest (PSR-12) 2026-04-30 07:13:51 +01:00
Prem Palanisamy 542aac7fda Merge remote-tracking branch 'origin/1.9.x' into distributed-lock
# Conflicts:
#	composer.lock
2026-04-30 06:53:31 +01:00
Prem Palanisamy 7430de293e test: unit tests for Lock facade
Covers the four public methods (set, run, runOrFail, withKey) plus the
kill switch and the project-fallback behavior.

Tests against a real Redis (the appwrite container has it always-on);
no markTestSkipped fallback — the suite fails loudly if Redis is
unreachable rather than silently passing.
2026-04-30 06:05:50 +01:00
Prem Palanisamy 2f2a124a06 revert: redis resource cluster support + _APP_CONNECTIONS_CACHE fallback
Cloud production runs four separate single-master+replica Dragonfly
deployments (cache, queue-dragonfly, queue-usage, pubsub-dragonfly),
not sharded Redis Cluster topology — confirmed by deploy/cloud/values
+ environments/production/*.values.yaml (Dragonfly Operator with
replicas=2 = 1 primary + 1 read replica), and by the dev DSN scheme
'redis://' (not 'redis-cluster://').

So a standard \Redis client suffices for the direct redis resource
(timelimit, Lock). Cloud just needs to pass _APP_REDIS_HOST/PORT/USER/
PASS through to the appwrite container — handled in the cloud PR's
docker-compose.yml change.

This reverts the resource to its original pre-PR shape. The
utopia-php/lock cluster-support PR (utopia-php/lock#1) stays open at
upstream as a future-ready option if cloud ever moves to actual
Redis Cluster mode.
2026-04-29 16:39:36 +01:00
Prem Palanisamy c2a249c48b feat(lock): include project internal id in lock key + telemetry
Per-manager request, lock keys are now prefixed with the project's
internal id (sequence) so that:
  - Locks are partitioned by project — Redis cluster slot affinity
    if/when sharded.
  - Cross-project requests can't compete on the same key for
    collection-scoped resources.
  - Telemetry (counter + Sentry tags) carries 'project' alongside
    'target', so dashboards can filter contention by project.

Key shapes:
  set:        lock:platform:{project}:{collection}:{id}:{attribute}
  run/orFail: lock:platform:{project}:{collection}:{id}
  withKey:    raw (caller-provided)

Lock now requires a project document at construction. All existing
call sites (4 in CE + 2 in cloud) run inside Http::init()-resolved
request scope where the project document is set, so no migration
needed. Workers/CLI without project context can use withKey directly.
2026-04-29 11:26:18 +01:00
Prem Palanisamy b2b9ac5b4d fix: redis resource reads _APP_CONNECTIONS_CACHE with _APP_REDIS_* fallback
The dedicated \Redis DI resource (used by timelimit and the new Lock
class) was reading _APP_REDIS_HOST/PORT/PASS exclusively. Cloud
deployments configure cache via _APP_CONNECTIONS_CACHE URI form
(e.g. cache=redis://dragonfly:6379) and don't pass the legacy
_APP_REDIS_* vars to the appwrite container locally, so timelimit and
Lock both fail to connect outside production where Helm separately
injects the legacy vars.

Now prefers _APP_CONNECTIONS_CACHE when set (matching the cache pool
backend), falls back to _APP_REDIS_* for CE-style configs. No new env
vars introduced; both timelimit and Lock work in CE, cloud-local, and
cloud-production without compose changes.
2026-04-29 10:16:17 +01:00
Prem Palanisamy 18a67e00d3 fix: refresh composer.lock content-hash 2026-04-29 07:55:38 +01:00
Prem Palanisamy e634145612 refactor: consolidate lock implementation into Lock class
Lock now uses Utopia\Lock\Distributed directly and owns the full
acquire/release/telemetry/error-reporting/fail-open/kill-switch logic
that previously lived in two inline DI factory closures.

Adds withKey($key, $fn, $ttl, $orFail, $waitTimeout) as a generic
escape hatch for non-platform key shapes (cache, queue, edge) and
unusual TTL/timeout requirements.

Per-attribute lock keys for set() so that an accessedAt bump and a
mcpAccessedAt bump on the same projects:{id} document don't compete.
Whole-document operations (run, runOrFail) keep document-level keys.

Removes the standalone distributedLock and distributedLockOrFail DI
factories — Lock is the single API.

request.php shrinks ~150 LOC; Lock.php grows to ~190 LOC.
2026-04-29 07:41:54 +01:00
Prem Palanisamy ce15eeb722 refactor: introduce Lock facade for platform-DB lock sites
Extracts the lock-key format and the lock+auth-skip+sparse-update pattern
into Appwrite\Locking\Lock with three methods:
  - set(collection, id, attribute=accessedAt, value=null) — throttled
    single-attribute write
  - run(collection, id, fn) — generic skip-on-contention
  - runOrFail(collection, id, fn) — block-then-409 for the deferred
    lost-update follow-up

Migrates the 4 call sites (router projects accessedAt + 3 in shared/api)
off the raw $distributedLock callable. Raw factories stay as escape
hatches for non-platform key shapes.
2026-04-29 07:17:04 +01:00
Prem Palanisamy b15457bcca style: trim verbose comments on lock factories and call sites 2026-04-29 05:50:37 +01:00
Prem Palanisamy fce2abfd4c revert: scope distributed-lock PR to thundering-herd sites only
Drop the 18 lost-update endpoint locks (Project/* settings + Projects
team/update). Those address a different bug class (read-modify-write
races) than the manager-flagged production problem (regions slow queries
to platform from thundering herd on accessedAt writes).

Kept:
- distributedLock + distributedLockOrFail factories on the per-request
  container, GENERAL_RESOURCE_LOCKED exception, 409 mapping
- 4 thundering-herd sites: cache-invalidation in shared/api.php (3) +
  router projects.accessedAt in general.php (1)

Dropped:
- 18 endpoint OrFail wires
- testConcurrentTogglesAllPersist + Swoole-cURL test client patches
- dev/test-distributed-lock.sh smoke script
2026-04-29 05:31:18 +01:00
premtsd-codeandGitHub da5382d58a Merge branch '1.9.x' into distributed-lock 2026-04-29 06:34:56 +05:30
Prem Palanisamy 380cc3eb27 refactor: drop log/logger boilerplate from lock call sites
The previous shape required every caller to thread `log: $log, logger: $logger`
as named args into each `distributedLock(...)` invocation, plus inject `log`
and `logger` into the surrounding action just to forward them to the lock.
Across 21 call sites this added ~100 LOC of pure plumbing.

The cause: the lock factory was registered on the global container in
`app/init/resources.php`, where per-request resources like `log` aren't
visible. That forced the factory to expose its inner closure with optional
`?Log $log = null, ?Logger $logger = null` params, which every caller had
to satisfy.

Move the lock factory + its `lockErrorReporter`/`lockTargetOf` helpers from
the global container to the per-request container (`resources/request.php`),
and add `'log'` + `'logger'` to the factory's dep list. The factory closure
now runs per-request and closes over the per-request `Log`/`Logger`. Inner
closure returned to callers no longer needs the optional params, and call
sites drop the named args entirely.

Knock-on cleanup:
- Drop `->inject('log')`, `->inject('logger')`, the corresponding action
  params, and `use Utopia\Logger\{Log,Logger}` imports from 19 endpoint
  files where they were only there for the lock
- Drop the same plumbing from `app/controllers/shared/api.php` (3 lock call
  sites)
- Drop just the Logger plumbing from `app/controllers/general.php` (router
  function + 3 callbacks); `Log` is kept because it's used elsewhere in
  that file
- Net 120 LOC removed across 23 files

No behavior change: the lock factories still produce the same closures
(skip-on-contention `distributedLock`, blocking-with-409 `distributedLockOrFail`).
The static lockErrorReporter rate limiter (1 push per 60s per
`(action, target)` bucket) continues to work — it lives on a closure-static
in the helper, which is independent of where the helper is constructed.

Verified end-to-end: testConcurrentTogglesAllPersist passes 4/5 (the cold-
start race flake is the same one we've consistently seen and is orthogonal
to lock changes).
2026-04-29 02:02:28 +01:00
Prem Palanisamy b29f9f4a45 feat: distributed lock on router projects.accessedAt RMW
Every request that arrives via a custom-domain rule (router path) reads
the project's `accessedAt` timestamp and, if the throttle window
(`APP_PROJECT_ACCESS`) has elapsed, writes a fresh value. With concurrent
traffic across multiple pods, this is a per-row hot RMW that loses
updates silently — the surviving timestamp depends on which pod's write
landed last.

Wrap the read-modify-write in `distributedLock('lock:platform:projects:{id}')`
(skip-on-contention variant). Every concurrent pod would write the same
throttled value, so losing the race is correct: the winner's update covers
ours.

Wires `distributedLock` and `?Logger` through:
  - `router()` function signature (app/controllers/general.php:70)
  - the three Http::init / Http::get callbacks that invoke router():
    `*` catch-all (init), `/robots.txt`, `/humans.txt`

Two related cloud-only RMW sites (`teams.accessedAt`,
`projects.mcpAccessedAt`) live in `appwrite-labs/cloud` and need a
follow-up PR there. They depend on this branch reaching 1.9.x so the
`distributedLock` DI resource is available downstream.
2026-04-29 01:36:38 +01:00
Prem Palanisamy 29a0d6c2bf feat: distributed locks on all platform-projects-doc writes
The pilot on `Project/Services/Update.php` (commit fb0d43daf3) fixed the
silent lost-update on the `services` JSON map. The same read-modify-write
pattern exists on 17 other endpoints — all writing to the SAME `projects`
document, mostly via sparse `updateDocument`. Concurrent writes silently
overwrite each other, both within an endpoint (two toggles to different
auth methods) and across endpoints (parallel SMTP update + service toggle).

The worst offender is `Projects/Update.php` (`PATCH /v1/projects/:projectId`):
it calls `setAttribute` 11 times then writes the WHOLE document back, so a
concurrent write to *any* attribute on the project doc — services, auths,
smtp, templates, mockNumbers — gets clobbered.

Wrap every read-modify-write window with `distributedLockOrFail` keyed on
`lock:platform:projects:{$projectId}`. The single shared key serializes
both same-endpoint and cross-endpoint races on the same project.

Endpoints now under the lock:

  Projects module (console-managed, plural):
    - Projects/Update                  (full doc setAttribute chain)
    - Projects/Team/Update             (sparse + computed permissions; cascade
                                        to installations/repositories/vcsComments
                                        runs after release — separate collections)

  Project module (current-project, singular):
    - AuthMethods/Update               (auths[authKey])
    - Protocols/Update                 (apis[protocolId])
    - SMTP/Update                      (smtp map; PHPMailer probe inside lock —
                                        10s default TTL covers Timeout=5)
    - Templates/Email/Update           (templates['email.X-locale'])
    - MockPhone/{Create,Delete,Update} (auths['mockNumbers'][])
    - Policies/MembershipPrivacy       (auths['memberships*'])
    - Policies/PasswordDictionary      (auths['passwordDictionary'])
    - Policies/PasswordHistory         (auths['passwordHistory'])
    - Policies/PasswordPersonalData    (auths['personalDataCheck'])
    - Policies/SessionAlert            (auths['sessionAlerts'])
    - Policies/SessionDuration         (auths['duration'])
    - Policies/SessionInvalidation     (auths['invalidateSessions'])
    - Policies/SessionLimit            (auths['maxSessions'])
    - Policies/UserLimit               (auths['limit'])

Each endpoint:
  1. Injects `distributedLockOrFail`, `log`, `logger`
  2. Re-reads the project document inside the lock so the baseline reflects
     any update that landed between request init and lock acquisition
  3. Throws `GENERAL_RESOURCE_LOCKED` (HTTP 409) on contention timeout

SMTP/Update and Templates/Email/Update additionally refactor variable-
variables (`${$key}`) to an explicit input array — PHPStan can't trace
dynamic references through closure `use` clauses.
2026-04-28 14:13:31 +01:00
Prem Palanisamy 752df21007 refactor: switch distributed-lock backend to utopia-php/lock
`utopia-php/lock` v0.2.0 was published this week and provides the same
Redis SET-NX-EX + Lua-compare-and-delete primitive we built locally as
`premtsd-code/lock`. Drop the dev-preview package in favor of the
official Utopia PHP library.

- composer: replace `premtsd-code/lock` with `utopia-php/lock` 0.2.*
  (still via VCS — not on Packagist yet)
- resources.php: rewire both factory variants
  - `Lock + Adapter\Redis` → `Distributed`
  - `acquire()` → `tryAcquire()` for skip variant
  - `acquire(blocking: true, waitTimeout)` → `acquire($waitTimeout)` for
    OrFail variant
  - `LockAcquireException` → `\RedisException`
  - `(int) $ttl` cast — utopia-php/lock takes seconds as int
- docker-compose: thread `_APP_LOCKING_ENABLED` into the appwrite
  service environment so the kill switch documented in
  `app/config/variables.php` is actually usable from `.env`

Verified end-to-end on local stack:
- positive case (locking enabled): 5/5 testConcurrentTogglesAllPersist
  pass, lock keys observed in `redis-cli MONITOR` with concurrent SET
  NX contention
- negative case (locking disabled): 1/3 detect lost updates as before
2026-04-28 09:38:08 +01:00
Prem Palanisamy 92b5f0dcd6 feat: report lock backend/release errors to logger (Sentry/Raygun/etc.)
Lock backend errors (Redis/Dragonfly unreachable) and release errors
(TTL expired or backend dropped while held) were previously visible only
in the lock.attempts counter and Console::warning lines. They now also
push a structured Log entry through the configured logger adapter, so
operators using Sentry/Raygun/AppSignal/LogOwl get first-class events
for these specific failure modes.

Pattern matches Embeddings/Text/Create.php exactly:

  - Action injects 'log' (per-request Log object) and 'logger'
    (?Logger, nullable when _APP_LOGGING_CONFIG unset).
  - Helper mutates the per-request $log instead of constructing a
    fresh one — preserves the per-request context Embeddings expects.
  - Same field set: namespace='http', server, version, type,
    setMessage, setAction, setEnvironment, addTag('code', ...),
    addExtra('file' / 'line' / 'trace').
  - Defensive try/catch around addLog() so logging failures don't
    break fail-open.

Lock-specific tags added for slicing in Sentry:

  - lock.target — collection name (projects, keys, users, ...).
    Bounded set, safe for high-cardinality stores.
  - lock.key_pattern — full key with the trailing document ID
    stripped (lock:platform:projects:* not lock:platform:projects:abc).
    Prevents unbounded log cardinality from per-document IDs.

Rate limiting via per-pod static buckets, 60s window per
(action, target) combo. During a 5-minute Dragonfly outage, a fleet
of N pods produces at most N events/min, well within Sentry's dedup
tolerance. Static state is per-Swoole-worker; coroutines may race
on the bucket boundary but the worst case is one duplicate report.

Type level set to Log::TYPE_WARNING (not ERROR): fail-open means the
request still succeeds, so this is degraded operation, not a failed
request.

Deliberately NOT reported to Sentry:

  - 409 GENERAL_RESOURCE_LOCKED (normal user-facing concurrency)
  - skip-on-contention events (idempotent fan-out by design)
  - acquire retry conflicts (internal loop)
  - destructor cleanups (have an expected baseline rate; the
    lock.attempts counter aggregates them better than Sentry would)

Factory signature change: distributedLock and distributedLockOrFail
now accept ?Log and ?Logger as optional named args at call time
(rather than capturing Logger at factory-build time). The factory
closure runs once at boot but the per-request Log resource is
fresh per request — capturing at boot would have given stale state.
Existing call sites threaded log: $log, logger: $logger. Sites that
don't (workers, CLI tasks) get null and just log to Console as
before.
2026-04-27 17:25:31 +01:00
Prem Palanisamy 77982f4b90 test: concurrency proof for distributedLockOrFail pilot
Two complementary tests for the lost-update bug the lock fixes, plus
the test-Client patches needed to make Swoole-coroutine HTTP work.

- tests/e2e/Services/Project/ServicesBase.php :: testConcurrentTogglesAllPersist
  Fires N parallel PATCHes via Swoole\Coroutine\run + SWOOLE_HOOK_CURL,
  then refetches the project and asserts (successCount == enabledCount).
  With the lock disabled (_APP_LOCKING_ENABLED=disabled) sparse
  updateDocument() calls overwrite each other and the assertion fails —
  proving the test detects the bug.

- dev/test-distributed-lock.sh
  Same proof via curl + bash background jobs. Runnable outside the
  PHPUnit suite for manual verification or load-style sweeps. Reads
  APPWRITE_ENDPOINT / APPWRITE_PROJECT_ID / APPWRITE_API_KEY.

- tests/e2e/Client.php (test util):
    * Skip CURLOPT_PATH_AS_IS (option 234) when SWOOLE_HOOK_CURL is
      active. Swoole's emulated cURL doesn't support it; setting it
      fatal-errors as soon as any test enables the cURL hook.
    * Don't redundantly set CURLOPT_NOBODY=false on non-HEAD requests
      (false is cURL's default). Under Swoole's hooked cURL this
      strips the body of PATCH/PUT requests, hitting the framework's
      404 fallback instead of the intended route.
  Both changes preserve native (non-hooked) cURL behavior unchanged.
  They unblock any future test that wants real parallel HTTP via
  Swoole\Coroutine\run + the cURL hook.

Both follow the same proof-of-bug pattern: run with locking enabled
(must pass) AND with it disabled (must fail). Verified locally against
the running stack:
  _APP_LOCKING_ENABLED=enabled  -> PASS  (15 assertions)
  _APP_LOCKING_ENABLED=disabled -> FAIL  (successCount=5 enabledCount=4)
2026-04-27 17:25:15 +01:00
Prem Palanisamy 784babcf45 fix: address Greptile review on PR #12062
Three P1 issues flagged on the initial commit:

1. Lock key in updateProjectService used "platform:project:{id}" —
   missing the "lock:" namespace prefix and using singular "project"
   instead of the conventional plural collection name. The factory's
   `lockTargetOf` extracts segment [2] as the telemetry target, so
   the broken key was emitting the project ID itself as the target
   attribute (cardinality blowup, broken dashboards). Fixed to
   "lock:platform:projects:{id}" matching the convention used in
   shared/api.php.

2. The 409 contention exception embedded the raw Redis lock key in
   its user-facing message, leaking internal collection names and
   the locking namespace to API clients. Removed the custom message
   so the catalog default ("The requested resource is currently
   being modified...") is used. Telemetry already carries the
   target collection for operator-side observability.

3. _APP_LOCKING_ENABLED variable doc had `introduction: '1.10.0'`
   on a 1.9.x-targeted PR. Corrected to '1.9.3' (next 1.9.x patch).
2026-04-27 17:24:50 +01:00
Prem Palanisamy fb0d43daf3 feat: distributed locking for platform-database writes
Adds two DI factories and wires them where coordination is needed:

  - distributedLock — skip on contention, void return. For idempotent
    fan-out where N pods doing the same write is wasteful but losing
    the race is correct.
  - distributedLockOrFail — blocking acquire (3s default) then throws
    GENERAL_RESOURCE_LOCKED (HTTP 409) on contention. For
    read-modify-write on shared mutable state where a silent skip
    would drop a user's change.

Both factories: _APP_LOCKING_ENABLED kill switch (set 'disabled' for
fail-open), fail-open on Redis-unreachable, and a lock.attempts
telemetry counter sliced by outcome and target collection.

Wired sites:
  - shared/api.php × 3 (distributedLock): keys.accessedAt + sdks,
    projects.accessedAt, users.accessedAt. Reduces redundant writes
    and cache-purge fan-out under request bursts on the same project.
  - Project/Services/Update.php × 1 (distributedLockOrFail): the
    services map toggle. Re-reads inside the lock so the baseline
    reflects concurrent updates. Two simultaneous toggles to
    different services no longer lose one of them.

Lock key namespace: lock:platform:{collection}:{id}.

Dep: premtsd-code/lock pinned to a specific commit as a development
preview. Migration to utopia-php/lock is a follow-up once that
package is published.
2026-04-27 07:54:56 +01:00
11 changed files with 660 additions and 64 deletions
+5
View File
@@ -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.',
+9
View File
@@ -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
View File
@@ -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);
}
}
+16 -16
View File
@@ -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'));
}
}
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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
+1
View File
@@ -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';
+235
View File
@@ -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) {
}
}
}
+257
View File
@@ -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);
}
}