Skip to content

Run typical scripts about 48× faster by copying only the data they read - #13

Merged
NetWin merged 7 commits into
incrementfrom
perf/lazy-data
Oct 21, 2026
Merged

NetWin merged 7 commits into
incrementfrom
perf/lazy-data

Conversation

@NetWin

@NetWin NetWin commented Oct 21, 2026

Copy link
Copy Markdown
Owner

Summary

A typical script reads one or two flat keys of a large contact record, such as return company || firstName. On a 75 KB record, that run drops from 0.762 ms to 0.016 ms, about 48× faster, and allocates 0.07 MB instead of 1.24 MB, about 18× less.

Almost all of the old cost was copying the whole of data into the sandbox before the first statement ran. A run now copies a top-level key only when the script first reads it, and every copy is cheaper. An opt-in AST cache per instance also skips parsing for scripts that run again.

Changes

  • data is a plain object only. A JSON string is no longer parsed as data, and run types data as object. A host that holds JSON text parses it first; the playground workers now do.
  • Faster defineOwn. When nothing on the prototype chain answers to a key, the property is created by assignment instead of Object.defineProperty. __proto__, inherited names and every other key keep the old path. This speeds up every copy across the boundary, object literals, spread, Object.fromEntries and JSON.parse.
  • Data keys are copied on first read. Each top-level key that holds an object, array or function is copied the first time the script reads it, at most once per run. Primitives are bound at once. All keys of a run share one conversion, so shared values and cycles stay intact.
  • Opt-in AST cache. new Maple({ astCache: true }) keeps the syntax trees of up to 200 scripts, keyed by their source text; { maxEntries } sets another limit, and the least recently used script is dropped when full. maple.clearAstCache() empties it. Cached trees are deep-frozen, and code that does not parse is not kept.
  • measure-run. The playground's measure-memory script is now measure-run. It parses the context once, times runs in a separate loop (median, min, max), compares with js-interpreter, and takes --ast-cache. A synthetic contact record and scripts from a one-liner to a 120-line summary come with it.

Behaviour changes for hosts

  • run(code, '{"a":1}') throws TypeError: data must be a plain object.
  • An unsupported value inside a data key (a class instance, a typed array, data nested deeper than 1000 levels) no longer throws a TypeError before the run. It ends the run with INTERNAL_ERROR at the identifier that reads it, with the same message and the TypeError as cause; the script cannot catch it. A key the script never reads goes unnoticed. A symbol or bigint directly under data still throws before the run.
  • The keys of data, and what each holds, are taken when the run starts. A change the host makes inside a value before the script first reads it is seen.

docs/boundary.md, docs/api.md and docs/security.md describe the new behaviour.

Measurements

measure-run on the synthetic contact record (46 flat fields, address, 450 activities, 300 customFields, about 75 KB), Maple median of 500 runs. increment is a fresh checkout of 0804400 measured with the same script and fixture.

Script increment This branch Speedup Allocated
return company || firstName 0.762 ms 0.016 ms ~48× 1.24 → 0.07 MB
return activities.length 0.766 ms 0.290 ms 2.6× 1.22 → 0.75 MB
10-line script 1.024 ms 0.521 ms 2.0× 2.16 → 1.71 MB
120-line contact summary 2.69 ms 2.37 ms 1.14× 9.9 → 9.5 MB
120-line contact summary, --ast-cache 2.69 ms 2.24 ms 1.2× 9.9 → 9.9 MB

The less of data a script reads, the larger the gain. A flat-key read no longer pays for the record at all. A script that reads one large key still copies that key, but 2.6× faster than before. The contact summary reads every key, so the large ones are still copied and most of its time is evaluation; even there, Maple goes from 5.2× to 5.9× faster than js-interpreter.

The AST cache saves about 9 µs on a one-liner, 47 µs on the 10-line script and 80 µs on the summary. measure-run runs Node with an enlarged young generation to measure allocation, so the times are for comparing before and after on one machine.

Notes for review

  • defineOwn assigns through a cast that typescript/no-unsafe-type-assertion rejects, so that line carries a disable comment with its reason. Reflect.set avoids the cast but gave away about half the gain.
  • contact-summary.js disables eslint/object-shorthand and unicorn/no-array-sort for the whole file, so js-interpreter can run it.

