You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Authenticated data requests issue ~20 DB queries in ~17 sequential legs — per-request auth/session/localization/metadata resolution has no cross-request caching, costing ~1.5s/request on remote Postgres #10757
Found while root-causing objectstack-ai/cloud#1518 (control plane pays ~1.5s of server time on EVERY authenticated DB-touching request on prod, ~6s on staging, independent of query shape and response size). Traced at cloud's pin 0c24898c. The mechanism is entirely in framework packages, so per cloud's contract-first rule it is filed here rather than worked around in the consumer.
The measurement
Staging (OS_SERVER_TIMING=true) for GET /api/v1/data/sys_user_preference?$top=1:
handler ≈ total and db ≈ handler — the request is essentially all DB time. db > total is explained by the db mark being a per-query SUM (PerfTiming.count, packages/drivers/driver-sql/src/sql-driver.tsinstallQueryTiming) over queries that partly overlap (see the parallel settings reads below).
Where the queries come from — one authenticated GET /data/:object?$top=1 issues ~20 queries in ~17 sequential legs
Every leg is a Neon round trip. At the ~50–90ms/query this deployment measures, ~17 sequential legs are the ~1.5s.
A. better-auth getSession — no caching anywhere (5 queries):
sys_session + 2. sys_user (better-auth core; cookieCache is not configured — zero grep hits in plugin-auth);
sys_user_permission_set, 4. sys_permission_set (FULL list, limit 50), 5. sys_member — the customSession plugin re-derives platform-admin/org roles on EVERY getSession call (packages/plugins/plugin-auth/src/auth-manager.ts ~2898–2960).
C. Localization (3 queries, parallel):resolveLocalizationContext → 3× settings.get('localization', …) → loadRows is uncached by design (#10221 caches only FAILED reads), so 3 sys_setting reads per request.
packages/rest/src/rest-server.ts ~800 documents this whole block as "~16 sequential queries" and memoizes it per request (execCtxMemo) — but nothing caches it ACROSS requests, so every request pays it once.
D. Route handler (5–6 more):
enforceApiAccess → loadObjectItems → getMetaItems('object') ALWAYS queries sys_metadata (1–2: the empty result triggers the alt-type retry) — packages/metadata-protocol/src/protocol.ts ~5700;
plugin-security's engine middleware runs resolvePermissionSetsForContext → dbLoader (sys_permission_set {name $in}) with no cache, and it runs TWICE — once for the find, once for the COUNT (security-plugin.ts ~1351);
the data SELECT itself;
findData runs engine.count() whenever a limit is present (protocol.ts ~8858) — a second data query even for $top=1.
E. Zero-hint observation: the SqlDriver's tenancy auto-scoping itself issues no extra queries — but its INPUTS (accessible_org_ids, org_user_ids) are what B pays for, including the limit-1000 fellow-org read.
Consequences on a real deployment (cloud prod/staging)
~1.5s fixed server time per authenticated data request at ZERO concurrency (prod; ~6s staging where per-query RTT is ~300ms).
Requests being ~100% DB-bound makes the pg pool cap the request-concurrency cap: cloud measured a hard knee exactly past 10 concurrent (= its pool max), median doubling at 20. The Console home fires 27 API calls in one cold load.
Fix directions (each independently valuable, roughly by leverage)
Session caching: enable better-auth cookieCache (signed cookie, short TTL) or an in-process TTL cache keyed by session token — removes A entirely on warm requests.
customSession is redundant work on the data path: B re-derives everything A3–A5 derive, more completely. Consider gating the customSession enrichment to the endpoints that need it, or a short per-user TTL cache.
Cross-request TTL cache for resolveUserAuthzGrants keyed (userId, tenantId) with write-invalidation or a short TTL (grants change rarely; the framework already accepts 30s staleness for hostname routing).
Batch B's reads: steps 6–13 are ~8 sequential round trips that could be 2–3 batched/joined queries; the duplicates (A2/13, A3/9) are free wins.
Cache getMetaItems' sys_metadata read (short TTL / registry-epoch key), and skip the alt-type retry when the first read returned an empty-but-healthy result set.
Reuse the find's permission-set resolution for its count (same request, same context — the middleware resolves twice today).
$count=false fast path: honor the existing $count parameter to skip the COUNT for callers that don't need total (objectui's cold-load probes mostly don't).
Per-query evidence is directly obtainable on any deployment of current main by an admin: X-OS-Debug-Timing: json returns Server-Timing (db;dur=…;desc="N queries") plus X-OS-Debug-Timing-Detail (slowest parametrized statements) — rest-server.ts:1937 opens the disclosure gate for admin principals.
Found while root-causing objectstack-ai/cloud#1518 (control plane pays ~1.5s of server time on EVERY authenticated DB-touching request on prod, ~6s on staging, independent of query shape and response size). Traced at cloud's pin
0c24898c. The mechanism is entirely in framework packages, so per cloud's contract-first rule it is filed here rather than worked around in the consumer.The measurement
Staging (
OS_SERVER_TIMING=true) forGET /api/v1/data/sys_user_preference?$top=1:handler ≈ totalanddb ≈ handler— the request is essentially all DB time.db > totalis explained by thedbmark being a per-query SUM (PerfTiming.count,packages/drivers/driver-sql/src/sql-driver.tsinstallQueryTiming) over queries that partly overlap (see the parallel settings reads below).Where the queries come from — one authenticated
GET /data/:object?$top=1issues ~20 queries in ~17 sequential legsEvery leg is a Neon round trip. At the ~50–90ms/query this deployment measures, ~17 sequential legs are the ~1.5s.
A. better-auth
getSession— no caching anywhere (5 queries):sys_session+ 2.sys_user(better-auth core;cookieCacheis not configured — zero grep hits inplugin-auth);sys_user_permission_set, 4.sys_permission_set(FULL list, limit 50), 5.sys_member— thecustomSessionplugin re-derives platform-admin/org roles on EVERYgetSessioncall (packages/plugins/plugin-auth/src/auth-manager.ts~2898–2960).B.
resolveAuthzContext/resolveUserAuthzGrants(8 queries, sequential) (packages/core/src/security/resolve-authz-context.ts~330–560):6.
sys_member {user_id}; 7.sys_user_position; 8.sys_member {organization_id}(fellow-org, limit 1000); 9.sys_user_permission_set(duplicate of A3); 10.sys_position {name $in}; 11.sys_position_permission_set; 12.sys_permission_set {id $in}; 13.sys_user {id}forai_seat(duplicate of A2).C. Localization (3 queries, parallel):
resolveLocalizationContext→ 3×settings.get('localization', …)→loadRowsis uncached by design (#10221 caches only FAILED reads), so 3sys_settingreads per request.packages/rest/src/rest-server.ts~800 documents this whole block as "~16 sequential queries" and memoizes it per request (execCtxMemo) — but nothing caches it ACROSS requests, so every request pays it once.D. Route handler (5–6 more):
enforceApiAccess→loadObjectItems→getMetaItems('object')ALWAYS queriessys_metadata(1–2: the empty result triggers the alt-type retry) —packages/metadata-protocol/src/protocol.ts~5700;resolvePermissionSetsForContext→dbLoader(sys_permission_set {name $in}) with no cache, and it runs TWICE — once for the find, once for the COUNT (security-plugin.ts~1351);findDatarunsengine.count()whenever a limit is present (protocol.ts~8858) — a second data query even for$top=1.E. Zero-hint observation: the SqlDriver's tenancy auto-scoping itself issues no extra queries — but its INPUTS (
accessible_org_ids,org_user_ids) are what B pays for, including the limit-1000 fellow-org read.Consequences on a real deployment (cloud prod/staging)
Fix directions (each independently valuable, roughly by leverage)
cookieCache(signed cookie, short TTL) or an in-process TTL cache keyed by session token — removes A entirely on warm requests.customSessionis redundant work on the data path: B re-derives everything A3–A5 derive, more completely. Consider gating the customSession enrichment to the endpoints that need it, or a short per-user TTL cache.resolveUserAuthzGrantskeyed(userId, tenantId)with write-invalidation or a short TTL (grants change rarely; the framework already accepts 30s staleness for hostname routing).getMetaItems'sys_metadataread (short TTL / registry-epoch key), and skip the alt-type retry when the first read returned an empty-but-healthy result set.$count=falsefast path: honor the existing$countparameter to skip the COUNT for callers that don't needtotal(objectui's cold-load probes mostly don't).Per-query evidence is directly obtainable on any deployment of current main by an admin:
X-OS-Debug-Timing: jsonreturnsServer-Timing(db;dur=…;desc="N queries") plusX-OS-Debug-Timing-Detail(slowest parametrized statements) —rest-server.ts:1937opens the disclosure gate for admin principals.Generated by Claude Code