Repository navigation
fix(server): budget counters hold their limit per key, count in a registered cache, and keep the in-process fallback bounded - #29345
Conversation
…registered cache Every budget counter holds its limit under concurrent requests (updates to one key are serialised in-process), counts in the registered shared cache when one is registered (ADR-0069 D2), and its in-process fallback stays bounded. - runtime: SharedTokenBucketLimiter orders the updates of one key (package-local per-key-serial.ts); the dispatcher's inbound and endpoint limiters resolve the cache through package-local kernel-cache.ts. - service-sms, service-automation: the SMS day quota and the notify node's email-field limit resolve the cache through package-local kernel-cache.ts. - core: the in-memory cache fallback evicts expired entries on write. Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
…e SMS quota, the notify limit and the memory cache Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
…l ObjectKernel Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
…tomation and core Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
…indOne through the engine predicate Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
…s tests Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 9 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 — 60 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 8b0b833fa8c311ecaf0e1514b597fa74954f98f4 && git checkout 8b0b833fa8c311ecaf0e1514b597fa74954f98f4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 47718699d6421b44e51a4c497bd8009b7d9fc94f 7789dab0469c6b98fc8d111b3a3aa9f8ee5ebb2f && git checkout -B drift-repro 47718699d6421b44e51a4c497bd8009b7d9fc94f && git merge --no-ff 7789dab0469c6b98fc8d111b3a3aa9f8ee5ebb2f
node scripts/docs-audit/affected-docs.mjs --json 47718699d6421b44e51a4c497bd8009b7d9fc94f
|
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 维护者速读
席位意见:与 diff 相符。帮手按席位裁定在各包本地复制,没有新增导出。替身规则与 #29216 一致(A)。正文只写规则(规则 C)。 席位核对ACCEPT ·
|
…tion at its root The package-root vitest config added for the source aliases sets disableConsoleIntercept: true literally, as every vitest-running package must (#10374, check-console-intercept-disarm). Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 席位核对ACCEPT ·
|
Requested by Steve · project thread
Refs #29053
Clause-②: no
Metadata-protocol: none
维护者速读
cache服务(值注册、工厂注册都能拿到,内核自带的 degraded 内存替身不算共享缓存);core 的内存 cache 兜底在写入时清掉已过期的键,不必等再读一次。packages/runtime/src/security/inbound-rate-limit.ts(SharedTokenBucketLimiter.consume)、packages/runtime/src/security/kernel-cache.ts、packages/core/src/fallbacks/memory-cache.ts的写入清扫。pnpm --filter @objectstack/runtime exec vitest run src/security/inbound-rate-limit-concurrency.test.ts src/security/kernel-cache.test.ts src/dispatcher-plugin.rate-limit-cache.integration.test.ts,预期 3 个测试文件、15 个用例全部通过。What changes
The rule, for every budget counter in the server lane: every budget counter holds its limit under concurrent requests (updates to one key are serialised in-process), counts in the registered shared cache when one is registered (ADR-0069 D2), and its in-process fallback stays bounded. PR #29216 applied it to
@objectstack/plugin-auth; this applies it to the rest.@objectstack/runtimetoken buckets.SharedTokenBucketLimiter.consumeruns its read, refill, spend and write for one key throughserializePerKey(new, package-localsrc/security/per-key-serial.ts). That covers the server-level inbound limiter (server.security.rateLimit) and every declared endpointrateLimit, which share the class. Other keys run concurrently.@objectstack/service-sms) and thenotifynode's per-address email-field limit (@objectstack/service-automation) resolve thecacheservice through a package-localresolveKernelCache(src/security/kernel-cache.ts,src/kernel-cache.ts,src/builtin/kernel-cache.ts): a value registration and a factory registration both answer; the kernel's own in-memory stand-in, which self-describesdegraded(ADR-0076 D12), does not, so those counters keep the bounded per-process fallback and its one-shot warning.@objectstack/corein-memory cache fallback. Expired entries are evicted on write once the map reaches a sweep mark (twice the entries left after the previous sweep, at least 1024), so the map holds at most about twice its live entries without a key having to be read again. No live entry (no TTL, or a TTL still running) is evicted; theICacheServicecontract is unchanged..changeset/29053-server-budget-counters.md, patch for@objectstack/runtime,@objectstack/service-sms,@objectstack/service-automation,@objectstack/core.Local copies, by the seat's decision.
per-key-serial.tsandkernel-cache.tsare copies of the two internal plugin-auth helpers #29216 added (same rule, same code), one per package that needs them. Re-exporting them from plugin-auth's published./rate-limit-storageentry would widen that entry, so they stay internal and are not re-exported from any package.Why
ADR-0069 D2 promises a budget counted once across nodes when a shared cache is registered, and a counter's limit is only a limit if it holds when requests for one key arrive together. #29216 measured both halves for the auth counters; the server lane's other counters follow the same rule.
Tests
packages/runtime/src/security/inbound-rate-limit-concurrency.test.ts(new, 6 cases): concurrent consumes of one key never exceed the budget, in a cache whose calls each cross a macrotask, in the per-process fallback through the middleware, and for a declared endpoint budget; serialisation is per key; a failing update releases the next; nothing is left in flight once the concurrent updates settle.packages/runtime/src/security/kernel-cache.test.ts(new, 7 cases), against realObjectKernelandLiteKernelcontexts: factory, value, none, thedegradedstand-in, a non-store value, and a bucket counted through it lands in the factory-registered cache.packages/runtime/src/dispatcher-plugin.rate-limit-cache.integration.test.ts(new, 2 cases), the composed dispatcher on a realObjectKernelover a socket: with a factory-registered cache the caller's bucket lives in it and the budget holds; with only the stand-in, the stand-in stays empty and the budget still holds.packages/services/service-sms/src/sms-daily-quota-cache.test.ts(new, 2 cases) andpackages/services/service-automation/src/builtin/notify-email-field-cache.integration.test.ts(new, 2 cases), on a realObjectKernel: with a factory-registered cache the counter lives in it and the limit holds; with only the stand-in, the stand-in stays empty and the limit still holds.packages/core/src/fallbacks/memory-cache-bound.test.ts(new, 3 cases): the fallback stays bounded after many distinct keys expire unread; live entries survive every sweep; an expired entry still reads as absent.scripts/engine-double-contract.pinned.jsongains the new notify test's data double (written bycheck-engine-double-contract.mjs --write).Acceptance notes
ICacheServicehas no atomic increment, so two nodes sharing one cache still read-modify-write against each other. Adding one is apackages/speccontract change, outside this lane.ObjectKerneldeployment's runtime buckets move from the stand-in to the bounded per-process fallback, which is just as process-local, and the "no shared cache" warning now fires there.Verification
Final head
0c0c6185(origin/main39951d2amerged inedc9e043; the two commits after it touch only test harness files: the notify pin's data double and its ledger row, andservice-sms'svitest.config.ts). Each reading names the head it was taken on.Tests and typecheck
edc9e043, under the verify lock:turbo run buildof the four packages and their dependencies, exit 0;pnpm --filter PKG test:@objectstack/core130 files / 2912 tests passed;@objectstack/service-sms8 / 109;@objectstack/service-automation260 / 3337;@objectstack/runtime518 files, 8346 passed, 19 skipped.pnpm --filter PKG typecheckexit 0 for core, service-sms and service-automation; runtime's first read was a missing optional-driver build (nodistfor@objectstack/driver-turso), and afterturbo run build --filter='./packages/**'it read exit 0 (check:test-typecheckOK, debt ledger held).a7dd43d1: the notify pin, 2 passed.0c0c6185:@objectstack/service-smstest 8 files / 109 tests passed, typecheck exit 0.Each pin goes red with its check removed (eight ablations, each restored to the HEAD blob).
Dispatch gates and lint
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon0c0c6185derived 70 commands (18 paths, 1005 changed lines, under the 3000-line threshold). All 70 run with stdin from /dev/null and the exit captured before any pipe: 70 exit 0.--ran: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero).check:engine-double-contract(the notify pin's data double now routesfindOnethroughassertEngineFindOnePredicate, and the pinned ledger gained its row via--write) andcheck:test-source-alias(service-smsaliases@objectstack/coreand@objectstack/spec/apito source rather than widening the shrink-only registry).eslint --no-inline-configoverpackages/runtime,packages/core,packages/services/service-sms,packages/services/service-automationonedc9e043: exit 0; overpackages/services/service-smson0c0c6185: exit 0. The repo-wide run is CI's.test:repois not run locally (no file of that project changed); it is CI'sTest Core.Human Minimum
packages/specholds no runtime logic. (No schema touched.)pnpm lintpasses. (eslint over the four touched packages; the repo-wide run is CI's.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.