Repository navigation
Commit 4840720
perf(formula): reuse one CEL environment per entry point instead of building the stdlib on every compile and evaluate (#24629)
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 the
`domain:engine` seat 1 (`session_01JtSQQ3EWsbNPzyYMug1tXy`) under claim
6272538729.
## What changes
`celEngine.evaluate` and `celEngine.compile` built a new cel-js
`Environment` on every call and registered the whole stdlib into it
(`buildEnv` → `registerStdLib` + `registerNumericCoercions`). objectql's
`applyFormulaPlan` reaches `evaluate` once per row per formula field.
Three commits change that:
1. **`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 the `PermissionBinding` through a reader
(`registerBoundStdLib(env, read)`, package-internal in `stdlib.ts`).
`cel-engine.ts` builds the shared environment once with a reader pointed
at an unexported module binding, `activeBinding`. `evaluate` saves it,
sets its own binding and restores the saved one in a `finally`. A nested
evaluation and a thrown one therefore both leave the outer binding
exactly as it was, and no binding outlives its evaluation. `can` still
answers about the acting subject by IDENTITY: the binding is made from
the `EvalUser` that `buildScope` mints, as before.
2. **`b5ec0a02` — `compile` reuses `canonicalParseEnv`** (`??=`), the
environment `parseCelToAst` already shares.
3. **`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.
- **Key:** the source `env.parse` is handed, AFTER
`rewriteTemporalEquality` (row-dependent), `rewriteNullableTernary` and
the overload retry's `rewriteFaultedOperands`. A rewrite decided by the
row is part of the key.
- **Bound:** 500 entries, oldest out first, the same shape as
`temporalRewriteCache`.
- **Invalidation:** none is needed. Every entry belongs to the one
evaluation environment, whose registrations never change after it is
built. A source that does not parse is never stored, so it is reparsed
and refused every time.
- **One run at a time:** a cached program is marked while it runs, and a
nested evaluation of the same source parses a private program (commit
`95f434429`).
`buildEnv` keeps 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.ts` is untouched. `registerStdLib`,
`registerPermissionPredicate` and `registerCapabilityPredicate` keep
their signatures, and each still answers from the values its caller
pinned: each is now its `bind*` twin with a reader that always returns
those values. `celEngine` gains no member. The new names
`registerBoundStdLib` and `StdLibBinding` are exported from `stdlib.ts`
for `cel-engine.ts` only, and `index.ts` re-exports neither.
## What a cel-js `Environment` holds, and what a cached program holds
Read in `@marcbachmann/cel-js` 8.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:
- each evaluation builds its own `RootContext`, which holds the variable
values and the only value-level cache, `#convertCache`;
- `Parser.parse` resets its input and its limit counters on every call;
- the registry's caches (`Candidates` match and check, type
declarations, list, map and optional types,
`TypeDeclaration#matchesCache`) are keyed on types;
- the registry locks only when it is cloned, and nothing registers on
the shared environment after it is built.
**A parsed program is not free of evaluation state.** cel-js keeps
per-run state on the AST:
- a comprehension (`exists`, `all`, `exists_one`, `map`, `filter`) keeps
its iteration context on the AST (`args.iterCtx`, `operators.js:667`),
and `cel.bind` keeps its binding context there (`bindCtx`,
`macros.js:195`);
- each run re-parents that overlay to its own scope
(`OverlayContext.reuse`, `registry.js:1423`), and every non-iteration
variable read inside the step goes through that parent;
- `callFn` / `callRecFn` write the arguments' runtime types into
`ast.argTypes` on 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 (the `RootContext` its overlays point
at, and their `iterValue` / `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: `ObjectQL` plus
`ObjectStackProtocolImplementation` and 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-js `registerFunction('now(): …')`
(stdlib builds) and `registerOperator('double + int')` (`buildEnv`
builds). The clock is a shared box, so read the ratios, not the
absolutes.
| reading | `origin/main` c03b17d | after env reuse (`b5ec0a02`) |
after program cache (`8eb3b5a8`) |
|---|---|---|---|
| environment builds per `evaluate` (20,000 evaluations of one source) |
20,000 (1 each) | 0 | 0 |
| environment builds per read (25 rows × 3 formulas) | 78 | 0 | 0 |
| µs per `celEngine.evaluate`, one source × 20,000 | 141.7 | 9.1 | 3.2 |
| µs per `celEngine.compile`, one source × 20,000 | 159.2 | 5.2 | 7.7
(no change on this path, box noise) |
| reference: bare cel-js parse+evaluate on a prebuilt env / evaluate of
a parsed program | 6.9 / 0.76 | 6.2 / 0.88 | 6.7 / 0.55 |
The 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.ts` counts cel-js
`Environment` constructions through a `vi.mock` subclass, so every path
is seen.
- A positive control: `buildEnv` is counted.
- Repeated `evaluate` of one source builds 0.
- Varying `now`, timezone, subject, permissions and capabilities builds
0.
- Repeated `compile` builds 0.
- The program cache: the #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`:
- An evaluation with no subject, after one with a subject, answers `can`
and `hasCapability` exactly as a `buildEnv` environment with no binding
does. Same `kind`, same message, including after a THROWN evaluation.
- An evaluation nested inside another leaves the outer binding as it
was. The nesting is a scope getter that runs a whole inner evaluation,
and it is tested when the inner one returns, when it throws, and for the
outer `now()` and timezone.
- `now` and 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 inside `exists(…)`, `map(…)` and
`cel.bind(…)`, answers for the outer subject, equal to a fresh
`buildEnv` parse, with the cache warmed first. Red on `1040a3a0c` (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 == HEAD `5c934212c2f8` and `git diff HEAD` empty. The formula suite
imports `./cel-engine` from `src` (no `dist` on the path), so no rebuild
leg applies.
| ablation | red |
|---|---|
| A1 — evaluation env reuse removed (`(evaluationEnv ??=
buildEnvReading(…))` → `buildEnvReading(…)`) | `repeated evaluations of
one source build no new environment`, `what varies per evaluation …
builds no environment either`: 2 failed / 17 passed |
| A2 — compile env reuse removed (`(canonicalParseEnv ??= buildEnv(…))`
→ `buildEnv(…)`) | `repeated compiles build no new environment`: 1
failed / 9 passed |
| B — the `finally` restore dropped (`activeBinding = outer;` deleted) |
all three nested-evaluation pins: 3 failed / 6 passed |
| C — the one-run-at-a-time guard dropped (`else if
(runningPrograms.has(program))` → `else if (false)`, fix round 1) | the
three nested same-source pins: 3 failed / 1 passed |
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`,
`registerCapabilityPredicate` or `PermissionBinding`, plus the
predicates' refusal messages (`ACTING SUBJECT`, `carries no permission
data`, `carries no capability list`):
- formula: `cel-stdlib-drift`, `unknown-function`, `validate`,
`permission-predicate`, `capability-predicate`;
- objectql: `engine-formula-default-permission`,
`engine-option-permission-predicate`,
`validation/rule-validator.option-visibility`, and every
`engine-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 merging `origin/main`
`8fbbd5082`) 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).
- Fix round 1, at `95f434429`: `pnpm --filter @objectstack/formula test`
51 files, 1367 passed; formula typecheck and build exit 0; `pnpm
--filter @objectstack/core test:repo` 82 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 (at `826ce6ae`, 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-loads` first exited 3 (PREREQUISITE
NOT MET, no `dist/`); after `pnpm build` it 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's `rule-validator.ts` and
plugin-approvals' `approval-service.ts`. It sits outside this card's
file surface (`buildEnv`, `compile`, `evaluate`) and was not measured
here. `canonicalParseEnv` is the same reuse shape. Carrier: none.
- The per-timezone cache the card offered as an alternative was not
built: the timezone is bound per evaluation instead (above).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7c7741d commit 4840720
7 files changed
Lines changed: 604 additions & 62 deletions
File tree
- .changeset
- packages/formula/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
21 | | - | |
| 20 | + | |
| 21 | + | |
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
61 | | - | |
| 61 | + | |
| 62 | + | |
62 | 63 | | |
63 | 64 | | |
64 | 65 | | |
| |||
74 | 75 | | |
75 | 76 | | |
76 | 77 | | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
77 | 88 | | |
78 | | - | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
79 | 197 | | |
80 | 198 | | |
81 | 199 | | |
| |||
391 | 509 | | |
392 | 510 | | |
393 | 511 | | |
394 | | - | |
395 | | - | |
396 | | - | |
397 | | - | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
398 | 515 | | |
399 | 516 | | |
400 | 517 | | |
| |||
1747 | 1864 | | |
1748 | 1865 | | |
1749 | 1866 | | |
1750 | | - | |
1751 | | - | |
1752 | | - | |
| 1867 | + | |
| 1868 | + | |
| 1869 | + | |
1753 | 1870 | | |
1754 | 1871 | | |
1755 | 1872 | | |
| |||
1799 | 1916 | | |
1800 | 1917 | | |
1801 | 1918 | | |
1802 | | - | |
| 1919 | + | |
1803 | 1920 | | |
1804 | 1921 | | |
1805 | 1922 | | |
1806 | | - | |
1807 | | - | |
1808 | | - | |
1809 | | - | |
1810 | | - | |
1811 | | - | |
1812 | | - | |
1813 | | - | |
1814 | | - | |
1815 | | - | |
1816 | | - | |
1817 | | - | |
1818 | | - | |
1819 | | - | |
1820 | | - | |
1821 | | - | |
1822 | | - | |
| 1923 | + | |
| 1924 | + | |
| 1925 | + | |
| 1926 | + | |
| 1927 | + | |
| 1928 | + | |
| 1929 | + | |
| 1930 | + | |
| 1931 | + | |
| 1932 | + | |
| 1933 | + | |
1823 | 1934 | | |
1824 | | - | |
1825 | | - | |
1826 | | - | |
1827 | | - | |
1828 | | - | |
1829 | | - | |
1830 | | - | |
1831 | | - | |
1832 | | - | |
1833 | | - | |
1834 | | - | |
1835 | | - | |
1836 | | - | |
1837 | | - | |
1838 | | - | |
1839 | | - | |
1840 | | - | |
1841 | | - | |
| 1935 | + | |
| 1936 | + | |
| 1937 | + | |
| 1938 | + | |
| 1939 | + | |
| 1940 | + | |
| 1941 | + | |
| 1942 | + | |
| 1943 | + | |
| 1944 | + | |
| 1945 | + | |
| 1946 | + | |
1842 | 1947 | | |
1843 | | - | |
| 1948 | + | |
1844 | 1949 | | |
1845 | | - | |
1846 | | - | |
1847 | | - | |
1848 | | - | |
| 1950 | + | |
| 1951 | + | |
| 1952 | + | |
| 1953 | + | |
| 1954 | + | |
| 1955 | + | |
| 1956 | + | |
| 1957 | + | |
| 1958 | + | |
| 1959 | + | |
| 1960 | + | |
| 1961 | + | |
| 1962 | + | |
| 1963 | + | |
| 1964 | + | |
| 1965 | + | |
| 1966 | + | |
| 1967 | + | |
| 1968 | + | |
| 1969 | + | |
| 1970 | + | |
| 1971 | + | |
| 1972 | + | |
| 1973 | + | |
1849 | 1974 | | |
| 1975 | + | |
| 1976 | + | |
1850 | 1977 | | |
1851 | 1978 | | |
1852 | 1979 | | |
| |||
0 commit comments