Skip to content

Commit 9ccfcd6

Browse files
os-zhuangclaude
andauthored
perf(core): request-scoped memoization for authenticated exec-context resolution (#2412)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cbc8c02 commit 9ccfcd6

5 files changed

Lines changed: 220 additions & 11 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/rest': patch
4+
---
5+
6+
perf(core): authenticated requests issued ~16 sequential queries — duplicate authz + repeated localization — now request-scoped memoized
7+
8+
An authenticated REST request resolves its execution context (identity +
9+
RBAC/RLS + localization) many times in a single handler — the data operation
10+
itself, app-nav RBAC filtering, dashboard widget gating, the ADR-0069 auth gate.
11+
Each `resolveExecCtx` pass is the full `resolveAuthzContext` aggregation plus the
12+
localization read (~16 sequential queries), and nothing memoized it, so a request
13+
that resolves twice paid for duplicate authz and repeated localization.
14+
15+
- **`@objectstack/rest`**`resolveExecCtx` is now memoized per request, keyed by
16+
the request object (a `WeakMap`, so the entry is collected with the request — no
17+
TTL, no cross-request leak) and the input `environmentId`. The in-flight Promise
18+
is cached so concurrent callers share one resolution. The heavy path moved to
19+
`computeExecCtx`. Anonymous (`undefined`) resolutions are cached too.
20+
- **`@objectstack/core`** — within a single `resolveAuthzContext` pass, `sys_user`
21+
is now read at most once (the email fallback and the `ai_seat` synthesis shared a
22+
duplicate query on the API-key path); `resolveLocalizationContext`'s direct-read
23+
fallback batches `timezone`/`locale`/`currency` into one `sys_setting` query
24+
(`$in` on `key`) instead of three sequential reads.
25+
26+
No authorization-behavior change — the same roles/permissions/RLS context is
27+
resolved, just without the redundant reads. The `sys_member` reads (per-user roles
28+
vs. all-org-members) are intentionally left distinct (different filters/limits).
29+
30+
Tests: query-counting regressions assert `sys_user` reads once and localization
31+
reads once; new rest-server tests pin the per-request/per-environment memo contract.

packages/core/src/security/resolve-authz-context.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { resolveAuthzContext } from './resolve-authz-context.js';
4+
import { resolveAuthzContext, resolveLocalizationContext } from './resolve-authz-context.js';
55

66
/**
77
* Contract test for the SINGLE authorization resolver. Every authorization
@@ -109,3 +109,63 @@ describe('resolveAuthzContext — single source of truth', () => {
109109
expect(ctx.permissions).toEqual([]);
110110
});
111111
});
112+
113+
// A counting ObjectQL: records how many find() calls hit each object so we can
114+
// assert the de-duplication of redundant authz/localization reads (#2409).
115+
function makeCountingQl(tables: Record<string, any[]>) {
116+
const counts: Record<string, number> = {};
117+
return {
118+
counts,
119+
async find(object: string, opts: any) {
120+
counts[object] = (counts[object] ?? 0) + 1;
121+
const rows = tables[object] ?? [];
122+
const where = opts?.where ?? {};
123+
return rows.filter((r) =>
124+
Object.entries(where).every(([k, v]) => {
125+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
126+
return r[k] === v;
127+
}),
128+
);
129+
},
130+
};
131+
}
132+
133+
describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', () => {
134+
it('reads sys_user at most once even when both email fallback and ai_seat need it', async () => {
135+
// No email in the session → email fallback reads sys_user; ai_seat synthesis
136+
// also needs sys_user. Previously these were two separate queries.
137+
const ql = makeCountingQl({
138+
sys_user: [{ id: 'u1', email: 'ada@x.com', ai_access: 1 }],
139+
sys_member: [],
140+
sys_user_role: [],
141+
sys_user_permission_set: [],
142+
});
143+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
144+
expect(ctx.email).toBe('ada@x.com');
145+
expect(ctx.permissions).toContain('ai_seat');
146+
expect(ql.counts.sys_user).toBe(1);
147+
});
148+
});
149+
150+
describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
151+
it('reads sys_setting once (all three keys) when no settings service is wired', async () => {
152+
const ql = makeCountingQl({
153+
sys_setting: [
154+
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' },
155+
{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' },
156+
{ namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' },
157+
],
158+
});
159+
const loc = await resolveLocalizationContext({ ql, tenantId: 'o1' });
160+
expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' });
161+
expect(ql.counts.sys_setting).toBe(1);
162+
});
163+
164+
it('falls back to UTC / en-US when no rows exist', async () => {
165+
const ql = makeCountingQl({ sys_setting: [] });
166+
const loc = await resolveLocalizationContext({ ql });
167+
expect(loc.timezone).toBe('UTC');
168+
expect(loc.locale).toBe('en-US');
169+
expect(loc.currency).toBeUndefined();
170+
});
171+
});

packages/core/src/security/resolve-authz-context.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,26 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
121121
if (tenantId) ctx.tenantId = tenantId;
122122
if (!ql || typeof ql.find !== 'function') return ctx;
123123

124+
// sys_user is needed for both the `current_user.email` fallback (API-key auth,
125+
// where the session didn't supply an email) and the ai_seat synthesis below.
126+
// Read the row at most once per resolution — the two reads were a duplicate
127+
// query on the API-key path.
128+
let userRowLoaded = false;
129+
let userRow: any;
130+
const getUserRow = async (): Promise<any> => {
131+
if (!userRowLoaded) {
132+
userRowLoaded = true;
133+
const rows = await tryFind(ql, 'sys_user', { id: userId }, 1);
134+
userRow = rows[0];
135+
}
136+
return userRow;
137+
};
138+
124139
// Resolve the caller's unique email for `current_user.email` RLS owner
125140
// policies when the session path didn't supply it (e.g. API-key auth).
126141
if (!ctx.email) {
127-
const userRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
128-
if (userRows[0]?.email) ctx.email = String(userRows[0].email);
142+
const u = await getUserRow();
143+
if (u?.email) ctx.email = String(u.email);
129144
}
130145

131146
// 3. Organization-administration roles via sys_member (better-auth), normalized
@@ -244,8 +259,7 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
244259
// 7. [ADR-0024] Env-side AI seat: synthesize the `ai_seat` capability from the
245260
// boolean sys_user.ai_access (sqlite returns 1/0; memory returns boolean).
246261
if (!ctx.permissions.includes('ai_seat')) {
247-
const seatRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
248-
const aiAccess = (seatRows?.[0] as { ai_access?: unknown } | undefined)?.ai_access;
262+
const aiAccess = ((await getUserRow()) as { ai_access?: unknown } | undefined)?.ai_access;
249263
if (aiAccess === true || aiAccess === 1 || aiAccess === '1') ctx.permissions.push('ai_seat');
250264
}
251265

@@ -304,12 +318,17 @@ export async function resolveLocalizationContext(
304318
} catch {
305319
// settings service unavailable → direct read
306320
}
307-
const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1);
308-
const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1);
309-
const currencyRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'currency', scope: 'tenant' }, 1);
321+
// One read for all three keys instead of a query per key (`$in` on `key`).
322+
const rows = await tryFind(
323+
ql,
324+
'sys_setting',
325+
{ namespace: 'localization', key: { $in: ['timezone', 'locale', 'currency'] }, scope: 'tenant' },
326+
10,
327+
);
328+
const valueOf = (k: string) => rows.find((r) => r.key === k)?.value;
310329
return {
311-
timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC',
312-
locale: coerceLocale(localeRows[0]?.value) ?? 'en-US',
313-
currency: coerceCurrency(currencyRows[0]?.value),
330+
timezone: coerceTimeZone(valueOf('timezone')) ?? 'UTC',
331+
locale: coerceLocale(valueOf('locale')) ?? 'en-US',
332+
currency: coerceCurrency(valueOf('currency')),
314333
};
315334
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { RestServer } from './rest-server';
4+
5+
/**
6+
* Request-scoped memoization of `resolveExecCtx` (#2409). A single HTTP request
7+
* resolves the same execution context many times (data op, app-nav RBAC,
8+
* dashboard gating, auth gate). Each resolution is ~16 sequential queries, so we
9+
* memoize on the per-request `req` object + input environmentId. These tests pin
10+
* that contract by stubbing the heavy `computeExecCtx` and counting invocations.
11+
*/
12+
const httpServer: any = {
13+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
14+
use: vi.fn(), listen: vi.fn(), close: vi.fn(),
15+
};
16+
const protocol: any = {};
17+
18+
describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () => {
19+
it('resolves once per request object and returns the cached instance', async () => {
20+
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
21+
let calls = 0;
22+
rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; };
23+
24+
const req = { method: 'GET', path: '/x', headers: {} };
25+
const a = await rest.resolveExecCtx(undefined, req);
26+
const b = await rest.resolveExecCtx(undefined, req);
27+
28+
expect(calls).toBe(1);
29+
expect(a).toBe(b);
30+
});
31+
32+
it('re-resolves for a different request object', async () => {
33+
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
34+
let calls = 0;
35+
rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; };
36+
37+
await rest.resolveExecCtx(undefined, { headers: {} });
38+
await rest.resolveExecCtx(undefined, { headers: {} });
39+
40+
expect(calls).toBe(2);
41+
});
42+
43+
it('keys the memo by environmentId within the same request', async () => {
44+
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
45+
let calls = 0;
46+
rest.computeExecCtx = async (env: any) => { calls++; return { env }; };
47+
48+
const req = { headers: {} };
49+
await rest.resolveExecCtx('envA', req);
50+
await rest.resolveExecCtx('envA', req);
51+
await rest.resolveExecCtx('envB', req);
52+
53+
expect(calls).toBe(2);
54+
});
55+
56+
it('caches an anonymous (undefined) resolution so repeat callers do not re-resolve', async () => {
57+
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
58+
let calls = 0;
59+
rest.computeExecCtx = async () => { calls++; return undefined; };
60+
61+
const req = { headers: {} };
62+
expect(await rest.resolveExecCtx(undefined, req)).toBeUndefined();
63+
expect(await rest.resolveExecCtx(undefined, req)).toBeUndefined();
64+
65+
expect(calls).toBe(1);
66+
});
67+
});

