Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/localization-success-read-cache.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
"@objectstack/core": minor
---

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
(maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0).
`resolveLocalizationContext` re-read `sys_setting` on **every** authenticated
request to answer the same three keys — `timezone` / `locale` / `currency` —
for a workspace whose values change roughly never. That read is now cached.

**Grade: `minor`, not `patch`.** It adds a deployment variable
(`OS_LOCALIZATION_CACHE_TTL_MS`) and changes the query pattern of a shipped code
path. Not `major`: the observable contract callers actually depend on — a
settings write is visible to the very next read — is preserved, and pinned.

Caching this read was tried once before and reverted. #10221's first version
memoized every outcome for 30s and CI went red on
`analytics-timezone.dogfood.test.ts`, which writes a new org timezone and
expects the very next analytics query to bucket under it; the cache was narrowed
to memoize **failures** only. That verdict was on **TTL-only** caching and it
still stands unamended. What changed is that the process now has invalidation
seams it did not have then:

- **Primary — the settings change seam.** `SettingsService.subscribe(ns, handler)`
dispatches synchronously and in-process from the write path, after the row is
persisted. (⚠️ #11633 calls this a "settings change bus"; no such module
exists — `subscribe()` is the seam. No change was needed in
`@objectstack/service-settings`: the seam was already public and already does
exactly this.)
- **Backstop — the engine write epoch** from #11968's substrate. Needed because
this resolver's own fallback reads `sys_setting` *directly*, so a seeder or
any other direct engine write emits no settings event at all. It is read
structurally rather than imported, because `@objectstack/objectql` depends on
`@objectstack/core` and the substrate declared `WriteEpochLike` separately for
exactly this consumer. A peer node's hint arrives as a local bump, so an
attached `authz.invalidated` bridge narrows cross-node convergence for free.
- **TTL** — the residual bound, for what neither seam can see. Default 30s,
`0` disables the cache on a real path rather than a degenerate one.

Two rules carry the change and are pinned rather than merely documented:

1. **A success is cached only when the engine exposes the write epoch.** A `ql`
with no seam is a `ql` whose writes the cache cannot observe, so rather than
degrade to the TTL-only shape that was already reverted here once, the cache
declines. A partial `{ current }` shape is not a seam either — a counter
nothing can bump would read as a live invalidation source and pin the answer
for a whole TTL.
2. **Invalidation retires success entries only.** #10221's failure memo exists
for an environment where `sys_setting` is missing; retiring it on a write
would restart precisely the per-request driver log spam that memo removed,
and no write can create a missing table. It stays TTL-bound and behaves
exactly as #10221/#11877 shipped it.

`analytics-timezone.dogfood.test.ts` is unchanged and unweakened — it is this
leg's acceptance test, and an ablation that reduces the cache to its TTL turns
it red on the same assertion the original revert was recorded against.
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -355,6 +355,7 @@ the hosted ObjectOS Cloud control plane.
| `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. |
| `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). |
| `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. |
| `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. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
24 changes: 18 additions & 6 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,7 +396,10 @@ function makeMissingTableQl() {
// The #2409 batching above already collapsed one request down to a single
// 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
// read on a `ql` that offers no way to learn a write happened. (#11966 later
// added the success cache behind exactly that seam; the failure memo below is
// untouched by it, and a dedicated pin in `resolve-localization-cache.test.ts`
// holds it untouched — retiring it on a write would restart this very spam.) 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),
Expand DownExpand Up@@ -458,7 +461,15 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
// 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 () => {
//
// [#11966] `makeCountingQl` carries no write-epoch seam, and that is now
// load-bearing rather than incidental: leg C caches a success ONLY behind an
// engine that can tell it a write happened, so this double pins the
// seam-ABSENT arm — where the pre-#11966 multiset must survive byte for byte.
// The seam-PRESENT arm is `resolve-localization-cache.test.ts`, which pins
// the same staleness property through the invalidation instead of through the
// absence of a cache.
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 () => {
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }];
const ql = makeCountingQl({ sys_setting: rows });
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
Expand All@@ -472,10 +483,11 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
});

// 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 () => {
// tenant yet) is a successful read too — not a failure — so on a seam-less
// `ql` 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. (#11966: same seam-absent arm as that case.)
it('without a seam, a legitimate empty result is not cached either: 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' });
Expand Down
Loading
Loading