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).
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.
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.
Redis stringifies scalars on save, so on a cache hit the `total` field
was served as a string. Flutter SDK (and any strictly-typed client) then
failed with `TypeError: "37": type 'String' is not a subtype of type 'int'`.
The cache-miss path returned an int from `count()`, which is why only
repeat requests with `ttl > 0` tripped the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>