From 5934a5b9c31a143a147c0a180901a2a2de52db1b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:39:12 +0800 Subject: [PATCH] perf(core): request-scoped memoization for authenticated exec-context resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authenticated REST request resolves its execution context (identity + RBAC/RLS + localization) many times in one handler — the data op, app-nav RBAC filtering, dashboard widget gating, the ADR-0069 auth gate. Each resolveExecCtx pass is the full resolveAuthzContext aggregation plus the localization read (~16 sequential queries), and nothing memoized it, so a request that resolved twice paid for duplicate authz + repeated localization. - rest: memoize resolveExecCtx per request (WeakMap keyed by req + input environmentId; caches the in-flight Promise; heavy path moved to computeExecCtx). Anonymous resolutions cached too. - core: read sys_user at most once per resolveAuthzContext pass (email fallback + ai_seat synthesis shared a duplicate query on the API-key path); batch the localization direct-read fallback (timezone/locale/currency) into one sys_setting query ($in on key) instead of three sequential reads. No authorization-behavior change. sys_member reads (per-user vs all-org) left distinct on purpose (different filters/limits). Tests: query-counting regressions (sys_user once, localization once) + rest-server memo contract (per-request, per-environment, anonymous cached). Co-Authored-By: Claude Opus 4.8 --- .changeset/perf-execctx-request-memo.md | 31 +++++++++ .../security/resolve-authz-context.test.ts | 62 ++++++++++++++++- .../src/security/resolve-authz-context.ts | 39 ++++++++--- packages/rest/src/rest-exec-ctx-memo.test.ts | 67 +++++++++++++++++++ packages/rest/src/rest-server.ts | 32 +++++++++ 5 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 .changeset/perf-execctx-request-memo.md create mode 100644 packages/rest/src/rest-exec-ctx-memo.test.ts diff --git a/.changeset/perf-execctx-request-memo.md b/.changeset/perf-execctx-request-memo.md new file mode 100644 index 0000000000..cad1e58463 --- /dev/null +++ b/.changeset/perf-execctx-request-memo.md @@ -0,0 +1,31 @@ +--- +'@objectstack/core': patch +'@objectstack/rest': patch +--- + +perf(core): authenticated requests issued ~16 sequential queries — duplicate authz + repeated localization — now request-scoped memoized + +An authenticated REST request resolves its execution context (identity + +RBAC/RLS + localization) many times in a single handler — the data operation +itself, app-nav RBAC filtering, dashboard widget gating, the ADR-0069 auth gate. +Each `resolveExecCtx` pass is the full `resolveAuthzContext` aggregation plus the +localization read (~16 sequential queries), and nothing memoized it, so a request +that resolves twice paid for duplicate authz and repeated localization. + +- **`@objectstack/rest`** — `resolveExecCtx` is now memoized per request, keyed by + the request object (a `WeakMap`, so the entry is collected with the request — no + TTL, no cross-request leak) and the input `environmentId`. The in-flight Promise + is cached so concurrent callers share one resolution. The heavy path moved to + `computeExecCtx`. Anonymous (`undefined`) resolutions are cached too. +- **`@objectstack/core`** — within a single `resolveAuthzContext` pass, `sys_user` + is now read at most once (the email fallback and the `ai_seat` synthesis shared a + duplicate query on the API-key path); `resolveLocalizationContext`'s direct-read + fallback batches `timezone`/`locale`/`currency` into one `sys_setting` query + (`$in` on `key`) instead of three sequential reads. + +No authorization-behavior change — the same roles/permissions/RLS context is +resolved, just without the redundant reads. The `sys_member` reads (per-user roles +vs. all-org-members) are intentionally left distinct (different filters/limits). + +Tests: query-counting regressions assert `sys_user` reads once and localization +reads once; new rest-server tests pin the per-request/per-environment memo contract. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index e035be0dc7..ea0405173c 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { resolveAuthzContext } from './resolve-authz-context.js'; +import { resolveAuthzContext, resolveLocalizationContext } from './resolve-authz-context.js'; /** * Contract test for the SINGLE authorization resolver. Every authorization @@ -109,3 +109,63 @@ describe('resolveAuthzContext — single source of truth', () => { expect(ctx.permissions).toEqual([]); }); }); + +// A counting ObjectQL: records how many find() calls hit each object so we can +// assert the de-duplication of redundant authz/localization reads (#2409). +function makeCountingQl(tables: Record) { + const counts: Record = {}; + return { + counts, + async find(object: string, opts: any) { + counts[object] = (counts[object] ?? 0) + 1; + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + return r[k] === v; + }), + ); + }, + }; +} + +describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', () => { + it('reads sys_user at most once even when both email fallback and ai_seat need it', async () => { + // No email in the session → email fallback reads sys_user; ai_seat synthesis + // also needs sys_user. Previously these were two separate queries. + const ql = makeCountingQl({ + sys_user: [{ id: 'u1', email: 'ada@x.com', ai_access: 1 }], + sys_member: [], + sys_user_role: [], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + expect(ctx.email).toBe('ada@x.com'); + expect(ctx.permissions).toContain('ai_seat'); + expect(ql.counts.sys_user).toBe(1); + }); +}); + +describe('resolveLocalizationContext — batched fallback read (#2409)', () => { + it('reads sys_setting once (all three keys) when no settings service is wired', async () => { + const ql = makeCountingQl({ + sys_setting: [ + { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }, + { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }, + { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' }, + ], + }); + const loc = await resolveLocalizationContext({ ql, tenantId: 'o1' }); + expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' }); + expect(ql.counts.sys_setting).toBe(1); + }); + + it('falls back to UTC / en-US when no rows exist', async () => { + const ql = makeCountingQl({ sys_setting: [] }); + const loc = await resolveLocalizationContext({ ql }); + expect(loc.timezone).toBe('UTC'); + expect(loc.locale).toBe('en-US'); + expect(loc.currency).toBeUndefined(); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index b4798f3934..2c836fbf8b 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -121,11 +121,26 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise => { + if (!userRowLoaded) { + userRowLoaded = true; + const rows = await tryFind(ql, 'sys_user', { id: userId }, 1); + userRow = rows[0]; + } + return userRow; + }; + // Resolve the caller's unique email for `current_user.email` RLS owner // policies when the session path didn't supply it (e.g. API-key auth). if (!ctx.email) { - const userRows = await tryFind(ql, 'sys_user', { id: userId }, 1); - if (userRows[0]?.email) ctx.email = String(userRows[0].email); + const u = await getUserRow(); + if (u?.email) ctx.email = String(u.email); } // 3. Organization-administration roles via sys_member (better-auth), normalized @@ -244,8 +259,7 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise rows.find((r) => r.key === k)?.value; return { - timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC', - locale: coerceLocale(localeRows[0]?.value) ?? 'en-US', - currency: coerceCurrency(currencyRows[0]?.value), + timezone: coerceTimeZone(valueOf('timezone')) ?? 'UTC', + locale: coerceLocale(valueOf('locale')) ?? 'en-US', + currency: coerceCurrency(valueOf('currency')), }; } diff --git a/packages/rest/src/rest-exec-ctx-memo.test.ts b/packages/rest/src/rest-exec-ctx-memo.test.ts new file mode 100644 index 0000000000..ecd1c21e00 --- /dev/null +++ b/packages/rest/src/rest-exec-ctx-memo.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +/** + * Request-scoped memoization of `resolveExecCtx` (#2409). A single HTTP request + * resolves the same execution context many times (data op, app-nav RBAC, + * dashboard gating, auth gate). Each resolution is ~16 sequential queries, so we + * memoize on the per-request `req` object + input environmentId. These tests pin + * that contract by stubbing the heavy `computeExecCtx` and counting invocations. + */ +const httpServer: any = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), +}; +const protocol: any = {}; + +describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () => { + it('resolves once per request object and returns the cached instance', async () => { + const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + let calls = 0; + rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; }; + + const req = { method: 'GET', path: '/x', headers: {} }; + const a = await rest.resolveExecCtx(undefined, req); + const b = await rest.resolveExecCtx(undefined, req); + + expect(calls).toBe(1); + expect(a).toBe(b); + }); + + it('re-resolves for a different request object', async () => { + const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + let calls = 0; + rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; }; + + await rest.resolveExecCtx(undefined, { headers: {} }); + await rest.resolveExecCtx(undefined, { headers: {} }); + + expect(calls).toBe(2); + }); + + it('keys the memo by environmentId within the same request', async () => { + const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + let calls = 0; + rest.computeExecCtx = async (env: any) => { calls++; return { env }; }; + + const req = { headers: {} }; + await rest.resolveExecCtx('envA', req); + await rest.resolveExecCtx('envA', req); + await rest.resolveExecCtx('envB', req); + + expect(calls).toBe(2); + }); + + it('caches an anonymous (undefined) resolution so repeat callers do not re-resolve', async () => { + const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + let calls = 0; + rest.computeExecCtx = async () => { calls++; return undefined; }; + + const req = { headers: {} }; + expect(await rest.resolveExecCtx(undefined, req)).toBeUndefined(); + expect(await rest.resolveExecCtx(undefined, req)).toBeUndefined(); + + expect(calls).toBe(1); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a6d2639f9b..ab40cf5593 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -542,6 +542,19 @@ export class RestServer { */ private readonly hostnameCache = new Map(); private readonly hostnameCacheTtlMs = 30_000; + /** + * Request-scoped memoization for `resolveExecCtx`. A single HTTP request + * resolves the SAME execution context (identity + RBAC/RLS + localization) + * many times — the data operation itself, app-nav RBAC filtering, dashboard + * widget gating, the auth gate, etc. Each resolution is ~16 sequential + * queries (the `resolveAuthzContext` aggregation plus localization), so a + * request that resolves twice pays for duplicate authz and repeated + * localization. Keyed by the per-request `req` object (a `WeakMap`, so the + * entry is collected with the request — naturally request-scoped, no TTL, + * no cross-request leak) and the input `environmentId`. We cache the + * in-flight Promise so concurrent callers share one resolution. + */ + private readonly execCtxMemo = new WeakMap>>(); private defaultEnvironmentIdProvider?: () => string | undefined; private authServiceProvider?: (environmentId?: string) => Promise; private objectQLProvider?: (environmentId?: string) => Promise; @@ -847,6 +860,25 @@ export class RestServer { * to the protocol layer (the SecurityPlugin treats undefined as anon). */ private async resolveExecCtx(environmentId: string | undefined, req: any): Promise { + // Request-scoped memoization — see `execCtxMemo`. The same `req` flows + // unchanged through every handler call, so its identity keys the memo; + // the input `environmentId` is part of the key because one host can route + // multiple environments. Anonymous (`undefined`) resolutions are cached + // too so repeat callers don't re-run getSession. Fall back to a direct + // resolve when there is no object to key on. + if (!req || typeof req !== 'object') return this.computeExecCtx(environmentId, req); + const key = environmentId ?? '\u0000default'; + let perReq = this.execCtxMemo.get(req); + if (!perReq) { perReq = new Map(); this.execCtxMemo.set(req, perReq); } + const cached = perReq.get(key); + if (cached) return cached; + const pending = this.computeExecCtx(environmentId, req); + perReq.set(key, pending); + return pending; + } + + /** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */ + private async computeExecCtx(environmentId: string | undefined, req: any): Promise { try { // For multi-tenant hosts (objectos), incoming requests on unscoped // URLs like `/api/v1/data/:object` arrive with `environmentId === undefined`.