Skip to content

Commit a8c00e2

Browse files
claude[bot]claude
andauthored
feat(core): cache successful sys_setting localization reads, invalidated synchronously on write (#12694)
* feat(core): cache successful sys_setting localization reads, invalidated synchronously on write (#11966) Leg C (ship-first) of the accepted #11633 cross-request caching design. `resolveLocalizationContext` re-read `sys_setting` on every authenticated request for three values that change roughly never. That read is now cached, behind invalidation that is synchronous and in-process rather than TTL-bound. - Primary trigger: `SettingsService.subscribe('localization', ...)`, which dispatches synchronously from the write path after the row is persisted. No change was needed in `@objectstack/service-settings` — the seam was already public and already does exactly this. - Backstop: the #11968 engine write epoch, read structurally rather than imported (objectql depends on core, so the edge cannot be reversed). Covers direct `sys_setting` writes that never pass through the service. - Residual bound: `OS_LOCALIZATION_CACHE_TTL_MS`, default 30s, `0` = off. Two rules carry it, and both are pinned: a success is cached ONLY when the engine exposes the write epoch (no seam ⇒ no cache, rather than degrading to the TTL-only shape that was reverted here once already), and invalidation retires SUCCESS entries only (retiring #10221's failure memo on a write would restart the per-request driver log spam it removed). `analytics-timezone.dogfood.test.ts` is unchanged and unweakened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry * test(core): hold the caller's bound in the leg-C localization cache double (#11966) `check:objectql-double-limit` (#10978): a `find` double that hands back every row it matched cannot tell this read's `limit: 10` from no bound at all, so a folded or dropped bound would stay green by construction. Applied after the filter and by presence, matching the sibling `bounded()` helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry * test(core): refuse combinators in the leg-C localization cache matcher (#11966) `check:where-matcher`: a hand-written WHERE matcher with no combinator branch reads `$and` as a field name and silently answers the wrong question rather than failing. Refusing the unsupported shape is the convention the sibling double in resolve-authz-context.test.ts already follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2805e52 commit a8c00e2

5 files changed

Lines changed: 775 additions & 21 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): cache successful `sys_setting` localization reads, invalidated synchronously on write (#11966)
6+
7+
Leg C (ship-first) of the accepted #11633 cross-request caching design
8+
(maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0).
9+
`resolveLocalizationContext` re-read `sys_setting` on **every** authenticated
10+
request to answer the same three keys — `timezone` / `locale` / `currency`
11+
for a workspace whose values change roughly never. That read is now cached.
12+
13+
**Grade: `minor`, not `patch`.** It adds a deployment variable
14+
(`OS_LOCALIZATION_CACHE_TTL_MS`) and changes the query pattern of a shipped code
15+
path. Not `major`: the observable contract callers actually depend on — a
16+
settings write is visible to the very next read — is preserved, and pinned.
17+
18+
Caching this read was tried once before and reverted. #10221's first version
19+
memoized every outcome for 30s and CI went red on
20+
`analytics-timezone.dogfood.test.ts`, which writes a new org timezone and
21+
expects the very next analytics query to bucket under it; the cache was narrowed
22+
to memoize **failures** only. That verdict was on **TTL-only** caching and it
23+
still stands unamended. What changed is that the process now has invalidation
24+
seams it did not have then:
25+
26+
- **Primary — the settings change seam.** `SettingsService.subscribe(ns, handler)`
27+
dispatches synchronously and in-process from the write path, after the row is
28+
persisted. (⚠️ #11633 calls this a "settings change bus"; no such module
29+
exists — `subscribe()` is the seam. No change was needed in
30+
`@objectstack/service-settings`: the seam was already public and already does
31+
exactly this.)
32+
- **Backstop — the engine write epoch** from #11968's substrate. Needed because
33+
this resolver's own fallback reads `sys_setting` *directly*, so a seeder or
34+
any other direct engine write emits no settings event at all. It is read
35+
structurally rather than imported, because `@objectstack/objectql` depends on
36+
`@objectstack/core` and the substrate declared `WriteEpochLike` separately for
37+
exactly this consumer. A peer node's hint arrives as a local bump, so an
38+
attached `authz.invalidated` bridge narrows cross-node convergence for free.
39+
- **TTL** — the residual bound, for what neither seam can see. Default 30s,
40+
`0` disables the cache on a real path rather than a degenerate one.
41+
42+
Two rules carry the change and are pinned rather than merely documented:
43+
44+
1. **A success is cached only when the engine exposes the write epoch.** A `ql`
45+
with no seam is a `ql` whose writes the cache cannot observe, so rather than
46+
degrade to the TTL-only shape that was already reverted here once, the cache
47+
declines. A partial `{ current }` shape is not a seam either — a counter
48+
nothing can bump would read as a live invalidation source and pin the answer
49+
for a whole TTL.
50+
2. **Invalidation retires success entries only.** #10221's failure memo exists
51+
for an environment where `sys_setting` is missing; retiring it on a write
52+
would restart precisely the per-request driver log spam that memo removed,
53+
and no write can create a missing table. It stays TTL-bound and behaves
54+
exactly as #10221/#11877 shipped it.
55+
56+
`analytics-timezone.dogfood.test.ts` is unchanged and unweakened — it is this
57+
leg's acceptance test, and an ablation that reduces the cache to its TTL turns
58+
it red on the same assertion the original revert was recorded against.

content/docs/deployment/environment-variables.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ the hosted ObjectOS Cloud control plane.
355355
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
356356
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
357357
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
358+
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
358359
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
359360
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |
360361

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,10 @@ function makeMissingTableQl() {
396396
// The #2409 batching above already collapsed one request down to a single
397397
// query; this collapses the FAILING query across requests with a short TTL
398398
// cache — but ONLY the failure, never a successful (or legitimately empty)
399-
// read. A first version cached every outcome, mirroring
399+
// read on a `ql` that offers no way to learn a write happened. (#11966 later
400+
// added the success cache behind exactly that seam; the failure memo below is
401+
// untouched by it, and a dedicated pin in `resolve-localization-cache.test.ts`
402+
// holds it untouched — retiring it on a write would restart this very spam.) A first version cached every outcome, mirroring
400403
// `packages/plugins/plugin-audit/src/audit-writers.ts` (`resolveWriteLocale`)'s
401404
// existing memoization of this same read — that broke
402405
// `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` (#1982/#2018),
@@ -458,7 +461,15 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
458461
// must never be served stale. Simulates the exact shape of the failing CI
459462
// scenario — a settings write changes the effective row between two calls —
460463
// entirely at this unit level, without booting the dogfood stack.
461-
it('never caches a successful read: a value change between two calls is visible on the very next call', async () => {
464+
//
465+
// [#11966] `makeCountingQl` carries no write-epoch seam, and that is now
466+
// load-bearing rather than incidental: leg C caches a success ONLY behind an
467+
// engine that can tell it a write happened, so this double pins the
468+
// seam-ABSENT arm — where the pre-#11966 multiset must survive byte for byte.
469+
// The seam-PRESENT arm is `resolve-localization-cache.test.ts`, which pins
470+
// the same staleness property through the invalidation instead of through the
471+
// absence of a cache.
472+
it('without an engine write-epoch seam, a successful read is never cached: a value change between two calls is visible on the very next call', async () => {
462473
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }];
463474
const ql = makeCountingQl({ sys_setting: rows });
464475
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
@@ -472,10 +483,11 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
472483
});
473484

474485
// A legitimate empty result (table exists, no settings configured for this
475-
// tenant yet) is a successful read too — not a failure — so it must not be
476-
// cached either: the first write for a previously-unconfigured tenant must
477-
// be visible on the very next call, same as the value-change case above.
478-
it('never caches a legitimate empty result: the first write for a previously-unconfigured tenant is visible immediately', async () => {
486+
// tenant yet) is a successful read too — not a failure — so on a seam-less
487+
// `ql` it must not be cached either: the first write for a previously-
488+
// unconfigured tenant must be visible on the very next call, same as the
489+
// value-change case above. (#11966: same seam-absent arm as that case.)
490+
it('without a seam, a legitimate empty result is not cached either: the first write for a previously-unconfigured tenant is visible immediately', async () => {
479491
const rows: Array<{ namespace: string; key: string; scope: string; value: string }> = [];
480492
const ql = makeCountingQl({ sys_setting: rows });
481493
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });

0 commit comments

Comments
 (0)