Skip to content

feat(core): cache successful sys_setting localization reads, invalidated synchronously on write - #12694

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-11966-localization-read-cache
Aug 27, 2026
Merged

feat(core): cache successful sys_setting localization reads, invalidated synchronously on write#12694
os-zhuang merged 3 commits into
mainfrom
claude/issue-11966-localization-read-cache

Conversation

@claude

@claudeclaudeBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#11966

Leg C (ship-first) of the accepted #11633 cross-request caching design — maintainer acceptance #11633 comment 5404681591, 2026-08-25, forks 1A / 2B / 3A / TTL-0. Routing per triage's re-ruling (#11966 comment 5421154211): the fix lands in packages/core.

resolveLocalizationContext re-read sys_setting on every authenticated request to answer three values — timezone / locale / currency — for a workspace whose values change roughly never. That read is now cached, behind invalidation that is synchronous and in-process rather than TTL-bound.

Why this is not the change that was reverted once already

#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 failures only, and that function's docblock has carried the reason ever since. 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:

seamcoverswhat it cannot see
PrimarySettingsService.subscribe('localization', ...)every write through the settings servicea write that never goes through it
Backstopthe #11968 engine write epochany engine write, including a seeder writing sys_setting directlya write on another replica
ResidualOS_LOCALIZATION_CACHE_TTL_MS, default 30s, 0 = offthe remaindernothing — it is the bound

Both invalidations are synchronous. emitChange dispatches in-line from the settings write path, afterawait this.upsertRow(...), and the epoch is compared at read time rather than through a listener.

⚠️There is no module called a "settings change bus."#11633's phrase for the primary seam maps to nothing in the tree; subscribe() is the seam. This is triage's carried correction, and it is recorded in the code so the next reader does not go hunting either.

Two rules carry the change, and both are pinned rather than 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 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. A welcome consequence: every existing test double keeps its exact query multiset, so the pre-existing pins keep their meaning instead of being rewritten around a new cache.
  2. Invalidation retires SUCCESS entries only. This is the card's own must-not-fight constraint, and it has a sharp edge: 新环境日志被 sys_setting 'no such table' ERROR 刷屏:本地化读取先于建表,真错误被噪音淹没 #10221's failure memo exists for an environment where sys_setting is missing, so 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. The failure memo stays purely TTL-bound and behaves exactly as 新环境日志被 sys_setting 'no such table' ERROR 刷屏:本地化读取先于建表,真错误被噪音淹没 #10221/resolveLocalizationContext's 30s failure cache can answer a deliberate post-bind re-read with the pre-bind value #11877 shipped it.

