Repository navigation
perf(formula): reuse one CEL environment per entry point instead of building the stdlib on every compile and evaluate - #24629
Conversation
…zone/permissions per evaluation celEngine.evaluate built a fresh Environment and registered the whole stdlib on every call. It now reuses one, and the stdlib reads now(), the timezone and the permission binding from a per-evaluation slot that evaluate saves, sets and restores in a finally. Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude <noreply@anthropic.com>
With the environment reused, parsing and checking were most of what an evaluation still cost. evaluate now runs a bounded per-source cache of cel-js programs on the shared evaluation environment, keyed on the source after the row-dependent rewrites. Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check17 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 9 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 763da2533c3d874d1a4a94eb22b36a0f99e481c8 && git checkout 763da2533c3d874d1a4a94eb22b36a0f99e481c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1e8b6284c0b617a0e971884e4ae04197c55e6dfe 95f434429ab0071ec19d6e1b000e25dedc5a88a6 && git checkout -B drift-repro 1e8b6284c0b617a0e971884e4ae04197c55e6dfe && git merge --no-ff 95f434429ab0071ec19d6e1b000e25dedc5a88a6
node scripts/docs-audit/affected-docs.mjs --json 1e8b6284c0b617a0e971884e4ae04197c55e6dfe |
Contract reviewServed-tier: ① Derived judgmentsBinding isolation
The shared environment: right. A cel-js The program cache
Compile: right. Results: right for every non-re-entrant evaluation, wrong for the shape above. The stdlib drift pin, the unknown-function oracle and the #24478 / #18781 predicate pins are untouched. Gates. The 40 check-runs on the head, latest per name: 34 success, 6 skipped (Build Docs, Console Pin Gate, the Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 0 failing. Latest completion 2026-10-19T18:29Z. The dev's own figures, 141.7 to 3.2 µs, 78 builds to 0 per read, 66 of 66 gate exits and the A1, A2 and B ablations, are claims I did not re-run. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Seat note, appended at posting (2026-10-19T19:20Z). It is not part of the record.
|
cel-js keeps a comprehension's and cel.bind's overlay context, and a call's runtime argTypes, on the AST. A run re-entered from inside its own comprehension could re-parent the outer run's overlay and read the inner run's scope. A run that finds its program already running now parses a private one. Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs:
There is no merge of ① Derived judgmentsThe repair: right. It is the record's "equivalent rule".
A run marks its program in Paths walked against the guard:
The guard does not depend on the slot. It keys on the program, so it holds for an unbound nested evaluation too. That is wider than the record's slot-keyed sketch, and correct. cel-js, re-read for this round:
The cited lines in the new docblock and in the dev's PR-body passage are where they say. The pin has teeth.
Prose: right.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24536. 7 files, +604/−62. Not governed: check-governed-merges matched 0 of 7 paths. |
| Scope | celEngine.evaluate builds no cel-js Environment per call. now, the timezone and the PermissionBinding are bound per evaluation through the unexported activeBinding, restored in a finally. compile reuses canonicalParseEnv. A per-source program cache (500, oldest out first) is keyed on the rewritten source. A program serves one run at a time (runningPrograms), so a nested evaluation of the same source parses a private program. Measured: 141.7 → about 3 µs per evaluate, and 78 → 0 environment builds per 25-row read. |
| Seat's own read | The fix commit's guard was walked on four paths: nested once, nested twice, the outer source evicted while it runs, and the overload retry. The cel-js lines it rests on were checked in the 8.0.0 tarball: reuse re-parents in place (registry.js:1422), iterCtx and bindCtx live on the AST (operators.js:667, macros.js:195), and a Promise value falls to unsupportedType (operators.js:66). The PR body was patched to this head (the dev's passage, the new bullet, the pin, ablation C, and this round's verification). |
| Review | TIER_DEFAULT. Record 6275342648 was FAIL (a cached program is not re-entrant: cel-js keeps comprehension and cel.bind state on the AST; one comment was false). The delta 6276125243 is PASS on this head. |
| Declaration | Clause-②: no on the claim, the PR body and the changeset (@objectstack/formula patch). registerBoundStdLib and StdLibBinding are exported from stdlib.ts only. exports is "." and index.ts is untouched, so no published entry moves. |
| Cross-lane | None. Every path is in packages/formula, this lane's own package. No objection on the card. |
| Gates | Dev, at 1040a3a0c: 66 derived, 66 run, all exit 0. Fix round at 95f434429: the 55 commands derived for its files, all exit 0. Formula suite 51 files / 1367 passed. Core test:repo 82 passed. objectql suite 8414 passed (the fix round did not touch objectql). |
| CI | At this read on 95f434429a: ci-failure GREEN, all 40 check-runs completed, none failed. |
Disposition:
open_questions: both taken as the dev settled them, per record 6275342648 ③ and the delta: (a) the program cache, now built right; (b) the timezone bound per evaluation.deviations: all accepted (record 6275342648 ③ and delta 6276125243 ③). The base record's sentence on Promise continuations is withdrawn by measurement.out_of_scope_findings:collectCelRootIdentifiersbuilding a fresh environment per call meets no filing gate (record ③); not filed.- Non-blocking notes, for the next touch of
packages/formula:unknown-function.tscould linkbuildEnvReadingby name;- the checklist item
api-backend.formula-stdlib-matrixre-countsregisterStdLib's body, and the chain now lives inregisterBoundStdLib; - the program cache has equivalence pins but no presence pin.
Downstream: the repo:cloud#2 seat is pointed at the landing for cloud#3053's measurement.
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24536 then closes the card.
Generated by Claude Code
Fixes #24536
Clause-②: no (an internal cache: no accept set moves, no evaluation result moves, no exported signature changes; owes the seat's review of record before the queue)
Dev session
session_01QNSdgij7mWezAKUbkzSmmR, dispatched by thedomain:engineseat 1 (session_01JtSQQ3EWsbNPzyYMug1tXy) under claim 6272538729.What changes
celEngine.evaluateandcelEngine.compilebuilt a new cel-jsEnvironmenton every call and registered the whole stdlib into it (buildEnv→registerStdLib+registerNumericCoercions). objectql'sapplyFormulaPlanreachesevaluateonce per row per formula field. Three commits change that:79fde52c— one evaluation environment, with a per-evaluation binding slot. The stdlib's closures (now(),today(),daysFromNow(),daysAgo(),can,hasCapability) now read the clock, the timezone and thePermissionBindingthrough a reader (registerBoundStdLib(env, read), package-internal instdlib.ts).cel-engine.tsbuilds the shared environment once with a reader pointed at an unexported module binding,activeBinding.evaluatesaves it, sets its own binding and restores the saved one in afinally. A nested evaluation and a thrown one therefore both leave the outer binding exactly as it was, and no binding outlives its evaluation.canstill answers about the acting subject by IDENTITY: the binding is made from theEvalUserthatbuildScopemints, as before.b5ec0a02—compilereusescanonicalParseEnv(??=), the environmentparseCelToAstalready shares.8eb3b5a8— a bounded per-source program cache on the evaluation environment. Taken because the measurement below shows parsing and checking were most of the remaining cost once the environment was reused.env.parseis handed, AFTERrewriteTemporalEquality(row-dependent),rewriteNullableTernaryand the overload retry'srewriteFaultedOperands. A rewrite decided by the row is part of the key.temporalRewriteCache.95f434429).buildEnvkeeps its signature and builds through the same constructor as the shared environment (buildEnvReading). So the stdlib drift pin and the unknown-function oracle still measure the environment that evaluates.No public surface change.
index.tsis untouched.registerStdLib,registerPermissionPredicateandregisterCapabilityPredicatekeep their signatures, and each still answers from the values its caller pinned: each is now itsbind*twin with a reader that always returns those values.celEnginegains no member. The new namesregisterBoundStdLibandStdLibBindingare exported fromstdlib.tsforcel-engine.tsonly, andindex.tsre-exports neither.What a cel-js
Environmentholds, and what a cached program holdsRead in
@marcbachmann/cel-js8.0.0 (lib/evaluator.js,lib/registry.js,lib/parser.js,lib/type-checker.js,lib/operators.js,lib/macros.js).The environment carries no per-evaluation state:
RootContext, which holds the variable values and the only value-level cache,#convertCache;Parser.parseresets its input and its limit counters on every call;Candidatesmatch and check, type declarations, list, map and optional types,TypeDeclaration#matchesCache) are keyed on types;A parsed program is not free of evaluation state. cel-js keeps per-run state on the AST:
exists,all,exists_one,map,filter) keeps its iteration context on the AST (args.iterCtx,operators.js:667), andcel.bindkeeps its binding context there (bindCtx,macros.js:195);OverlayContext.reuse,registry.js:1423), and every non-iteration variable read inside the step goes through that parent;callFn/callRecFnwrite the arguments' runtime types intoast.argTypeson every call (operators.js:302,:319).In sequential use each of these is overwritten before it is read, so a cached program answers exactly as a fresh parse does. Two runs of ONE AST at once do not: a run re-entered from inside its own comprehension would read the inner run's scope. So a cached program serves one run at a time (
runningPrograms), and a nested evaluation of the same source parses a private program. Runs are synchronous: this environment registers no async function, and cel-js refuses a Promise-valued scope value (Unsupported type: Promise, measured). A cached program also keeps its last evaluation's scope (theRootContextits overlays point at, and theiriterValue/accuValue) reachable until the next evaluation of that source, bounded by the cache's 500 entries.The three inputs that did vary per evaluation (now, timezone, binding) are the ones moved into the slot. Timezone: bound per evaluation, not a cache keyed on it. With the timezone in the slot, one environment serves every zone, so no per-timezone cache exists to bound.
Measurement (M)
Taken on a composition with formula fields:
ObjectQLplusObjectStackProtocolImplementationand a stub driver, 25 rows, three formula fields (joinNonEmpty,record.amount / 1000,date(record.due) less-than today()). The probe was a throwaway vitest file, not committed. It counts cel-jsregisterFunction('now(): …')(stdlib builds) andregisterOperator('double + int')(buildEnvbuilds). The clock is a shared box, so read the ratios, not the absolutes.origin/mainc03b17db5ec0a02)8eb3b5a8)evaluate(20,000 evaluations of one source)celEngine.evaluate, one source × 20,000celEngine.compile, one source × 20,000The served body of that read (
protocol.findData, 3,625 bytes of JSON) is byte-identical before and after: compared as strings,true.The program cache was taken on this reading. After the environment reuse, about 6 of the remaining 9 µs was parse and check, against under 1 µs of evaluation.
Pins
packages/formula/src/cel-env-reuse.test.tscounts cel-jsEnvironmentconstructions through avi.mocksubclass, so every path is seen.buildEnvis counted.evaluateof one source builds 0.now, timezone, subject, permissions and capabilities builds 0.compilebuilds 0.inmembership, and==against a Date-valued binding #7168 binding rewrite on alternating rows keeps each row's own answer, and so does the overload retry on alternating rows. Parse, check, runtime and unknown-function faults are reported identically on every evaluation.packages/formula/src/cel-eval-binding-isolation.test.ts:canandhasCapabilityexactly as abuildEnvenvironment with no binding does. Samekind, same message, including after a THROWN evaluation.now()and timezone.nowand the timezone are each evaluation's own.packages/formula/src/cel-program-reentry.test.ts(fix round 1,95f434429): the same source, nested through a scope getter that evaluates it for another subject insideexists(…),map(…)andcel.bind(…), answers for the outer subject, equal to a freshbuildEnvparse, with the cache warmed first. Red on1040a3a0c(3 failed / 1 passed).Ablations, each red and then restored. Each used
node scripts/ablation-replace.mjs: anchor hit ×1 → ×0, blob moved, restore blob == HEAD5c934212c2f8andgit diff HEADempty. The formula suite imports./cel-enginefromsrc(nodiston the path), so no rebuild leg applies.(evaluationEnv ??= buildEnvReading(…))→buildEnvReading(…))repeated evaluations of one source build no new environment,what varies per evaluation … builds no environment either: 2 failed / 17 passed(canonicalParseEnv ??= buildEnv(…))→buildEnv(…))repeated compiles build no new environment: 1 failed / 9 passedfinallyrestore dropped (activeBinding = outer;deleted)else if (runningPrograms.has(program))→else if (false), fix round 1)B's first attempt passed an empty
--replacement, which the tool refused (the replacement count moved 0 -> 0) and restored. It was re-run with--delete, and that run is the one cited.Pin sweep. I grepped every test that names
buildEnv,registerStdLib,registerPermissionPredicate,registerCapabilityPredicateorPermissionBinding, plus the predicates' refusal messages (ACTING SUBJECT,carries no permission data,carries no capability list):cel-stdlib-drift,unknown-function,validate,permission-predicate,capability-predicate;engine-formula-default-permission,engine-option-permission-predicate,validation/rule-validator.option-visibility, and everyengine-formula-*.None moved. All of them ran green unedited in the full suites below, the stdlib drift pin and the unknown-function oracle included.
Verification
All runs are at HEAD
1040a3a0c(after mergingorigin/main8fbbd5082) unless noted.pnpm --filter @objectstack/formula typecheck(tsc --noEmit+check:test-typecheck): exit 0, before and after the merge.pnpm --filter @objectstack/formula test: 50 files, 1363 tests passed, exit 0 (post-merge).95f434429:pnpm --filter @objectstack/formula test51 files, 1367 passed; formula typecheck and build exit 0;pnpm --filter @objectstack/core test:repo82 passed; the 55 gate commands derived for this round's files all exit 0. The other 11 of the 66 read only the changeset, which this round did not touch.pnpm --filter @objectstack/objectql test(--project local): 447 files, 8414 tests passed, exit 0 (at826ce6ae, pre-merge; the merge touched neither package).pnpm --filter @objectstack/objectql test:repo: 1 file, 5 tests, exit 0.pnpm --filter @objectstack/core test:repo: 6 files, 82 tests, exit 0.pnpm --filter '@objectstack/objectql...' build: exit 0 (post-merge).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 66 commands. All 66 ran with exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, nodist/); afterpnpm buildit was re-run and exited 0. Reconciliation:✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3).Acceptance notes
collectCelRootIdentifiers(cel-engine.ts) still builds a fresh environment per call:buildEnv(() => new Date(0)).parse(…). Its runtime callers include objectql'srule-validator.tsand plugin-approvals'approval-service.ts. It sits outside this card's file surface (buildEnv,compile,evaluate) and was not measured here.canonicalParseEnvis the same reuse shape. Carrier: none.Generated by Claude Code