Checks

pnpm format && pnpm lint && pnpm typecheck && pnpm test passes: 698 tests in packages/maple, 738 in packages/maple-parity.

measure-run parses the context once, before any run, so both engines receive a plain object as a
host's run call does. A second loop without forced collections reports median, min and max time,
and a closing line says how many times faster or slower Maple is than js-interpreter.

A synthetic contact record (46 flat fields, address, 450 activities, 300 custom fields) with three
small scripts replaces context.json and demo-script.js as the default fixture. runJsInterpreter
takes parsed data; the Benchmark worker parses inside its timed runs, as before.
A JSON string is no longer parsed as data, and run types data as object. A host
that holds JSON text parses it first; the playground's workers now do, and the
Benchmark worker parses once, outside the timed runs, for both engines.
When nothing on the chain answers to a key, assignment creates the same own
data property as defineProperty, and no setter can run, so defineOwn assigns
directly; every other key, __proto__ and inherited names included, keeps
defineProperty. Copying the 75 KB contact record into a run drops from about
0.77 to 0.34 ms and from 1.22 to 0.85 MB allocated, and a 2,000-item
filter/map/reduce from 8.0 to 5.6 ms.
Each top-level key of data that holds an object, array or function is copied
when the script first reads it, at most once per run; primitives are bound at
once. All keys of a run share one conversion, so shared values and cycles stay
intact. An unsupported value inside a key now ends the run with INTERNAL_ERROR
at the read instead of a TypeError before the run, and one the script never
reads goes unnoticed. A one-key read of the 75 KB contact record drops from
0.34 to 0.04 ms and from 0.87 to 0.09 MB allocated.
new Maple({ astCache: true }) keeps the syntax trees of up to 200 scripts,
keyed by their source text, so a script that runs again is not parsed again;
{ maxEntries } sets another limit, the least recently used script is dropped
when full, and maple.clearAstCache() empties it. Trees are deep-frozen before
they are kept, code that does not parse is not kept, and nested runs share the
cache. measure-run gains --ast-cache, which reuses one instance: a one-line
script drops from 0.017 to 0.008 ms, a 10-line script from 0.55 to 0.51 ms.
contact-summary.js builds a CRM summary from the contact fixture and reads
every key, activities and customFields included, so it measures a run in which
nothing is left unread.
Copilot AI lite review requested due to automatic review settings October 21, 2026 18:29
@NetWin NetWin self-assigned this Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

📋 Release Preview

Changelog

0.4.0 (2026-10-21)

🚀 Features

  • builtins: pass thisArg to array, Map and Set callbacks (80e5ccb)
  • evaluator: support this in script functions (f90237d)
  • input: accept data only as a plain object (88dfd69)
  • json: give a JSON replacer its holder as this (d1c1e20)
  • json: give a JSON reviver its holder as this (7ed2e5f)
  • parse: cache syntax trees per instance when enabled (e53f816)

🩹 Bug Fixes

  • playground: reject an array context before either engine runs (f037c65)

⚡ Performance Improvements

  • boundary: assign new own properties directly in defineOwn (6728bff)
  • input: convert data keys on first read (ce33aa2)

📖 Documentation

  • contributing: update conventional commit instructions (8cc9c43)

🔧 Miscellaneous Chores

  • playground: add a contact summary script to measure-run (ae94d6d)
  • playground: rename measure-memory to measure-run and add timing (54e170e)

@NetWin
NetWin marked this pull request as ready for review October 21, 2026 18:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Moderate boundary and AST-cache validation issues remain unresolved.

3 open issues
What changed in this PR

Optimizes Maple by lazily copying accessed data keys and adding optional bounded AST caching.

Changes:

  • Adds lazy, shared host-value conversion.
  • Adds configurable frozen AST caching.
  • Updates playground benchmarks, fixtures, documentation, and tests.