Measurements that differ from what the brief and the design predicted

  • The design predicted an object-scoped backstop ("engine-seam write to sys_setting"); the substrate that shipped is process-wide.ObjectQL.writeEpoch bumps on any insert/update/delete to any object and carries nothing about which entry to drop, by deliberate design (Design: cross-request caching for the authenticated request path (tranche 2 of #10757) — write-invalidation-first, short-TTL fallback, configurable staleness window #11633 §2.2, Fork 1 → A). So the backstop over-invalidates: any engine write retires the localization entry. That costs hit rate, never correctness, and narrowing it is the keyed-invalidation optimisation the ruling explicitly defers.
  • The service-settings "thin consumer touch" turned out to be zero edits. The subscribe() seam is already public and already does exactly what leg C needs, and the change event's lack of a tenant discriminator is handled the way §2.3 rules — drop the whole bucket. No file outside packages/core and the docs table was touched, which also means the declared cross-lane surface was never actually entered.
  • A peer node's hint arrives as a local epoch bump (authz-invalidation-bridge.ts calls epoch.bump('remote')), so an attached authz.invalidated bridge narrows cross-node convergence for this leg for free. Pinned.
  • packages/corehas no typecheck scriptpnpm --filter @objectstack/core typecheck would match zero scripts and exit 0 having measured nothing. Types were measured with tsc -p directly, and the two edited test files were confirmed present in the program with --listFiles before that result was read as covering them.

Acceptance and ablation

analytics-timezone.dogfood.test.ts is unchanged and unweakened (design §7 pin 8). It passes against the cached path: 2/2 green.

A green acceptance test does not by itself prove the cache is engaged — so the ablation is what carries that claim.

Ablation C (the ruled one: remove both invalidations, leave only the TTL). Direction and exact count were written down before anything was mutated. @objectstack/dogfood resolves core through dist/, so both legs rebuilt @objectstack/core and both were checked with scripts/ablation-dist-preflight.mjs.

predictedobserved
directionREDRED
count1 of 2, the second test1 failed, 1 passed — the second
assertionexpected undefined to be 3expected undefined to be 3
unit pins7 failing, by name7 failing, the same 7

That assertion string is the failure recorded verbatim in this function's own #10221 docblock, from the CI run that caused the original narrowing. Because ablation C turns the dogfood test red, the cache is proven engaged in that stack — a cache that never cached would have left it green.

Ablation B (remove the seam detection only) turns 7 unit pins red for the opposite reason — the cache declines entirely, so the caching assertions fail while no staleness assertion does — which is what makes the identity pin's zero-read half discriminating rather than decorative. Predicted 7 by identity; observed those 7. Restore legs were proven by comparing the worktree blob hash against the HEAD blob, not by a clean exit code.

Verification

Gate union derived from the actual changed paths with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (48 commands: 43 path-matched families plus 5 convention-triggered). Exit codes captured before any pipe.

  • Green at final commit cdf400ea: check:objectql-double-limit, check:where-matcher, check:engine-double-contract, check:type-check-debt, check:type-check-coverage, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:kernel-hook-pairs, check:page-declaration-shape, check:published-files, check:slot-lookup, check:authz-resolver, and the docs/changeset families.
  • pnpm --filter @objectstack/core exec vitest run src/security/485 passed (19 files) at cdf400ea.
  • Two gates caught real defects in this PR's own test double and are fixed in their own commits: it was limit-blind (check:objectql-double-limit, finding: ObjectQL test doubles ignore limit, so a limit regression is invisible to every suite that uses one #10978) and read a combinator as a field name (check:where-matcher). Both now conform by refusing the unsupported shape.
  • NOT MEASURED, named:node scripts/pm/check-half-states.mjs returns PREREQUISITE NOT MET (exit 3) — this container's GITHUB_TOKEN is a proxy placeholder, not a GitHub credential, so it swept nothing. That is neither a clean board nor a dirty one; it is no reading. check:type-check-debt first hit a 240s harness timeout (recorded as NOT MEASURED, not as red) and is green on re-run with a longer budget.

Draft on purpose: not enqueued, auto-merge not enabled.


Generated by Claude Code

…ted 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
…ouble (#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
#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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 16 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via expiresAt (symbol))
  • content/docs/kernel/contracts/auth-service.mdx(via expiresAt (symbol))
  • content/docs/permissions/authentication.mdx(via expiresAt (symbol))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0d4a6a85b5e027b41f6833b66e40e2733a4fc769packageMentionDocs.

Which tree this was computed on

This run read content/docs from 779afeddd1747020b08052406b46af52c0f413e6 — the merge of head cdf400eade21947be0d8cf6bc1181e1a05d218e2 into base 0d4a6a85b5e027b41f6833b66e40e2733a4fc769, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 779afeddd1747020b08052406b46af52c0f413e6 && git checkout 779afeddd1747020b08052406b46af52c0f413e6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d4a6a85b5e027b41f6833b66e40e2733a4fc769 cdf400eade21947be0d8cf6bc1181e1a05d218e2 && git checkout -B drift-repro 0d4a6a85b5e027b41f6833b66e40e2733a4fc769 && git merge --no-ff cdf400eade21947be0d8cf6bc1181e1a05d218e2
node scripts/docs-audit/affected-docs.mjs --json 0d4a6a85b5e027b41f6833b66e40e2733a4fc769

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0d4a6a85b5e027b41f6833b66e40e2733a4fc769 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authz caching leg C: cache successful localization reads (sys_setting) — settings-bus invalidation, engine-seam backstop, TTL bound

2 participants

@os-zhuang@claude