packages/rest/src/rest-server.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,19 @@ export class RestServer {
542542
*/
543543
private readonly hostnameCache = new Map<string, { value: { environmentId: string } | null; expiresAt: number }>();
544544
private readonly hostnameCacheTtlMs = 30_000;
545+
/**
546+
* Request-scoped memoization for `resolveExecCtx`. A single HTTP request
547+
* resolves the SAME execution context (identity + RBAC/RLS + localization)
548+
* many times — the data operation itself, app-nav RBAC filtering, dashboard
549+
* widget gating, the auth gate, etc. Each resolution is ~16 sequential
550+
* queries (the `resolveAuthzContext` aggregation plus localization), so a
551+
* request that resolves twice pays for duplicate authz and repeated
552+
* localization. Keyed by the per-request `req` object (a `WeakMap`, so the
553+
* entry is collected with the request — naturally request-scoped, no TTL,
554+
* no cross-request leak) and the input `environmentId`. We cache the
555+
* in-flight Promise so concurrent callers share one resolution.
556+
*/
557+
private readonly execCtxMemo = new WeakMap<object, Map<string, Promise<any | undefined>>>();
545558
private defaultEnvironmentIdProvider?: () => string | undefined;
546559
private authServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
547560
private objectQLProvider?: (environmentId?: string) => Promise<any | undefined>;
@@ -847,6 +860,25 @@ export class RestServer {
847860
* to the protocol layer (the SecurityPlugin treats undefined as anon).
848861
*/
849862
private async resolveExecCtx(environmentId: string | undefined, req: any): Promise<any | undefined> {
863+
// Request-scoped memoization — see `execCtxMemo`. The same `req` flows
864+
// unchanged through every handler call, so its identity keys the memo;
865+
// the input `environmentId` is part of the key because one host can route
866+
// multiple environments. Anonymous (`undefined`) resolutions are cached
867+
// too so repeat callers don't re-run getSession. Fall back to a direct
868+
// resolve when there is no object to key on.
869+
if (!req || typeof req !== 'object') return this.computeExecCtx(environmentId, req);
870+
const key = environmentId ?? '\u0000default';
871+
let perReq = this.execCtxMemo.get(req);
872+
if (!perReq) { perReq = new Map(); this.execCtxMemo.set(req, perReq); }
873+
const cached = perReq.get(key);
874+
if (cached) return cached;
875+
const pending = this.computeExecCtx(environmentId, req);
876+
perReq.set(key, pending);
877+
return pending;
878+
}
879+
880+
/** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
881+
private async computeExecCtx(environmentId: string | undefined, req: any): Promise<any | undefined> {
850882
try {
851883
// For multi-tenant hosts (objectos), incoming requests on unscoped
852884
// URLs like `/api/v1/data/:object` arrive with `environmentId === undefined`.

0 commit comments

Comments
 (0)