File Summary Review notes
packages/​maple/​src/​support/​own-property.ts Optimizes own-property creation. —
packages/​maple/​src/​support/​own-property.spec.ts Tests property behavior. —
packages/​maple/​src/​spec-helpers.ts Updates runner data typing. —
packages/​maple/​src/​maple.ts Implements lazy data and AST caching. Moderate: preserve null for maxEntries validation (1 vote).
Moderate: avoid masking host getter TypeErrors (1 vote).
packages/​maple/​src/​maple.spec.ts Tests data validation. —
packages/​maple/​src/​host-functions.spec.ts Updates test helper typing. —
packages/​maple/​src/​evaluator/​scope.ts Supports lazy binding kinds. —
packages/​maple/​src/​evaluator/​scope.spec.ts Tests lazy binding kinds. —
packages/​maple/​src/​evaluator/​operators.spec.ts Updates evaluator test inputs. —
packages/​maple/​src/​evaluator/​objects.spec.ts Updates evaluator test inputs. —
packages/​maple/​src/​evaluator/​globals.spec.ts Updates evaluator test inputs. —
packages/​maple/​src/​evaluator/​evaluator.ts Reports lazy conversion failures. —
packages/​maple/​src/​evaluator/​destructuring.spec.ts Updates boundary cases. —
packages/​maple/​src/​evaluator/​control-flow.spec.ts Updates boundary cases. —
packages/​maple/​src/​escapes.spec.ts Updates boundary and escape tests. —
packages/​maple/​src/​errors.spec.ts Updates test helper typing. —
packages/​maple/​src/​builtins/​string/​string.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​set/​set.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​regexp/​regexp.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​object/​object.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​number/​number.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​math/​math.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​map/​map.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​locale.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​json/​json.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​iterator/​iterator.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​globals.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​function/​function.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​date/​date.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​boolean/​boolean.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​builtins/​array/​array.spec.ts Updates builtin test helpers. —
packages/​maple/​src/​boundary/​lazy-data.spec.ts Tests lazy data conversion. —
packages/​maple/​src/​boundary/​from-host.ts Adds shared host conversion. —
packages/​maple/​src/​ast-cache.ts Implements bounded AST caching. —
packages/​maple/​src/​ast-cache.spec.ts Tests AST cache behavior. Nit: use “an astCache option” (2 votes).
packages/​maple/​README.md Documents public API changes. —
packages/​maple/​docs/​security.md Documents isolation guarantees. —
packages/​maple/​docs/​boundary.md Documents boundary semantics. Nit: clarify that the changed value “is seen” (4 votes).
packages/​maple/​docs/​api.md Documents caching and data behavior. —
apps/​maple-playground/​src/​app/​data-access/​maple.worker.ts Parses worker context before execution. —
apps/​maple-playground/​src/​app/​data-access/​maple-worker-protocol.ts Adds context parsing. Moderate: reject arrays before running either engine (3 votes).
apps/​maple-playground/​src/​app/​data-access/​js-interpreter-engine.ts Accepts parsed data. —
apps/​maple-playground/​src/​app/​data-access/​benchmark.worker.ts Parses context once per benchmark. —
apps/​maple-playground/​scripts/​measure-run.ts Adds timing and cache measurements. Nit: change Pnpm to pnpm (1 vote).
apps/​maple-playground/​scripts/​demo-script.js Removes obsolete fixture. —
apps/​maple-playground/​scripts/​context.json Removes obsolete fixture. —
apps/​maple-playground/​scripts/​contact-ten-lines.js Adds benchmark script. —
apps/​maple-playground/​scripts/​contact-summary.js Adds benchmark script. —
apps/​maple-playground/​scripts/​contact-nested-key.js Adds benchmark script. —
apps/​maple-playground/​scripts/​contact-large-key.js Adds benchmark script. —
apps/​maple-playground/​scripts/​contact-flat-key.js Adds benchmark script. —
apps/​maple-playground/​scripts/​contact-context.json Adds benchmark fixture data. —
apps/​maple-playground/​README.md Documents the new benchmark tool. —
apps/​maple-playground/​package.json Renames the benchmark command. —
AGENTS.md Updates benchmark script guidance. —

Review effort: Lite

Comment thread apps/maple-playground/src/app/data-access/maple-worker-protocol.ts Outdated
Comment thread packages/maple/docs/boundary.md Outdated
Comment thread packages/maple/src/ast-cache.spec.ts Outdated
@NetWin

NetWin commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

Security review

This review was done by Claude Fable (Claude Code /security-review), scoped to what this branch adds over master.

