From 49cc05896156ef9b4263bd15347dda8440dda9ab Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:23:19 +0800 Subject: [PATCH 1/3] fix(core): cache resolveLocalizationContext across requests (#10221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh environment's sys_setting table doesn't exist yet, so every authenticated request re-issued the same localization read, and every one failed the same way — driver-sql's backendStatementFault logs a [sql-driver] DATABASE_ERROR warning on every failed read, burying real errors between the noise. resolveLocalizationContext now memoizes its result (including the missing-table fallback to UTC/en-US) for 30s per (ql, tenantId, userId), mirroring the TTL cache plugin-audit/audit-writers.ts already applies to this same read. Functional behavior is unchanged; only the repeated per-request query is eliminated. --- .changeset/localization-context-ttl-cache.md | 9 +++ .../security/resolve-authz-context.test.ts | 73 ++++++++++++++++++- .../src/security/resolve-authz-context.ts | 60 ++++++++++++++- 3 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 .changeset/localization-context-ttl-cache.md diff --git a/.changeset/localization-context-ttl-cache.md b/.changeset/localization-context-ttl-cache.md new file mode 100644 index 0000000000..19ac29f686 --- /dev/null +++ b/.changeset/localization-context-ttl-cache.md @@ -0,0 +1,9 @@ +--- +"@objectstack/core": patch +--- + +`resolveLocalizationContext` now memoizes its result per `(ql, tenantId, userId)` for 30s (#10221). + +On a fresh environment whose `sys_setting` table hasn't been created/migrated yet, every authenticated request re-ran the same `sys_setting` localization read, and every one of those reads failed the same way ("no such table"). The `#2409` batching had already collapsed the three per-key reads a single request used to issue into one query, but that one query still repeated on every subsequent request, and `driver-sql`'s `backendStatementFault` logs a `[sql-driver] DATABASE_ERROR` warning on every failed read — so the identical warning printed once per request and buried real errors in between. + +The fallback to the built-in `UTC` / `en-US` defaults on a failed read is unchanged; this only stops the (failing, or successful-but-rarely-changing) query from re-running every request. It mirrors the TTL memoization `packages/plugins/plugin-audit/src/audit-writers.ts` (`resolveWriteLocale`) already applies to this same read for the identical reason, extended here to the other direct callers (`@objectstack/rest`, `@objectstack/runtime`) that call `resolveLocalizationContext` per request with no cache of their own. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never see each other's cached locale, and self-heals within one TTL window once `sys_setting` exists or carries a value. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 11eb159ed6..cb0a3ac79b 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js'; import { POSTURE_RANK } from './posture-ladder.js'; import { hashApiKey } from './api-key.js'; @@ -173,6 +173,77 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => { }); }); +// #10221: a fresh environment's `sys_setting` table doesn't exist yet, so +// EVERY request's read used to fail and the sql-driver's `[sql-driver] +// DATABASE_ERROR` warning repeated once per request, burying real errors. +// The #2409 batching above already collapsed one request down to a single +// query; this collapses that query across requests with a short TTL cache, +// mirroring `packages/plugins/plugin-audit/src/audit-writers.ts` +// (`resolveWriteLocale`)'s existing memoization of this same read. +describe('resolveLocalizationContext — cross-request TTL cache (#10221)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + // Simulates a fresh environment: `sys_setting` not migrated/written yet, so + // every read rejects the way the real sql-driver's "no such table" does. + function makeMissingTableQl() { + const counts = { sys_setting: 0 }; + return { + counts, + async find(_object: string) { + counts.sys_setting += 1; + throw new Error('no such table: sys_setting'); + }, + }; + } + + it('does not re-query on a second call within the TTL window (same tenant)', async () => { + const ql = makeMissingTableQl(); + const first = await resolveLocalizationContext({ ql, tenantId: 'o1' }); + const second = await resolveLocalizationContext({ ql, tenantId: 'o1' }); + // Functional behavior is unchanged — still the built-in defaults — the + // second call just doesn't repeat the failing query (and its log line). + expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined }); + expect(second).toEqual(first); + expect(ql.counts.sys_setting).toBe(1); + }); + + it('re-queries once the TTL expires, so the cache self-heals once the table/migration lands', async () => { + const ql = makeMissingTableQl(); + await resolveLocalizationContext({ ql, tenantId: 'o1' }); + expect(ql.counts.sys_setting).toBe(1); + await vi.advanceTimersByTimeAsync(30_001); + await resolveLocalizationContext({ ql, tenantId: 'o1' }); + expect(ql.counts.sys_setting).toBe(2); + }); + + it('keys the cache per tenant, so a lookup for one tenant never reuses another tenant\'s entry', async () => { + const ql = makeCountingQl({ + sys_setting: [{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }], + }); + const a = await resolveLocalizationContext({ ql, tenantId: 'tenant-a' }); + const b = await resolveLocalizationContext({ ql, tenantId: 'tenant-b' }); + expect(ql.counts.sys_setting).toBe(2); + expect(a).toEqual(b); + // A repeat for the first tenant hits its own cache entry, not tenant-b's. + await resolveLocalizationContext({ ql, tenantId: 'tenant-a' }); + expect(ql.counts.sys_setting).toBe(2); + }); + + it('keys the cache per `ql` instance, so two environments in one process never share a cached locale', async () => { + const qlA = makeMissingTableQl(); + const qlB = makeMissingTableQl(); + await resolveLocalizationContext({ ql: qlA, tenantId: 'o1' }); + await resolveLocalizationContext({ ql: qlB, tenantId: 'o1' }); + expect(qlA.counts.sys_setting).toBe(1); + expect(qlB.counts.sys_setting).toBe(1); + }); +}); + describe('grant validity windows (ADR-0091 D1/D2)', () => { const NOW = Date.parse('2026-07-10T12:00:00Z'); const PAST = '2026-07-01T00:00:00Z'; diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 903d3e6347..5662eaf9fe 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -577,15 +577,69 @@ export interface ResolveLocalizationInput { userId?: string; } +type LocalizationResult = { timezone: string; locale: string; currency?: string }; + +/** + * Process-local TTL cache for {@link resolveLocalizationContext} (#10221). + * + * `resolveAuthzContext`'s #2409 de-dup already collapsed the THREE per-key + * reads a request used to issue into one batched `sys_setting` query; what it + * did not address is the SAME query repeating on EVERY request. On a fresh + * environment (`sys_setting` not yet migrated / never written) that one query + * fails every time, and `packages/drivers/driver-sql`'s + * `backendStatementFault` — deliberately generic; see its doc — logs a + * `[sql-driver] DATABASE_ERROR` line for EVERY failed read, so the identical + * "no such table: sys_setting" warning repeats once per request and buries + * real errors in between. + * + * `tryFind` below already guarantees the FUNCTIONAL fallback (catch → `[]` → + * built-in `UTC` / `en-US` defaults) — this cache only stops the query (and + * therefore the log line) from re-running every request. It mirrors the + * TTL-memoization `packages/plugins/plugin-audit/src/audit-writers.ts` + * (`resolveWriteLocale`) already applies to this exact read for the same + * reason ("workspace locale changes rarely"); this closes the gap for the + * OTHER callers (`@objectstack/rest`, `@objectstack/runtime`) that call + * `resolveLocalizationContext` directly, once per request, without their own + * cache. + * + * Keyed on the `ql` instance (one entry-set per environment engine, so two + * environments/tenants sharing a process never see each other's cached + * locale) and then `tenantId|userId` beneath it, matching the audit writer's + * key shape. A `ql` that isn't cacheable (missing/non-object — `tryFind` + * already no-ops on that) skips the cache entirely; there is no query to + * dedupe in that case. + */ +const LOCALIZATION_CACHE_TTL_MS = 30_000; +const localizationCache = new WeakMap>(); + /** * Resolve workspace localization defaults (reference `timezone` / `locale` / * `currency`). Canonical path is the `localization` SettingsManifest (cascade: * platform default → global → tenant); falls back to direct tenant-scoped * `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws. + * + * Cross-request result is memoized for {@link LOCALIZATION_CACHE_TTL_MS} per + * `(ql, tenantId, userId)` — see the cache doc above (#10221). */ -export async function resolveLocalizationContext( - input: ResolveLocalizationInput, -): Promise<{ timezone: string; locale: string; currency?: string }> { +export async function resolveLocalizationContext(input: ResolveLocalizationInput): Promise { + const { ql, tenantId, userId } = input; + if (ql && typeof ql === 'object') { + const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`; + const now = Date.now(); + const perEngine = localizationCache.get(ql); + const hit = perEngine?.get(cacheKey); + if (hit && hit.expiresAt > now) return hit.value; + + const value = await resolveLocalizationContextUncached(input); + const bucket = perEngine ?? new Map(); + bucket.set(cacheKey, { value, expiresAt: now + LOCALIZATION_CACHE_TTL_MS }); + if (!perEngine) localizationCache.set(ql, bucket); + return value; + } + return resolveLocalizationContextUncached(input); +} + +async function resolveLocalizationContextUncached(input: ResolveLocalizationInput): Promise { const { ql, settings, tenantId, userId } = input; try { if (settings && typeof settings.get === 'function') { From f242a35fe00127929a16fea8fcb9460137628375 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:48:18 +0800 Subject: [PATCH 2/3] fix(core): narrow resolveLocalizationContext cache to failed reads only (#10221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the first version's regression: it cached every outcome (including a successful read) for 30s, which broke packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts (#1982/#2018) — that dogfood test writes a new org timezone via the real settings route and expects the very next analytics read to bucket under it. Narrow the cache to memoize ONLY the case where the underlying sys_setting read genuinely throws (a backend fault, e.g. "no such table") — the case that actually produces the log spam #10221 reports. A successful read, including a legitimate empty result, is never cached and always re-reads, so a settings write is visible on the very next call, matching pre-existing behavior. Functional fallback (UTC/en-US on failure) is unchanged. Added two tests pinning the never-cache-a-success guarantee directly (value change and first-write-after-empty, both visible on the next call with no TTL advance), and updated the existing failure-path tests' description accordingly. --- .changeset/localization-context-ttl-cache.md | 4 +- .../security/resolve-authz-context.test.ts | 71 +++++++-- .../src/security/resolve-authz-context.ts | 142 ++++++++++++------ 3 files changed, 155 insertions(+), 62 deletions(-) diff --git a/.changeset/localization-context-ttl-cache.md b/.changeset/localization-context-ttl-cache.md index 19ac29f686..ff3c4fd46d 100644 --- a/.changeset/localization-context-ttl-cache.md +++ b/.changeset/localization-context-ttl-cache.md @@ -2,8 +2,8 @@ "@objectstack/core": patch --- -`resolveLocalizationContext` now memoizes its result per `(ql, tenantId, userId)` for 30s (#10221). +`resolveLocalizationContext` now memoizes a FAILED read's fallback per `(ql, tenantId, userId)` for 30s (#10221). On a fresh environment whose `sys_setting` table hasn't been created/migrated yet, every authenticated request re-ran the same `sys_setting` localization read, and every one of those reads failed the same way ("no such table"). The `#2409` batching had already collapsed the three per-key reads a single request used to issue into one query, but that one query still repeated on every subsequent request, and `driver-sql`'s `backendStatementFault` logs a `[sql-driver] DATABASE_ERROR` warning on every failed read — so the identical warning printed once per request and buried real errors in between. -The fallback to the built-in `UTC` / `en-US` defaults on a failed read is unchanged; this only stops the (failing, or successful-but-rarely-changing) query from re-running every request. It mirrors the TTL memoization `packages/plugins/plugin-audit/src/audit-writers.ts` (`resolveWriteLocale`) already applies to this same read for the identical reason, extended here to the other direct callers (`@objectstack/rest`, `@objectstack/runtime`) that call `resolveLocalizationContext` per request with no cache of their own. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never see each other's cached locale, and self-heals within one TTL window once `sys_setting` exists or carries a value. +Only the case where the underlying read genuinely fails (a backend fault, e.g. the missing table) is cached; a successful read — including a legitimate "nothing configured yet" empty result — is never cached and always re-reads on the next call, so a settings write takes effect immediately. (An earlier version of this fix cached every outcome, mirroring `packages/plugins/plugin-audit/src/audit-writers.ts`'s existing TTL cache of this same read — safe there because audit-trail enrichment is best-effort, but not safe for `@objectstack/rest`'s use of this function: analytics date-bucketing reads the org timezone on every query and `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` — the #1982/#2018 golden regression — asserts the very next read reflects a just-written timezone.) The `UTC` / `en-US` fallback behavior itself is unchanged; this only stops the failing query — and its log line — from re-running every request. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never share a cached outcome, and self-heals within one TTL window once `sys_setting` exists. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index cb0a3ac79b..5b9ce2a512 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -177,10 +177,16 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => { // EVERY request's read used to fail and the sql-driver's `[sql-driver] // DATABASE_ERROR` warning repeated once per request, burying real errors. // The #2409 batching above already collapsed one request down to a single -// query; this collapses that query across requests with a short TTL cache, -// mirroring `packages/plugins/plugin-audit/src/audit-writers.ts` -// (`resolveWriteLocale`)'s existing memoization of this same read. -describe('resolveLocalizationContext — cross-request TTL cache (#10221)', () => { +// query; this collapses the FAILING query across requests with a short TTL +// cache — but ONLY the failure, never a successful (or legitimately empty) +// read. A first version cached every outcome, mirroring +// `packages/plugins/plugin-audit/src/audit-writers.ts` (`resolveWriteLocale`)'s +// existing memoization of this same read — that broke +// `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` (#1982/#2018), +// which writes a new org timezone and expects the very next analytics read to +// bucket under it. See the cache doc on `resolveLocalizationContext` for why +// audit-writer's staleness tolerance doesn't transfer here. +describe('resolveLocalizationContext — failure-only cross-request cache (#10221)', () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -201,7 +207,7 @@ describe('resolveLocalizationContext — cross-request TTL cache (#10221)', () = }; } - it('does not re-query on a second call within the TTL window (same tenant)', async () => { + it('does not re-query on a second call within the TTL window when the read fails (same tenant)', async () => { const ql = makeMissingTableQl(); const first = await resolveLocalizationContext({ ql, tenantId: 'o1' }); const second = await resolveLocalizationContext({ ql, tenantId: 'o1' }); @@ -221,20 +227,21 @@ describe('resolveLocalizationContext — cross-request TTL cache (#10221)', () = expect(ql.counts.sys_setting).toBe(2); }); - it('keys the cache per tenant, so a lookup for one tenant never reuses another tenant\'s entry', async () => { - const ql = makeCountingQl({ - sys_setting: [{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }], - }); - const a = await resolveLocalizationContext({ ql, tenantId: 'tenant-a' }); - const b = await resolveLocalizationContext({ ql, tenantId: 'tenant-b' }); - expect(ql.counts.sys_setting).toBe(2); - expect(a).toEqual(b); + it('keys the failure cache per tenant, so a lookup for one tenant never reuses another tenant\'s entry', async () => { + const qlA = makeMissingTableQl(); + // Reuse the SAME underlying table state across two tenant-scoped calls by + // routing both through one `ql`, distinguished only by `tenantId` in the + // cache key (the direct-read fallback query itself is not tenant-scoped + // in its `where`, matching the real resolver's existing behavior). + await resolveLocalizationContext({ ql: qlA, tenantId: 'tenant-a' }); + await resolveLocalizationContext({ ql: qlA, tenantId: 'tenant-b' }); + expect(qlA.counts.sys_setting).toBe(2); // A repeat for the first tenant hits its own cache entry, not tenant-b's. - await resolveLocalizationContext({ ql, tenantId: 'tenant-a' }); - expect(ql.counts.sys_setting).toBe(2); + await resolveLocalizationContext({ ql: qlA, tenantId: 'tenant-a' }); + expect(qlA.counts.sys_setting).toBe(2); }); - it('keys the cache per `ql` instance, so two environments in one process never share a cached locale', async () => { + it('keys the failure cache per `ql` instance, so two environments in one process never share a cached outcome', async () => { const qlA = makeMissingTableQl(); const qlB = makeMissingTableQl(); await resolveLocalizationContext({ ql: qlA, tenantId: 'o1' }); @@ -242,6 +249,38 @@ describe('resolveLocalizationContext — cross-request TTL cache (#10221)', () = expect(qlA.counts.sys_setting).toBe(1); expect(qlB.counts.sys_setting).toBe(1); }); + + // The dogfood-test guard (#1982/#2018 golden regression): a SUCCESSFUL read + // must never be served stale. Simulates the exact shape of the failing CI + // scenario — a settings write changes the effective row between two calls — + // entirely at this unit level, without booting the dogfood stack. + it('never caches a successful read: a value change between two calls is visible on the very next call', async () => { + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const ql = makeCountingQl({ sys_setting: rows }); + const first = await resolveLocalizationContext({ ql, tenantId: 'o1' }); + expect(first.timezone).toBe('UTC'); + // Simulate a settings write landing between the two calls — no TTL + // advance, so a cache would still be "fresh" if one existed. + rows[0].value = 'America/Los_Angeles'; + const second = await resolveLocalizationContext({ ql, tenantId: 'o1' }); + expect(second.timezone).toBe('America/Los_Angeles'); + expect(ql.counts.sys_setting).toBe(2); + }); + + // A legitimate empty result (table exists, no settings configured for this + // tenant yet) is a successful read too — not a failure — so it must not be + // cached either: the first write for a previously-unconfigured tenant must + // be visible on the very next call, same as the value-change case above. + it('never caches a legitimate empty result: the first write for a previously-unconfigured tenant is visible immediately', async () => { + const rows: Array<{ namespace: string; key: string; scope: string; value: string }> = []; + const ql = makeCountingQl({ sys_setting: rows }); + const first = await resolveLocalizationContext({ ql, tenantId: 'o1' }); + expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined }); + rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }); + const second = await resolveLocalizationContext({ ql, tenantId: 'o1' }); + expect(second.timezone).toBe('Asia/Tokyo'); + expect(ql.counts.sys_setting).toBe(2); + }); }); describe('grant validity windows (ADR-0091 D1/D2)', () => { diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 5662eaf9fe..9b7f1852aa 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -580,7 +580,9 @@ export interface ResolveLocalizationInput { type LocalizationResult = { timezone: string; locale: string; currency?: string }; /** - * Process-local TTL cache for {@link resolveLocalizationContext} (#10221). + * Process-local TTL cache for the FAILED-READ outcome of + * {@link resolveLocalizationContext} only (#10221; narrowed after a patch + * round — see below). * * `resolveAuthzContext`'s #2409 de-dup already collapsed the THREE per-key * reads a request used to issue into one batched `sys_setting` query; what it @@ -592,25 +594,43 @@ type LocalizationResult = { timezone: string; locale: string; currency?: string * "no such table: sys_setting" warning repeats once per request and buries * real errors in between. * - * `tryFind` below already guarantees the FUNCTIONAL fallback (catch → `[]` → - * built-in `UTC` / `en-US` defaults) — this cache only stops the query (and - * therefore the log line) from re-running every request. It mirrors the - * TTL-memoization `packages/plugins/plugin-audit/src/audit-writers.ts` - * (`resolveWriteLocale`) already applies to this exact read for the same - * reason ("workspace locale changes rarely"); this closes the gap for the - * OTHER callers (`@objectstack/rest`, `@objectstack/runtime`) that call - * `resolveLocalizationContext` directly, once per request, without their own - * cache. + * `tryFind`-style catches already guaranteed the FUNCTIONAL fallback (catch → + * `[]` → built-in `UTC` / `en-US` defaults) — this cache only stops the + * FAILING query (and therefore the log line) from re-running every request. + * + * ## Why only the failure, not every result (patch round, CI red) + * + * The first version of this cache memoized every outcome — including a + * SUCCESSFUL read — for 30s, mirroring `packages/plugins/plugin-audit/src/audit-writers.ts` + * (`resolveWriteLocale`)'s existing TTL cache of this same read. That is safe + * for audit-writer's use: audit trail enrichment is explicitly best-effort, + * so a stale locale in a log line for up to 30s costs nothing observable. + * It is NOT safe for `@objectstack/rest`'s use of this same function: analytics + * date-bucketing reads the org timezone on every query, and + * `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` (the golden + * regression for #1982/#2018) writes a NEW org timezone via the real settings + * route and asserts the VERY NEXT analytics read buckets under it — a 30s-old + * cached value broke that test (CI: `expected undefined to be 3`, the bucket + * stayed on the previous timezone). Analytics bucketing is declared, tested + * behavior, not best-effort enrichment — it cannot tolerate the same + * staleness plugin-audit's cache is allowed to. + * + * So this cache is narrowed to memoize ONLY the case the underlying read + * itself THREW (a backend fault — e.g. "no such table" — not a legitimate + * empty/no-rows-yet result, which is a normal, cheap, un-failing read and + * stays uncached so a fresh write is visible on the next request same as + * before). That fully addresses #10221 — the log spam only exists on an env + * where the read is actively failing — while never caching a value a caller + * could observe going stale. * * Keyed on the `ql` instance (one entry-set per environment engine, so two * environments/tenants sharing a process never see each other's cached - * locale) and then `tenantId|userId` beneath it, matching the audit writer's - * key shape. A `ql` that isn't cacheable (missing/non-object — `tryFind` - * already no-ops on that) skips the cache entirely; there is no query to - * dedupe in that case. + * outcome) and then `tenantId|userId` beneath it, matching the audit writer's + * key shape. A `ql` that isn't cacheable (missing/non-object) skips the cache + * entirely — there is no query to dedupe in that case. */ -const LOCALIZATION_CACHE_TTL_MS = 30_000; -const localizationCache = new WeakMap>(); +const LOCALIZATION_FAILURE_CACHE_TTL_MS = 30_000; +const localizationFailureCache = new WeakMap>(); /** * Resolve workspace localization defaults (reference `timezone` / `locale` / @@ -618,56 +638,90 @@ const localizationCache = new WeakMap { const { ql, tenantId, userId } = input; + const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`; if (ql && typeof ql === 'object') { - const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`; - const now = Date.now(); - const perEngine = localizationCache.get(ql); - const hit = perEngine?.get(cacheKey); - if (hit && hit.expiresAt > now) return hit.value; - - const value = await resolveLocalizationContextUncached(input); - const bucket = perEngine ?? new Map(); - bucket.set(cacheKey, { value, expiresAt: now + LOCALIZATION_CACHE_TTL_MS }); - if (!perEngine) localizationCache.set(ql, bucket); - return value; + const hit = localizationFailureCache.get(ql)?.get(cacheKey); + if (hit && hit.expiresAt > Date.now()) return hit.value; } - return resolveLocalizationContextUncached(input); + + const { value, failed } = await resolveLocalizationContextUncached(input); + if (failed && ql && typeof ql === 'object') { + const bucket = localizationFailureCache.get(ql) ?? new Map(); + bucket.set(cacheKey, { value, expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS }); + localizationFailureCache.set(ql, bucket); + } + return value; } -async function resolveLocalizationContextUncached(input: ResolveLocalizationInput): Promise { +async function resolveLocalizationContextUncached( + input: ResolveLocalizationInput, +): Promise<{ value: LocalizationResult; failed: boolean }> { const { ql, settings, tenantId, userId } = input; + let failed = false; try { if (settings && typeof settings.get === 'function') { const sctx = { tenantId, userId } as any; const [tzRes, localeRes, currencyRes] = await Promise.all([ - settings.get('localization', 'timezone', sctx).catch(() => undefined), - settings.get('localization', 'locale', sctx).catch(() => undefined), - settings.get('localization', 'currency', sctx).catch(() => undefined), + settings.get('localization', 'timezone', sctx).catch(() => { + failed = true; + return undefined; + }), + settings.get('localization', 'locale', sctx).catch(() => { + failed = true; + return undefined; + }), + settings.get('localization', 'currency', sctx).catch(() => { + failed = true; + return undefined; + }), ]); const tz = coerceTimeZone(tzRes?.value); const locale = coerceLocale(localeRes?.value); const currency = coerceCurrency(currencyRes?.value); - if (tz || locale || currency) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }; + if (tz || locale || currency) { + return { value: { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }, failed: false }; + } } } catch { // settings service unavailable → direct read + failed = true; } // One read for all three keys instead of a query per key (`$in` on `key`). - const rows = await tryFind( - ql, - 'sys_setting', - { namespace: 'localization', key: { $in: ['timezone', 'locale', 'currency'] }, scope: 'tenant' }, - 10, - ); + // Inlined (rather than the shared `tryFind`) so a genuine backend fault — + // as opposed to a legitimate empty result — is visible to the caller above, + // which is the signal the failure-only cache keys off. + let rows: any[] = []; + if (ql && typeof ql.find === 'function') { + try { + let result = await ql.find('sys_setting', { + where: { namespace: 'localization', key: { $in: ['timezone', 'locale', 'currency'] }, scope: 'tenant' }, + limit: 10, + context: { isSystem: true }, + } as any); + if (result && (result as any).value) result = (result as any).value; + rows = Array.isArray(result) ? result : []; + } catch { + failed = true; + } + } const valueOf = (k: string) => rows.find((r) => r.key === k)?.value; return { - timezone: coerceTimeZone(valueOf('timezone')) ?? 'UTC', - locale: coerceLocale(valueOf('locale')) ?? 'en-US', - currency: coerceCurrency(valueOf('currency')), + value: { + timezone: coerceTimeZone(valueOf('timezone')) ?? 'UTC', + locale: coerceLocale(valueOf('locale')) ?? 'en-US', + currency: coerceCurrency(valueOf('currency')), + }, + failed, }; } From 34e7bade970648290cc245367ed5080d063b5beb Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:51:44 +0800 Subject: [PATCH 3/3] fix(core): drop unnecessary as-any cast in the inlined sys_setting read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:query-options-erasure caught it: the previous commit's inlined ql.find() call (added to observe read failure for the #10221 cache) cast its options literal to any, a NEW counted erasure site distinct from tryFind's existing grandfathered one. ql is already typed any, so the cast was redundant — tsc doesn't need it, and dropping it restores the ratchet to its pre-existing count. No behavior change. --- packages/core/src/security/resolve-authz-context.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 9b7f1852aa..cd376a0f38 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -700,7 +700,11 @@ async function resolveLocalizationContextUncached( // One read for all three keys instead of a query per key (`$in` on `key`). // Inlined (rather than the shared `tryFind`) so a genuine backend fault — // as opposed to a legitimate empty result — is visible to the caller above, - // which is the signal the failure-only cache keys off. + // which is the signal the failure-only cache keys off. `ql` is already + // typed `any` (its shape varies by caller — REST's engine, a test double, + // …), so the options literal below needs no `as any` of its own (#4918 + // query-options-erasure guard: that cast is a distinct, counted erasure + // site, not implied by an already-`any` receiver). let rows: any[] = []; if (ql && typeof ql.find === 'function') { try { @@ -708,7 +712,7 @@ async function resolveLocalizationContextUncached( where: { namespace: 'localization', key: { $in: ['timezone', 'locale', 'currency'] }, scope: 'tenant' }, limit: 10, context: { isSystem: true }, - } as any); + }); if (result && (result as any).value) result = (result as any).value; rows = Array.isArray(result) ? result : []; } catch {