Commit Graph
34120 Commits
Author SHA1 Message Date
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
ArnabChatterjee20k 1b8123bf62 Merge pull request #11992 from appwrite/realtime-logs
added missing include for exporter in the realtime
2026-04-24 16:45:28 +05:30
ArnabChatterjee20k 1ca75c73df Merge branch '1.9.x' into realtime-logs 2026-04-24 16:35:25 +05:30
ArnabChatterjee20k 0633662695 removed dispatch experiment 2026-04-24 16:22:57 +05:30
ArnabChatterjee20k 89819db775 added exporter 2026-04-24 16:12:42 +05:30
Matej Bačo 29b700d1ec Merge pull request #11981 from appwrite/feat-public-list-endpoints
Feat: Project public list endpoints
2026-04-24 10:08:23 +02:00
Matej Bačo e3231393b9 Fix anayser 2026-04-23 16:06:45 +02:00
Matej Bačo 5beeca5a99 Placeholder test 2026-04-23 15:57:09 +02:00
Matej Bačo 4de3009f67 Fix analyser 2026-04-23 15:36:16 +02:00
Matej Bačo 4b3963512c Linter fix 2026-04-23 15:28:20 +02:00
Matej Bačo 8c634a95e4 Fix failing tests 2026-04-23 15:28:10 +02:00
Matej Bačo 7a3c001452 Re-add project removal tests 2026-04-23 15:22:40 +02:00
Matej Bačo a48fd13ced Add getPolicy + tests + move wrongly placed project tests 2026-04-23 15:19:49 +02:00
Matej Bačo 9c6ed9565e Remove tests of removed endpoints 2026-04-23 14:07:58 +02:00
Matej Bačo bdbc5b92df Fix after code review 2026-04-23 13:47:31 +02:00
Matej Bačo c246fb0f83 Project deletion tests 2026-04-23 13:41:11 +02:00
Matej Bačo a0a3849b16 Remove unsupported bulk endpoints 2026-04-23 13:37:32 +02:00
Matej Bačo b99139661e Migrate delete project endpoint 2026-04-23 13:37:19 +02:00
Matej Bačo 6d86b8fd0d Removal of project JWTs 2026-04-23 13:25:21 +02:00
Matej Bačo cef7a5197f List policies API 2026-04-23 13:24:39 +02:00
Matej Bačo c1dfeae323 Add queries to email tempaltes list 2026-04-23 13:06:05 +02:00
Matej Bačo 51fa0770a6 Add queries to mock numbers list 2026-04-23 12:43:45 +02:00
Matej Bačo d46403507c Merge pull request #11979 from appwrite/fix-membership-privacy
Fix: membership privacy bug on production
2026-04-23 10:47:54 +02:00
Matej Bačo 83724ce96f Console membership privacy test coverage 2026-04-23 10:37:35 +02:00
Matej Bačo 34930e6d67 Merge branch '1.9.x' into fix-membership-privacy 2026-04-23 10:18:32 +02:00
Matej Bačo 096f8041fd Merge pull request #11970 from appwrite/feat-mocks-public-api
Feat: Public mock phone APIs
2026-04-23 10:18:14 +02:00
Matej Bačo 9dad7cef9e Merge branch '1.9.x' into feat-mocks-public-api 2026-04-23 10:17:32 +02:00
Matej Bačo 9e23867f0a Merge pull request #11976 from appwrite/feat-auth-methods-api
Feat: Auth methods public API
2026-04-23 10:14:34 +02:00
Matej Bačoandgreptile-apps[bot] 48353faa9b Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-23 10:13:01 +02:00
Matej Bačo c36b8fbabf Fix membershiip privacy bug on production 2026-04-23 10:07:32 +02:00
Luke B. Silver 5a5cb1e74e Merge pull request #11977 from appwrite/perf/v20-investigation
perf: memoize request filter chain and V20 schema lookups
2026-04-23 07:38:03 +01:00
Chirag Aggarwal a8e6b1b683 Merge pull request #11963 from appwrite/chore/http-benchmark-comparison 2026-04-23 09:48:37 +05:30
Matej Bačo b0939b92c3 Fix failing account tests 2026-04-22 17:02:22 +02:00
Chirag Aggarwal 3d66078fe9 Increase benchmark iterations 2026-04-22 19:46:27 +05:30
Chirag Aggarwal 9a6a597710 Address benchmark hardening review 2026-04-22 19:38:48 +05:30
Chirag Aggarwal c15e8d0126 Harden benchmark failure guard 2026-04-22 19:30:01 +05:30
Chirag Aggarwal 7b25d778d4 Trim benchmark scenarios 2026-04-22 19:21:51 +05:30
loks0n 3283b0bec0 perf: memoize request filter chain and V20 schema lookups
A phpspy profile of a production databases worker showed the V20
backwards-compat request filter accounting for ~40% of in-request
samples on `databases.listDocuments` traffic. Two compounding causes:

1. `Request::getParams()` re-ran the entire filter chain on every
   invocation. The framework and app call `getParams()` several times
   per request (route param binding, `cacheIdentifier()`, action
   injection, logging), so V20's recursive schema walk executed N times
   with identical inputs.
2. Inside `V20::getRelatedCollectionKeys`, the `databases/$databaseId`
   document was fetched at every recursion frame (up to
   RELATION_MAX_DEPTH = 3), and sibling relationships pointing at the
   same related collection each did their own `getDocument` call.

This commit:

- Memoizes the post-filter params on `Request`. The cache is
  invalidated by `addFilter`, `resetFilters`, and `setRoute`. `Request`
  is constructed per HTTP request (app/http.php), so the memo is
  naturally request-scoped. Helps every request filter version, not
  just V20.
- Splits V20's walk into an entry point that resolves the database
  namespace once and a pure recursive helper.
- Caches the collection `attributes` array per
  `(databaseNamespace, collectionId)` on the filter instance, so shared
  related collections collapse to one `getDocument` call. Missing or
  errored lookups are cached as `null` to avoid retry storms.
2026-04-22 14:49:13 +01:00
ArnabChatterjee20k 49f8d6e89c Merge pull request #11971 from appwrite/realtime-logs
Structured Logging
2026-04-22 19:10:36 +05:30
Matej Bačo a0274a7b6f Fix failing tests 2026-04-22 15:12:58 +02:00
Chirag Aggarwal 277a09847e Merge pull request #11975 from appwrite/t3code/database-tables-retry-fix
[codex] Stabilize database e2e CI retries
2026-04-22 18:34:29 +05:30
Chirag Aggarwal cdc5654c26 Merge branch '1.9.x' into chore/http-benchmark-comparison 2026-04-22 18:24:50 +05:30
Matej Bačo bb4fdefee7 New tests for auth methods (base + integration) 2026-04-22 14:51:10 +02:00