Uh oh!
There was an error while loading. Please reload this page.
feat(core): cache successful sys_setting localization reads, invalidated synchronously on write - #12694
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Uh oh!
There was an error while loading. Please reload this page.
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.resolveLocalizationContextre-readsys_settingon 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:SettingsService.subscribe('localization', ...)sys_settingdirectlyOS_LOCALIZATION_CACHE_TTL_MS, default 30s,0= offBoth invalidations are synchronous.
emitChangedispatches in-line from the settings write path, afterawait this.upsertRow(...), and the epoch is compared at read time rather than through a listener.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
qlwith no seam is aqlwhose 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.sys_settingis 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
sys_setting"); the substrate that shipped is process-wide.ObjectQL.writeEpochbumps on anyinsert/update/deleteto 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.service-settings"thin consumer touch" turned out to be zero edits. Thesubscribe()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 outsidepackages/coreand the docs table was touched, which also means the declared cross-lane surface was never actually entered.authz-invalidation-bridge.tscallsepoch.bump('remote')), so an attachedauthz.invalidatedbridge narrows cross-node convergence for this leg for free. Pinned.packages/corehas notypecheckscript —pnpm --filter @objectstack/core typecheckwould match zero scripts and exit 0 having measured nothing. Types were measured withtsc -pdirectly, and the two edited test files were confirmed present in the program with--listFilesbefore that result was read as covering them.Acceptance and ablation
analytics-timezone.dogfood.test.tsis 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/dogfoodresolves core throughdist/, so both legs rebuilt@objectstack/coreand both were checked withscripts/ablation-dist-preflight.mjs.expected undefined to be 3expected undefined to be 3That 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.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) atcdf400ea.check:objectql-double-limit, finding: ObjectQL test doubles ignorelimit, 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.node scripts/pm/check-half-states.mjsreturnsPREREQUISITE NOT MET(exit 3) — this container'sGITHUB_TOKENis 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-debtfirst 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