Result: no newly introduced vulnerability found. The discovery pass produced no finding at or above the 0.7 confidence threshold, so no false-positive filtering was needed. The riskiest claims were verified by reading the branch sources and by a probe run against them.

Areas examined

defineOwn direct assignment — packages/maple/src/support/own-property.ts:5

  • The guard is !(key in target), which consults the whole prototype chain. __proto__, constructor, toString, hasOwnProperty, __defineGetter__, array length and existing indices all take the defineProperty branch, so no inherited setter or accessor can run. Only a key nothing on the chain answers to is assigned, and plain assignment then creates the same enumerable/writable/configurable own data property.
  • Every caller (boundary/from-host.ts, boundary/to-host.ts, evaluator/evaluator.ts, builtins/json/statics/parse.ts, stringify.ts, object/statics/from-entries.ts, values/arguments*.ts, regexp/utils/match-to-sandbox.ts) passes a freshly built {} / [] or a sandbox-built object; no Proxy exists in the codebase.
  • Probe: on {} and Object.create(null), __proto__/constructor become own data properties, the prototype is unchanged, and Object.prototype is untouched.

Lazy data conversion — packages/maple/src/maple.ts:166-189, evaluator/scope.ts:35-52, evaluator/evaluator.ts:1400-1407

  • defineDataBindings captures data[key] once at run start; a host function that mutates data later cannot inject a raw host object. The lazy closure calls the same HostConversion.convert as before, which always copies into fresh sandbox objects — the host's original object is never handed to the script.
  • The primitive test (null or typeof not object/function) matches convert's own, so symbols and bigints still fail before the run; boxed primitives and class instances fail lazily with an uncatchable INTERNAL_ERROR (UnsupportedInputError is a host Error, and script try/catch only catches SandboxThrow).
  • HostConversion is created per prepare call, so the shared-copies map is per run; a nested run calls prepare again and gets its own. No instance-level conversion state exists, so nothing leaks between runs.
  • kind: 'global' lazy bindings remain unassignable (evaluator.ts:1426).

AST cache — packages/maple/src/ast-cache.ts

  • Per Maple instance, keyed by exact source text with constant parse options, so the key is complete.
  • deepfreeze walks every enumerable own property and freezes every object, including loc, regex and Literal.value. The evaluator never uses a Literal's RegExp value (it builds a fresh RegExpWrapper from node.regex on each evaluation) and no code writes to AST nodes. Closures hold their node privately and never expose it to the script.

this support — evaluator/evaluator.ts:206-213, 1044-1056, 1322-1343

  • Receivers are only ever values the script already holds: the object of a member call, a call/apply/bind thisArg, a built-in callback's thisArg, or a JSON holder (the top-level holder is a fresh sandbox object built with defineOwn).
  • Host functions are invoked as fn(...hostArgs) and intrinsics as fn(args, handle) with no receiver; NativeFunction wrappers ignore the receiver and the Sandbox handle's call(callee, args) takes none, so no path passes a script this to the host or a host receiver to a script function. Top-level this is undefined; strict-mode primitives are not boxed.

Playground workers — maple-worker-protocol.ts, maple.worker.ts, benchmark.worker.ts, js-interpreter-engine.ts

  • JSON.parse creates own properties only (a "__proto__" key becomes an own data property), the result is type-checked, then goes through Maple's isPlainObject and copy semantics. Sharing one parsed object across benchmark runs is safe because both engines copy input and Maple never mutates data. Same-origin, user-controlled input in a dev playground; no new surface.

parseContext refused only what is not an object at all and left an array to
Maple, which rejects it, while js-interpreter exposed its indexes, so the
Benchmark tab reported two outcomes instead of one input error.

From the same review: astCache.maxEntries of null is rejected instead of
silently taking the default, the boundary doc's sentence on host mutations
is completed, an ast-cache test name gets its article, and the measure-run
usage line spells pnpm.
@NetWin
NetWin merged commit 199bac4 into increment Oct 21, 2026
7 checks passed
@NetWin
NetWin deleted the perf/lazy-data branch October 21, 2026 18:48
@NetWin NetWin mentioned this pull request Oct 21, 2026
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.4.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants