From 09f32b428b581c60a6b2e7949a1de25ecbef28a0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:22:01 +0800 Subject: [PATCH] perf(core): resolveLocalizationContext reads its three keys through settings.getMany (#10826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller half of #10826 (service half: #11200). One grouped namespace read replaces three per-key get()s — queries 16-18 of 24 on the measured rig collapse to one, with per-key answers unchanged by the service's equivalence contract. Feature-detected: an older service without getMany keeps the three parallel gets (still one leg — this is a query-count fix per the card's own calibration, not a latency fix). A thrown getMany lands exactly where a thrown get did: failed=true and the direct $in fallback, which reads the same three keys. Serial constraint honored: landed after #11197 (#10825) on this file. Co-Authored-By: Claude Opus 5 --- .../localization-getmany-caller-10826.md | 5 ++ .../security/resolve-authz-context.test.ts | 54 +++++++++++++++++++ .../src/security/resolve-authz-context.ts | 47 +++++++++++----- 3 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 .changeset/localization-getmany-caller-10826.md diff --git a/.changeset/localization-getmany-caller-10826.md b/.changeset/localization-getmany-caller-10826.md new file mode 100644 index 0000000000..20212afb10 --- /dev/null +++ b/.changeset/localization-getmany-caller-10826.md @@ -0,0 +1,5 @@ +--- +'@objectstack/core': patch +--- + +`resolveLocalizationContext` prefers `settings.getMany` — one grouped namespace read instead of three per-key `get()`s (#10826); older services without `getMany` keep the three parallel gets, and a thrown `getMany` lands in the same direct `$in` fallback a thrown `get` did. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 54ab651d69..36e7889670 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -171,6 +171,60 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => { expect(loc.locale).toBe('en-US'); expect(loc.currency).toBeUndefined(); }); + + // [#10826] The settings-service path prefers ONE grouped getMany over three + // per-key get()s; an older service without getMany keeps the three gets; + // a thrown getMany lands in the same direct-$in fallback a thrown get did. + it('prefers settings.getMany (one grouped call) and never calls per-key get', async () => { + const getMany = { calls: 0 }; + const settings = { + get: async () => { throw new Error('per-key get must not be called'); }, + getMany: async (ns: string, keys: readonly string[]) => { + getMany.calls += 1; + expect(ns).toBe('localization'); + expect([...keys].sort()).toEqual(['currency', 'locale', 'timezone']); + return { + timezone: { value: 'Asia/Tokyo' }, + locale: { value: 'ja-JP' }, + currency: { value: 'JPY' }, + }; + }, + }; + const ql = makeCountingQl({ sys_setting: [] }); + const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' }); + expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' }); + expect(getMany.calls).toBe(1); + expect(ql.counts.sys_setting ?? 0).toBe(0); // service answered — no direct read + }); + + it('a service without getMany keeps the three per-key gets (older deployments)', async () => { + let gets = 0; + const settings = { + get: async (_ns: string, key: string) => { + gets += 1; + return { value: key === 'timezone' ? 'Asia/Tokyo' : key === 'locale' ? 'ja-JP' : 'JPY' }; + }, + }; + const ql = makeCountingQl({ sys_setting: [] }); + const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' }); + expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' }); + expect(gets).toBe(3); + }); + + it('a thrown getMany falls back to the direct $in read, same as a broken service', async () => { + const settings = { + get: async () => { throw new Error('unused'); }, + getMany: async () => { throw new Error('store exploded'); }, + }; + const ql = makeCountingQl({ + sys_setting: [ + { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }, + ], + }); + const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' }); + expect(loc.timezone).toBe('Europe/Paris'); + expect(ql.counts.sys_setting).toBe(1); // the batched $in fallback ran once + }); }); // #10221: a fresh environment's `sys_setting` table doesn't exist yet, so diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index f88bcf1242..876f9598ac 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -748,20 +748,41 @@ async function resolveLocalizationContextUncached( 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(() => { + // [#10826] ONE grouped namespace read instead of three: `getMany` + // resolves all three keys over at most two `loadRows` calls (queries + // 16–18 of 24 on the measured rig collapse to one). Same per-key + // answers by the service's own equivalence contract. Feature-detected: + // an older service without `getMany` keeps the three parallel `get`s + // (still 1 leg — this is a query-count fix, per the card's calibration). + // A thrown `getMany` lands in the same place a thrown `get` did — + // `failed = true` and the direct `$in` fallback below, which reads the + // exact same three keys. + let tzRes: any; let localeRes: any; let currencyRes: any; + if (typeof settings.getMany === 'function') { + try { + const many = await settings.getMany('localization', ['timezone', 'locale', 'currency'], sctx); + tzRes = many.timezone; + localeRes = many.locale; + currencyRes = many.currency; + } 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; - }), - ]); + } + } else { + [tzRes, localeRes, currencyRes] = await Promise.all([ + 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);