diff --git a/.spiral/cycles/CYC-20260924-TTZ7C-56.md b/.spiral/cycles/CYC-20260924-TTZ7C-56.md new file mode 100644 index 0000000..42e4435 --- /dev/null +++ b/.spiral/cycles/CYC-20260924-TTZ7C-56.md @@ -0,0 +1,59 @@ +# Isolated query execution + +Goal: register the selected isolated-vm engine and replace VM2 query execution +with an evaluable, restricted integration that preserves SimplyStore behavior. + +Status: Accepted on 2026-09-24. Human commitment: explicit engine choice after benchmark and +capability evidence, retained in SRC-20260924-TTZ7C-57. Classification: continue the +file-backed/sandbox roadmap; engine investigation has produced a selection. +Object history remains the next target, with UI deferred. + +Branch: spiral/CYC-20260924-TTZ7C-56-isolated-vm, created from authoritative master +029b36f. Branch identity checked when opening the cycle. CYC-44 and CYC-51 are +accepted and integrated on master; the previous context's pending-integration +text was stale. + +In scope: query engine, controlled data/grant interface, supplied query helpers, +JSON/JSONTag responses, resource limits, dependency/runtime documentation and +regression/performance evidence. No imports or general host capabilities for +queries. Trusted command execution and file/integrity formats remain governed +by their accepted contracts. Hostile queries may lose their worker/session. + +Non-goals: history implementation, UI work, broad public-deployment claims, +engine fallback framework, snapshots, full-history rebuild and unrelated cleanup. + +Evaluation ready: isolated-vm replaces VM2, with a fresh isolate per query and +host-authorized read-only views. The host retains the existing parser and grants; +this resolves the authority gap in the raw-record probe. Execution/serialization, +memory, output and outer-worker limits are covered. EVD-20260924-TTZ7C-60 records +202 passing tests, clean focused lint, package checks, copied-example queries, +and actual worker-path timings. Implementation is at bd507554a2fed6f77322ef38a98839f127d810db. + +The final scan benchmark is about 453 ms for 10,000 records; filtering/sorting/ +projection is about 498 ms. The measured result supports the selected engine, +with the synthetic/warm-cache scope retained. No snapshots or whole-process +memory/crash boundary is claimed. Human acceptance is recorded below; integration +remains pending. + +## Human acceptance and version + +On 2026-09-24, the maintainer explicitly instructed: + +> mark this cycle as accepted, bump simplystore version to 0.11.0 (if that is correct) + +This accepts the implementation at `bd8f8eb3ee617c5f9e9198f234725ef7ab5b4201`, including the +published od-jsontag 0.5.0 dependency and Node parser entry point. The full suite +was rerun against that published package: 202 tests passed, focused lint passed, +and queries against a disposable copy of the example store succeeded. + +The maintainer emphasized that increased security is the purpose of the change; +keeping performance comparable to VM2, with modest improvements on larger +queries, is a welcome secondary result. Acceptance retains the documented limits +on whole-process memory/crash isolation and public deployment claims. + +SimplyStore is bumped from 0.10.3 to 0.11.0. A minor increment is appropriate for +this pre-1.0 release's engine replacement, Node 22+ requirement, startup flag, +and query capability/limit changes. No release tag or npm publication is made +by this version change. The cycle branch identity was verified before recording +acceptance. The branch is ready for a separately authorized normal merge into +master; no next cycle has been opened. diff --git a/.spiral/cycles/CYC-20260924-TTZ7C-56.ttl b/.spiral/cycles/CYC-20260924-TTZ7C-56.ttl new file mode 100644 index 0000000..f1ae596 --- /dev/null +++ b/.spiral/cycles/CYC-20260924-TTZ7C-56.ttl @@ -0,0 +1,9 @@ +@prefix sd: . +@prefix dcterms: . +@prefix project: . +project:CYC-20260924-TTZ7C-56 a sd:Cycle ; + dcterms:identifier "CYC-20260924-TTZ7C-56" ; + sd:repositoryPath ".spiral/cycles/CYC-20260924-TTZ7C-56.md" ; + sd:derivedFrom [ a sd:ArtifactReference ; sd:artifact project:REQ-20260924-TTZ7C-58 ; sd:gitCommit "fdef01a844e14f50c0af1aac6e89461714736f6d" ] ; + sd:transforms [ a sd:ArtifactReference ; sd:artifact project:CYC-20260924-TTZ7C-56 ; sd:gitCommit "b176d4553c4f4b62c71571ed1a56475a06f9f975" ] ; + sd:status sd:Accepted . diff --git a/.spiral/designs/DES-20260924-TTZ7C-59.md b/.spiral/designs/DES-20260924-TTZ7C-59.md new file mode 100644 index 0000000..53549f1 --- /dev/null +++ b/.spiral/designs/DES-20260924-TTZ7C-59.md @@ -0,0 +1,58 @@ +# Fresh isolates and a host-authorized data view + +Use isolated-vm 6.2.0, without snapshots, for each query. Keep the existing +worker pool and trusted command/storage runtime. The worker owns the established +FileDataset, offset/ID indexes and access module. Bundle the pure JAQT/JSONTag +query helpers for the isolate; do not supply an import resolver or Node globals. + +The benchmark used a parser inside the guest and raw record callbacks. That is +not an authorization boundary: parser internals and same-realm mutable library +objects must not expose raw denied fields. Existing access modules are trusted +Node modules. Rather than moving them into a hostile realm or stringifying +functions and silently losing their imports/closures, keep parsing and access +checks on the host. Supply an isolate-local read-only view with copied values +and private synchronous callbacks. A disposable prototype scanned 10,000 records +in about 362–430 ms through this view; the chosen boundary still warrants real +integration measurements. + +References identify record numbers plus property paths (or schema paths), not +host objects or paths on disk. Resolve them through the host parser for every +operation so bounded record caches remain effective. Preserve guest identity +with weak references. Return scalars, tagged scalar copies and descriptions of +object/array views. Expose only ordinary property names and JSONTag's public +metadata symbols. Do not forward parser-private symbols, prototypes, methods, +raw buffers, source handles or isolated-vm References. Mutating the view throws; +mutating a copied tagged scalar cannot modify stored data. Enforce grants on +reads, existence, enumeration and descriptors in the host realm. + +Each query receives fresh globals/root/data/meta/request and helper instances. +Run pointer selection, the user script and result serialization inside the +isolate's deadline. Capture the serializer privately so its Reference never +enters guest code. Only primitive response text crosses back. Bound UTF-8 result +size before copying it to Node; reject asynchronous query results. Configure +64 MiB isolate memory and 10 MiB result bytes by default, with explicit positive +finite validation. Also limit individual bridge copies. Keep callback values +small and never pass an untrusted function to a host API. + +An outer pool deadline replaces a stuck query worker and initializes its +replacement from the current committed head. Updates remain serialized with +queries. Initialization/update failures keep the existing fail-stop behavior. +Storage failure remains host-owned FileParser state, including when a query +catches its exception. Guest-thrown properties never classify storage failure. +Always release references and dispose the isolate, including errors/timeouts. + +The native engine's heap limit is not an OS process/RSS limit, and this worker +thread integration does not claim resilience against every native V8 failure. +It is the authorized query-engine step, not internet-exposure certification. +A separate OS process boundary remains a deployment-hardening consideration. + +Validate the actual runtime API, grant reflection, Unicode/tagged values, JAQT, +updates/restart, hostile serialization, imports and post-failure queries. Keep +accepted durability tests. Benchmarks must distinguish this view from the prior +raw-record bridge. The portable od-jsontag branch remains separate work; this +integration can preserve the accepted host parser dependency without publishing +or depending on an uncommitted cross-repository change. + +Muze's replaceable-boundary and library-inspectability preferences favour one +small engine adapter and an explicit data-view contract over a general sandbox +framework. No engine selection option or VM2 fallback is needed. diff --git a/.spiral/designs/DES-20260924-TTZ7C-59.ttl b/.spiral/designs/DES-20260924-TTZ7C-59.ttl new file mode 100644 index 0000000..244ac42 --- /dev/null +++ b/.spiral/designs/DES-20260924-TTZ7C-59.ttl @@ -0,0 +1,8 @@ +@prefix sd: . +@prefix dcterms: . +@prefix project: . +project:DES-20260924-TTZ7C-59 a sd:Design ; + dcterms:identifier "DES-20260924-TTZ7C-59" ; + sd:repositoryPath ".spiral/designs/DES-20260924-TTZ7C-59.md" ; + sd:satisfies [ a sd:ArtifactReference ; sd:artifact project:REQ-20260924-TTZ7C-58 ; sd:gitCommit "fdef01a844e14f50c0af1aac6e89461714736f6d" ] ; + sd:status sd:Active . diff --git a/.spiral/evidence/EVD-20260924-TTZ7C-60.md b/.spiral/evidence/EVD-20260924-TTZ7C-60.md new file mode 100644 index 0000000..8db7e7c --- /dev/null +++ b/.spiral/evidence/EVD-20260924-TTZ7C-60.md @@ -0,0 +1,105 @@ +# Isolated query worker evaluation + +Implementation evaluated: bd507554a2fed6f77322ef38a98839f127d810db. This initial evaluation preceded human acceptance. +The maintainer accepted the cycle on 2026-09-24; the cycle record retains that +acceptance and the subsequently evaluated od-jsontag dependency update. + +## Verification + +202 tests pass, with no failures/skips, using Node 24.7.0 and isolated-vm 6.2.0: + +```sh +node --no-node-snapshot --test \ + test/required-integrity.mjs test/file-data.mjs test/index-ids.mjs \ + test/store-runtime.mjs test/durability-startup.mjs \ + test/conversion-offsets.mjs test/command-offsets.mjs \ + test/query-isolate.mjs test/acid-baseline.mjs test/durability-crash.mjs \ + test/after-change-handlers.mjs test/storage.mjs test/admin-recovery.mjs \ + test/power-loss-baseline.mjs +``` + +The nine new query tests exercise real workers and cover: + +- JAQT, JSON/JSONTag, Unicode, typed URLs, ID identity/lookups, schema, browsing; +- host access modules retaining Node imports, reflected property grants, + private parser symbols, readonly objects/arrays and metadata; +- independently allowed existence versus denied reads, and the converse; +- absent Node/network/shared-memory APIs, rejected static/dynamic/generated + imports, asynchronous result rejection and primitive throws; +- query-global/prototype isolation, inherited toJSON/reference/tagged attacks; +- execution/getter/toJSON timeouts, follow-up queries, UTF-8 result limits, + serializer tampering and bounded error messages; +- isolate memory exhaustion followed by a successful query; +- invalid limit configuration and outer-timeout worker replacement initialized + at the newly committed head. + +Existing tests retain command durability, integrity, publication ordering, +updates/restart and caught versus forged storage-failure behavior. The power-loss +baseline here is the existing modeled fault suite; no new QEMU/ext4 power-cut +campaign was run for this engine change. + +Focused ESLint passes without warnings/errors. git diff --check passes. npm +pack --dry-run includes all engine/view/guest source and the runtime guide. +Staged provenance validation passed before implementation commits; range/tree +validation is run again for the completed branch. + +## Example + +Opened a disposable copy of the current example data, command history, offsets, +ID indexes and integrity manifest. Root collections, JSON projections and tagged +person serialization succeeded with the example access module. A projection of +name/gender returned names and omitted gender. typeof process/require/fetch all +returned undefined. The original example data was not modified. + +## Actual worker measurements + +Reproduction: node --no-node-snapshot scripts/benchmark-query-runtime.mjs. +Five measured samples after two warmups; sequential datasets and warm filesystem +pages, Intel Core i7-1195G7, Node 24.7.0. All results match independent host +expectations. Times include worker messaging, a fresh isolate/helper setup, +host grant callbacks, lazy file reads and response serialization. They exclude +store startup and are not physical-disk or total-RSS measurements. + +| Records | Query | Median ms | +|---:|---|---:| +| 1,000 | scan | 48.69 | +| 1,000 | filter_sort_project | 52.85 | +| 1,000 | serialize_1000 | 81.33 | +| 10,000 | scan | 453.27 | +| 10,000 | filter_sort_project | 497.90 | +| 10,000 | serialize_1000 | 81.00 | + +Raw samples are retained in benchmark/query-engines/worker-results.json. This +is a different data boundary from the initial raw-parser comparison: the +production parser/grants stay on the host. The measured scan/query advantage +remains substantial; do not treat either experiment as a universal speedup or +public-access security certification. + +## Limits and next boundary + +Queries use 64 MiB isolate memory and 10 MiB result limits by default, configurable +with positive validated bounds. Native memory limits are not whole-process RSS +limits. Worker threads still share the server process; OS process isolation, +broader adversarial review and concurrent deployment capacity remain outside +this evaluated slice. No snapshots are used. Trusted storage/command code and +the accepted parser revision remain unchanged in role; portable od-jsontag is +separate work, not a silently published dependency. + +Source SRC-57, request REQ-58 and design DES-59 retain the rationale. The cycle +continues the roadmap's sandbox replacement. Queryable object history and audit +metadata remain the next target after this cycle's review/integration; UI is not +required for that target. + +## Published dependency verification and acceptance + +At `bd8f8eb3ee617c5f9e9198f234725ef7ab5b4201`, SimplyStore pins published +od-jsontag 0.5.0 and imports its Node parser adapter for file-descriptor input. +The same full test command above passed 202 tests with no failures or skips +against the installed npm release. Focused lint on src/file-data.mjs and +git diff --check passed. A disposable copy of the current example store opened +and returned root collections, permitted person fields and tagged values; +process/require/fetch remained unavailable. Original example files were preserved. + +The maintainer explicitly accepted the cycle after this evaluation and requested +version 0.11.0. Only package metadata and acceptance records change after this +evaluated implementation; the existing regression result remains applicable. diff --git a/.spiral/evidence/EVD-20260924-TTZ7C-60.ttl b/.spiral/evidence/EVD-20260924-TTZ7C-60.ttl new file mode 100644 index 0000000..630fad5 --- /dev/null +++ b/.spiral/evidence/EVD-20260924-TTZ7C-60.ttl @@ -0,0 +1,10 @@ +@prefix sd: . +@prefix dcterms: . +@prefix project: . +project:EVD-20260924-TTZ7C-60 a sd:VerificationEvidence ; + dcterms:identifier "EVD-20260924-TTZ7C-60" ; + sd:repositoryPath ".spiral/evidence/EVD-20260924-TTZ7C-60.md" ; + sd:verifies [ a sd:ArtifactReference ; sd:artifact project:IMP-20260919-TTZ7C-33 ; sd:gitCommit "bd507554a2fed6f77322ef38a98839f127d810db" ] ; + sd:verifies [ a sd:ArtifactReference ; sd:artifact project:DES-20260924-TTZ7C-59 ; sd:gitCommit "1e2c7851a12ddc1b4c236d557a70479f411dfb8d" ] ; + sd:verifies [ a sd:ArtifactReference ; sd:artifact project:IMP-20260919-TTZ7C-33 ; sd:gitCommit "bd8f8eb3ee617c5f9e9198f234725ef7ab5b4201" ] ; + sd:status sd:Active . diff --git a/.spiral/implementations/IMP-20260919-TTZ7C-33.md b/.spiral/implementations/IMP-20260919-TTZ7C-33.md index 978ee00..cbe752c 100644 --- a/.spiral/implementations/IMP-20260919-TTZ7C-33.md +++ b/.spiral/implementations/IMP-20260919-TTZ7C-33.md @@ -214,3 +214,28 @@ The example exposes npm run init-integrity with a declarative store.json. Regression coverage and disposable-copy example verification preserve existing commands. Performance comparison uses the accepted optional baseline and identical 30-command runs; canonical hashes are reused and index entries share one barrier. + + +## Isolated query execution (CYC-56) + +VM2 is replaced by isolated-vm 6.2.0. Each query gets a fresh isolate and bundled +JAQT/JSONTag helpers, with no module loading or Node/network APIs. A private +copied-value bridge exposes read-only views while the established FileDataset, +access module and storage-failure state remain on the trusted host. Record/path +references preserve bounded parser caching without leaking parser-private +symbols or isolated-vm References. This deliberately differs from the initial +raw-record performance probe and does not require the uncommitted portable +od-jsontag dependency branch. + +Execution and serialization share the configured deadline. The pool replaces +queries stuck beyond the deadline plus a 250 ms response allowance. Memory and +UTF-8 result/bridge bounds are explicit options; default values are 64 MiB and +10 MiB. Typed scalar copies cannot mutate the dataset. Reflection applies host +grants, with existence checks using the host has policy independently of the +read/descriptor policy. Primitive throws are bounded errors, prototype changes stay within one +query, and serialization getters/toJSON remain under the isolate timeout. + +Query tests, actual-worker performance measurement, runtime documentation and +Node startup requirements are included. Native isolates remain inside Node +worker threads; no OS-process memory/crash boundary or public safety certification +is claimed. Trusted command/storage/integrity/recovery contracts remain effective. diff --git a/.spiral/implementations/IMP-20260919-TTZ7C-33.ttl b/.spiral/implementations/IMP-20260919-TTZ7C-33.ttl index 3e92bce..42805ee 100644 --- a/.spiral/implementations/IMP-20260919-TTZ7C-33.ttl +++ b/.spiral/implementations/IMP-20260919-TTZ7C-33.ttl @@ -32,8 +32,10 @@ project:IMP-20260919-TTZ7C-33 sd:implementationLocation "test/index-ids.mjs" ; sd:implementationLocation "scripts/benchmark-file-data.mjs" ; sd:implements [ a sd:ArtifactReference ; sd:artifact project:DES-20260923-TTZ7C-47 ; sd:gitCommit "83edb03bc406b9e651ef93bed69b87ece17dcf47" ] ; - sd:changeCausedBy [ a sd:ArtifactReference ; sd:artifact project:DEF-20260923-TTZ7C-50 ; sd:gitCommit "4bb10c2013fd2a7cb989b02586b992b936121362" ] ; - sd:transforms [ a sd:ArtifactReference ; sd:artifact project:IMP-20260919-TTZ7C-33 ; sd:gitCommit "c8a3fd9464abfd5e7b699a2752789d82af4da2da" ] ; sd:implements [ a sd:ArtifactReference ; sd:artifact project:DES-20260923-TTZ7C-54 ; sd:gitCommit "c8a3fd9464abfd5e7b699a2752789d82af4da2da" ] ; - sd:changeCausedBy [ a sd:ArtifactReference ; sd:artifact project:REQ-20260923-TTZ7C-53 ; sd:gitCommit "c8a3fd9464abfd5e7b699a2752789d82af4da2da" ] ; + sd:implements [ a sd:ArtifactReference ; sd:artifact project:DES-20260924-TTZ7C-59 ; sd:gitCommit "1e2c7851a12ddc1b4c236d557a70479f411dfb8d" ] ; + sd:transforms [ a sd:ArtifactReference ; sd:artifact project:IMP-20260919-TTZ7C-33 ; sd:gitCommit "2595efb946993f1b088a430c46dd04927fa27868" ] ; + sd:changeCausedBy [ a sd:ArtifactReference ; sd:artifact project:REQ-20260924-TTZ7C-58 ; sd:gitCommit "fdef01a844e14f50c0af1aac6e89461714736f6d" ] ; + sd:shapedBy [ a sd:ArtifactReference ; sd:artifact ; sd:gitCommit "8d4b2c738a413abd4cccca740ce958f486e5f7af" ] ; + sd:implementationLocation "src/query-isolate.mjs", "src/query-view.mjs", "src/query-guest.mjs", "test/query-isolate.mjs", "scripts/benchmark-query-runtime.mjs" ; sd:implementationChangeKind sd:SemanticChange . diff --git a/.spiral/project-context.md b/.spiral/project-context.md index a6f5eee..3cef4b5 100644 --- a/.spiral/project-context.md +++ b/.spiral/project-context.md @@ -18,43 +18,32 @@ Project causal-graph namespace: `https://github.com/simplyedit/simplystore/spira Spiral core source: `.spiral-core/`, git submodule for `https://github.com/muze-labs/spiral-developer.git`, currently checked out at `8d4b2c738a413abd4cccca740ce958f486e5f7af`. -Active cycle: none. Latest accepted cycle: CYC-20260923-TTZ7C-51, required -integrity and existing-store initialization, accepted on 2026-09-23, awaiting -integration on spiral/CYC-20260923-TTZ7C-51-required-integrity. This continues -the reliability roadmap from the accepted file-backed tip while integration is -pending. The maintainer explicitly confirmed mandatory hashes, initialization -and latency measurement. Implementation and evaluation are complete: 193 tests -pass, lint is clean, the copied example retains its command after initialization, -and local ext4 median completion increased roughly 2 ms. EVD-55 retains evidence. -Normal startup now requires hashes; existing stores use explicit init-integrity. -The maintainer confirmed startup refusal without integrity, successful -initialization and working commands. Acceptance is retained in CYC-51. -Both CYC-44 and CYC-51 are accepted; integration remains a separate decision. -No next cycle is selected. - -Previously accepted cycle: CYC-20260923-TTZ7C-44, -File-backed dataset runtime, accepted on 2026-09-23, awaiting integration on -spiral/CYC-20260923-TTZ7C-44-file-backed. The maintainer authorized replacing -shared-memory data after reviewing od-jsontag hardening. This continues the -file-only roadmap direction, prioritized ahead of rebuild. SRC-45, UND-48, -REQ-46 and DES-47 (20260923-TTZ7C namespace) retain the commitment. Implementation -uses ordered file sources, bounded clean-record caches, worker-owned handles, -bounded hashing and file-backed administrative replay. Committed offset and ID -indexes are trusted and loaded directly; missing indexes reconstruct, and -validation/reconstruction are explicit maintenance options. ID indexes are -finalized from serialized bytes and durable before success. Integrity manifests -cover canonical data and present standard index files. Duplicate IDs are -errors; no legacy index compatibility is required. The maintainer verified the -example folder and explicitly confirmed these corrections. -EVD-20260923-TTZ7C-49 records 178 passing tests, clean lint and large-file probes. -The maintainer confirmed the tests, successful example command application and -persistence after restart. Acceptance is retained in the cycle record. -Integration remains pending; accepted CYC-51 adds mandatory integrity. -Client-visible version metadata and audit trails follow this migration; -neither is implemented here. - -Latest integrated cycle: CYC-20260919-TTZ7C-42, Updated Roadmap And Planning -Context. Accepted on 2026-09-23 and integrated through PR #24 at dbb0703. +Latest accepted cycle: CYC-20260924-TTZ7C-56, isolated-vm query execution, on +spiral/CYC-20260924-TTZ7C-56-isolated-vm. The maintainer selected isolated-vm after +capability and performance probes and explicitly authorized continuation. +SRC-20260924-TTZ7C-57 retains the commitment. This continues ROADMAP section 2. +The branch implementation now uses isolated-vm with host-authorized read-only +views and fresh per-query isolates. EVD-20260924-TTZ7C-60 records 202 passing tests, +clean lint, copied-example verification and actual worker timings (about 453 ms +for a 10,000-record scan). The maintainer accepted the cycle on 2026-09-24 at +bd8f8eb3ee617c5f9e9198f234725ef7ab5b4201 and requested version 0.11.0. +The published od-jsontag 0.5.0 package also passed all 202 tests and copied-example +checks. Acceptance closes this cycle; integration remains pending. No next cycle +is active. +Queries must have no import, Node filesystem/network/database authority. Preserve +existing grants and file-backed ordering. Queryable history follows; UI can wait. + +Latest integrated cycle: CYC-20260923-TTZ7C-51, required integrity, accepted +and integrated through PR #26 at 029b36f. The earlier file-backed CYC-44 is also +accepted and integrated. The previous pending-integration text was stale. +Normal startup requires integrity manifests; init-integrity initializes existing +stores explicitly. CYC-51 evidence records 193 passing tests, example operation, +and approximately 2 ms median local command-latency overhead. + +The integrated file-backed foundation uses ordered sources, trusted offset/ID +indexes, worker-owned file handles, bounded clean-record caches, mandatory hashes, +and durable index publication. Duplicate IDs are errors; no legacy index mode +exists. Keep these contracts while replacing query execution. Previously accepted and integrated cycle: CYC-20260919-TTZ7C-36, Reliable Runtime Contracts And Readable Workflows, on spiral/CYC-20260919-TTZ7C-36-runtime-contracts. The maintainer @@ -351,7 +340,7 @@ This is not yet a claim that SimplyStore is production-safe for all workloads. I | Dependency | Role | Replaceability/constraint | Review scope | |---|---|---|---| | Express | HTTP application framework | Unknown | Runtime/API behavior | -| VM2 | Current JavaScript sandbox | Known security concern; planned replacement | Security, query behavior | +| isolated-vm | Fresh query isolates on CYC-56 | Native V8 dependency; host-authorized data bridge | Query capabilities, limits, compatibility and deployment boundary | | JSONTag packages | Data format support | Core dependency | Parsing/serialization behavior | | JAQT | Query helper library | Unknown | Query examples and behavior | diff --git a/.spiral/requests/REQ-20260924-TTZ7C-58.md b/.spiral/requests/REQ-20260924-TTZ7C-58.md new file mode 100644 index 0000000..ab56c63 --- /dev/null +++ b/.spiral/requests/REQ-20260924-TTZ7C-58.md @@ -0,0 +1,26 @@ +# Restricted isolated-vm query workers + +Replace VM2 query execution with isolated-vm, selected explicitly after the +retained engine comparison. Preserve the remote JavaScript query interface, +JAQT and JSONTag helpers, root/data/request/meta, path browsing, ID lookup, +read-only grants, committed-view updates, and storage-failure classification. +Queries cannot import modules or access Node, filesystem, network, databases, +or raw host references. File reads and grants must be controlled by the host. + +Execution and serialization are bounded by the selected normal/slow timeout. +Configure an isolate memory budget and a maximum encoded response size. Reject +bad limits and fail queries clearly when limits are exceeded. A fresh query +must not inherit another query's global or prototype mutations. Failed queries +must not make later queries hang or publish a newer view out of order. + +Evidence: real worker/runtime tests for helpers, tagged responses, Unicode, +links, ID/schema access, grants (including reflection), mutation attempts, +imports and dynamic code, timeout during execution and serialization, oversized +results, committed updates/restart, and forged versus actual storage failures. +Retain the accepted durability regression tests. Measure the integrated data +boundary independently from the raw-parser engine benchmark. + +This is a query execution migration, not public-deployment certification or an +entire-server runtime change. History/audit functionality and its UI are later +work. Disk formats, command execution, integrity and recovery contracts remain +unchanged. No snapshots or selectable VM2 fallback. diff --git a/.spiral/requests/REQ-20260924-TTZ7C-58.ttl b/.spiral/requests/REQ-20260924-TTZ7C-58.ttl new file mode 100644 index 0000000..795e54d --- /dev/null +++ b/.spiral/requests/REQ-20260924-TTZ7C-58.ttl @@ -0,0 +1,8 @@ +@prefix sd: . +@prefix dcterms: . +@prefix project: . +project:REQ-20260924-TTZ7C-58 a sd:Request ; + dcterms:identifier "REQ-20260924-TTZ7C-58" ; + sd:repositoryPath ".spiral/requests/REQ-20260924-TTZ7C-58.md" ; + sd:derivedFrom [ a sd:ArtifactReference ; sd:artifact project:SRC-20260924-TTZ7C-57 ; sd:gitCommit "07d0af0fe680269d230124a7514c9be1aa63d87a" ] ; + sd:status sd:Active . diff --git a/.spiral/sources/SRC-20260924-TTZ7C-57.md b/.spiral/sources/SRC-20260924-TTZ7C-57.md new file mode 100644 index 0000000..09dd5be --- /dev/null +++ b/.spiral/sources/SRC-20260924-TTZ7C-57.md @@ -0,0 +1,44 @@ +# isolated-vm selection and authorization + +Retained source: maintainer conversation on 2026-09-24. + +The maintainer requires query JavaScript to have no Node filesystem, network, +or database access, and no imports. Supplied JavaScript libraries and the +permitted dataset view remain available. A failed or malicious query may be +terminated; preserving its computation is not an objective. + +After comparing QuickJS and Secure Exec, the maintainer requested: +“Lets do a performance test against isolated-vm as well, if it is substantially +better, I will prefer that.” The assistant reported actual od-jsontag benchmark +results: approximately 4.8–5.0 times faster scans, 4.6–5.0 times faster result +serialization, and 25–27 times faster arithmetic. Static and dynamic imports +failed in the tested script embedding, and an infinite loop was stopped by its +timeout. The report distinguished these probes from production integration and +recommended proceeding without snapshots. + +The maintainer then instructed: “Lets register this choice and continue with +isolated-vm.” This settles engine selection and authorizes the next integration +step after the concrete capability/performance discussion. No repeated engine +approval is required. Human evaluation and integration remain separate. + +Current effective behavior is evidenced in src/query-worker-module.mjs: VM2 +executes queries using a host-owned FileDataset, JSONTag and JAQT helpers. The +worker pool sequences committed file-source updates with queries. The requested +isolated-vm boundary is not implemented. Existing access modules run in Node; +preserving their authority is an integration requirement, not something proved +by the raw-byte benchmark. Timeouts currently cover VM2 evaluation, while +serialization and outer worker termination need explicit coverage in the new +boundary. + +Understanding: continue ROADMAP section 2, replacing VM2 for query execution +with isolated-vm while preserving file-backed data, grants, query helpers, +response formats, committed update ordering and storage-failure handling. +Add bounded execution, memory and result handling at this boundary. Queryable +object history/audit metadata follows; its user interface can come later. +Commands and persistence remain trusted Node operations. No new disk format, +deployment, package release, merge, or public-access safety certification is +implied. + +The benchmark report, source and raw results are retained in +benchmark/query-engines/. Those measurements motivate selection; integration +must be tested independently, especially when adapting the data/grant bridge. diff --git a/.spiral/sources/SRC-20260924-TTZ7C-57.ttl b/.spiral/sources/SRC-20260924-TTZ7C-57.ttl new file mode 100644 index 0000000..481cc31 --- /dev/null +++ b/.spiral/sources/SRC-20260924-TTZ7C-57.ttl @@ -0,0 +1,9 @@ +@prefix sd: . +@prefix dcterms: . +@prefix project: . +project:SRC-20260924-TTZ7C-57 a sd:Source ; + dcterms:identifier "SRC-20260924-TTZ7C-57" ; + sd:repositoryPath ".spiral/sources/SRC-20260924-TTZ7C-57.md" ; + sd:sourceAvailability sd:Retained ; + sd:provenanceConfidence sd:Explicit ; + sd:status sd:Active . diff --git a/README.md b/README.md index 5289963..5ba520e 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ SimplyStore is a radically simpler backend storage server. It does not have a database, certainly no SQL or GraphQL, it is not REST. In return it has a well defined API that is automatically derived from your dataset. It supports JSONTag to allow for semantically meaningful data, without having to do the full switch to Linked Data and triple stores. The query format is javascript, you can post javascript queries that will run on the server. Dataset records are read lazily from indexed files. Javascript queries use ordinary objects and arrays; SimplyStore manages file access and indexes. [JSONTag](https://github.com/muze-nl/jsontag) is an enhancement over JSON that allows you to tag JSON data with metadata using HTML-like tags. -Javascript queries are run in a [VM2](https://www.npmjs.com/package/vm2) sandbox. +Javascript queries run in fresh [isolated-vm](https://github.com/laverdet/isolated-vm) isolates with read-only, host-authorized data views. You can query data using the [jaqt](https://github.com/muze-nl/jaqt/) library. -Note: _There are known security issues in VM2, so the project will switch to V8-isolate. For now make sure SimplyStore is not publically accessible, by adding an api gateway in front of it for example_ +See [query execution](docs/query-runtime.md) for capabilities, resource limits, native runtime requirements and the tested isolation boundary. Engine replacement alone does not establish readiness for unrestricted public access. ## Table of Contents @@ -14,6 +14,7 @@ Note: _There are known security issues in VM2, so the project will switch to V8- - [Install](#install) - [Usage](#usage) - [File-backed Data](docs/file-data.md) +- [Query Execution and Limits](docs/query-runtime.md) - [Custom Index Modules](#custom-index-modules) - [Example Query](#examples) - [Goals](#goals) @@ -24,7 +25,7 @@ Note: _There are known security issues in VM2, so the project will switch to V8- ## Install -SimplyStore is a NodeJS/[ExpressJS](https://expressjs.com/) library. You can install it in your application like this: +SimplyStore is a NodeJS/[ExpressJS](https://expressjs.com/) library requiring Node 22 or newer and a compatible isolated-vm native addon. You can install it in your application like this: ```shell npm install @muze-nl/simplystore @@ -57,7 +58,7 @@ simplystore is an [express application](https://expressjs.com/), with all the us If you start your server: ```shell -node myApp.js +node --no-node-snapshot myApp.js ``` You should be able to go http://localhost:3000/query/ and see something like this: diff --git a/ROADMAP.md b/ROADMAP.md index e712c1b..042aa12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -91,19 +91,22 @@ performance guarantee. Verify representative query/command performance and memory use, including data larger than RAM, when choosing the implementation. Preserve consistent views, authoritative ordering and durability behavior. -### Replace VM2; evaluate QuickJS and V8 isolates +### Replace VM2 with isolated-vm Replace the existing VM2 sandbox with an execution boundary whose isolation and performance can support consideration of an internet-facing query interface. -QuickJS and V8 isolates are candidates to investigate, not selected dependencies -or a commitment to replace the whole server runtime. +The maintainer selected isolated-vm on 2026-09-24 after comparing QuickJS, +Secure Exec and isolated-vm. The [retained benchmark](benchmark/query-engines/comparison.md) +shows a substantial advantage with the portable parser and synchronous data +bridge. Selection authorizes query-worker integration, not replacement of the +whole server runtime. Do not use snapshots for this integration. Required query behavior: - Anonymous or authenticated access, configurable per instance. - Read-only dataset access under the applicable grants. -- No arbitrary filesystem or network access; dataset reads go through a - controlled boundary. +- No imports, Node modules, filesystem, network or database APIs in queries; + permitted dataset access goes through a controlled boundary. - Enforced execution-time, memory and result-size limits. Evaluate candidate isolation, termination, resource enforcement, compatibility @@ -115,7 +118,8 @@ help remove shared-memory coupling without exposing arbitrary files to queries. Allow a JavaScript query to find a specific object's previous version, then follow previous versions recursively. Each version gives access to its command, -author and message. The purpose is a UI-readable audit trail. +author and message. The immediate purpose is queryable history and an inspectable audit trail; +a dedicated user interface can follow later. - Deleted objects remain discoverable by ID and retain their history. - Deletion remains a visible step in the version chain. diff --git a/benchmark/query-engines/README.md b/benchmark/query-engines/README.md new file mode 100644 index 0000000..9583a03 --- /dev/null +++ b/benchmark/query-engines/README.md @@ -0,0 +1,11 @@ +# Engine-selection evidence + +These files retain the 2026-09-24 disposable benchmark, including its original +absolute checkout paths. See comparison.md for conditions, measurements and +limitations. Run probes in a disposable directory with the stated dependency +versions; they create data fixtures and result files. They are not the normal +SimplyStore test suite or a production runtime implementation. + +The subsequent production-boundary measurement is worker-results.json, generated +by scripts/benchmark-query-runtime.mjs. It includes fresh isolates, host grants, +file reads, serialization and worker messaging; it is distinct from bench.mjs. diff --git a/benchmark/query-engines/bench.mjs b/benchmark/query-engines/bench.mjs new file mode 100644 index 0000000..d22340b --- /dev/null +++ b/benchmark/query-engines/bench.mjs @@ -0,0 +1,87 @@ +import {build} from '/home/auke/git/slonl/spiral/od-jsontag/node_modules/esbuild/lib/main.js'; +import {readFileSync,writeFileSync,openSync,closeSync,readSync} from 'node:fs'; +import {performance} from 'node:perf_hooks'; +import {cpus} from 'node:os'; +import assert from 'node:assert/strict'; +const repo='/home/auke/git/slonl/spiral/od-jsontag'; +const engine=process.argv[2]||'ivm'; +const clock=()=>performance.now(); +const loadStart=clock(); +const ivm=engine==='ivm'?(await import('isolated-vm')).default:null; +const QuickJS=engine==='quickjs'?await (await import(repo+'/node_modules/quickjs-emscripten/dist/index.mjs')).getQuickJS():null; +const moduleLoadMs=clock()-loadStart; +const bundle=(await build({stdin:{contents:"import Parser from './src/parse.mjs'; globalThis.library={Parser};",resolveDir:repo},bundle:true,platform:'neutral',mainFields:['main'],format:'iife',write:false})).outputFiles[0].text; +// Only copied primitive values/bytes cross the boundary. The callbacks are captured +// in closures, not exposed as raw isolated-vm Reference objects. +const bootstrap=`(()=>{let enc,dec,read; + globalThis.__bind=(a,b,c)=>{enc=a;dec=b;read=c;delete globalThis.__bind}; + globalThis.TextEncoder=class{encode(text=''){return new Uint8Array(enc(JSON.stringify(String(text))))}}; + globalThis.TextDecoder=class{decode(bytes=new Uint8Array()){return JSON.parse(dec(bytes.buffer,bytes.byteOffset,bytes.byteLength))}}; + globalThis.makeSource=byteLength=>({byteLength,read(a,b){return new Uint8Array(read(a,b))}}); +})();`; +const encoder=new TextEncoder(),decoder=new TextDecoder(); +function fixture(n){const index={},chunks=[],values=[];let offset=0; + for(let i=0;i<=n;i++){ + const item={name:'Padmé 𠮷 '+i,value:i,group:i%20,active:i%3===0,note:'Unicode café 日本語 '+('x'.repeat(i%80))}; + const body=i===0?`{"items":[~1-${n}]}`:`${JSON.stringify(item)}`; + if(i)values.push(item); + const bytes=Buffer.from(`(${Buffer.byteLength(body)})${body}`);index[i]=[offset,offset+bytes.length];chunks.push(bytes,Buffer.from('\n'));offset+=bytes.length+1; + } + const path=`${engine}-fixture-${n}.odjt`;writeFileSync(path,Buffer.concat(chunks));const fd=openSync(path,'r'); + const ranges=new Map(Object.values(index));let reads=0,bytesRead=0; + return {n,index,values,size:offset,read(a,b){assert.ok(Number.isSafeInteger(a)&&ranges.get(a)===b,'invalid source range');const bytes=Buffer.alloc(b-a);let used=0;while(used0);used+=count;}reads++;bytesRead+=bytes.length;return bytes;},stats(){return{reads,bytesRead}},close(){closeSync(fd)}}; +} +const data=[fixture(1000),fixture(10000)]; +function create(input,snapshot){ + const start=clock();let evaluate,dispose,bind,heap; + if(ivm){const isolate=new ivm.Isolate({memoryLimit:64,...(snapshot?{snapshot}:{})});const ctx=isolate.createContextSync(); + evaluate=code=>ctx.evalSync(code,{timeout:10000}); + bind=()=>ctx.evalClosureSync('__bind($0,$1,$2)',[ + new ivm.Callback(text=>encoder.encode(JSON.parse(text)).buffer), + new ivm.Callback((buffer,start,length)=>JSON.stringify(decoder.decode(new Uint8Array(buffer,start,length)))), + new ivm.Callback((a,b)=>Uint8Array.from(input.read(a,b)).buffer) + ]); + heap=()=>isolate.getHeapStatisticsSync();dispose=()=>isolate.dispose(); + }else{const q=QuickJS.newContext();q.runtime.setMemoryLimit(64*1024*1024);let deadline=0;q.runtime.setInterruptHandler(()=>Date.now()>deadline); + evaluate=code=>{deadline=Date.now()+10000;const result=q.evalCode(code);const h=result.error||result.value;try{const value=q.dump(h);if(result.error)throw Error(JSON.stringify(value));return value;}finally{h.dispose()}}; + function expose(name,fn){const h=q.newFunction(name,fn);q.setProp(q.global,name,h);h.dispose()} + bind=()=>{expose('__enc',text=>q.newArrayBuffer(encoder.encode(JSON.parse(q.getString(text))))); + expose('__dec',(buffer,start,length)=>{const bytes=q.getArrayBuffer(buffer);try{return q.newString(JSON.stringify(decoder.decode(bytes.value.subarray(q.getNumber(start),q.getNumber(start)+q.getNumber(length)))))}finally{bytes.dispose()}}); + expose('__read',(a,b)=>q.newArrayBuffer(input.read(q.getNumber(a),q.getNumber(b))));evaluate('__bind(__enc,__dec,__read);delete globalThis.__enc;delete globalThis.__dec;delete globalThis.__read;void 0;');}; + heap=()=>null;dispose=()=>q.dispose(); + } + const created=clock();if(!snapshot)evaluate(bootstrap);bind();if(!snapshot)evaluate(bundle+'\nvoid 0;');const loaded=clock(); + evaluate(`globalThis.parser=new library.Parser();globalThis.root=parser.parse(makeSource(${input.size}),${JSON.stringify(input.index)});void 0;`); + return {evaluate,dispose,heap,times:{createMs:created-start,libraryMs:loaded-created,parseRootMs:clock()-loaded,totalMs:clock()-start}}; +} +const result={engine,node:process.version,cpu:cpus()[0].model,moduleLoadMs,bundleBytes:Buffer.byteLength(bundle),fixtures:data.map(f=>({records:f.n,bytes:f.size})),startup:[],workloads:[],checks:{}}; +let snapshot; +if(ivm){const start=clock();snapshot=ivm.Isolate.createSnapshot([{code:bootstrap},{code:bundle}]);result.snapshotBuildMs=clock()-start;result.snapshotBytes=snapshot.copy().byteLength;} +for(const useSnapshot of ivm?[false,true]:[false])for(let i=0;i<7;i++){const vm=create(data[0],useSnapshot?snapshot:null);assert.equal(vm.evaluate('root.items.length'),1000);assert.equal(vm.evaluate('root.items[0].name'),'Padmé 𠮷 1');result.startup.push({snapshot:useSnapshot,...vm.times});vm.dispose();} +const expression='(()=>{let sum=0;for(let i=0;i<1000000;i++)sum+=i;return sum})()'; +for(const input of data){const vm=create(input);const evaluate=vm.evaluate; + result.checks.globals=JSON.parse(evaluate('JSON.stringify({process:typeof process,require:typeof require,fetch:typeof fetch})')); + for(const text of ['','a\u0000b','\ufeffabc','Padmé 𠮷','\ud800'])assert.equal(JSON.parse(evaluate(`JSON.stringify(new TextDecoder().decode(new TextEncoder().encode(${JSON.stringify(text)})))`)),decoder.decode(encoder.encode(text))); + if(input.n===1000){ + try{evaluate("import x from 'node:fs'");throw Error('static import allowed')}catch(e){assert.match(e.message,/SyntaxError|Unexpected|import|could not load module/);result.checks.staticImport=e.message;} + if(ivm){evaluate(`globalThis.importResult='pending';import('node:fs').then(()=>importResult='ALLOWED',e=>importResult=e.message);void 0;`);result.checks.dynamicImport=evaluate('importResult');assert.notEqual(result.checks.dynamicImport,'ALLOWED');assert.notEqual(result.checks.dynamicImport,'pending');} + } + const expectedFilter=JSON.stringify(input.values.filter(x=>x.active&&x.value>input.n/2).sort((a,b)=>b.value-a.value).slice(0,50).map(x=>({name:x.name,value:x.value}))); + const expectedOutput=JSON.stringify(input.values.slice(0,1000).map(x=>({name:x.name,value:x.value,note:x.note}))); + const workloads=[ + ['return_one','1',1], + ['compute_million',expression,499999500000], + ['scan',`root.items.reduce((sum,item)=>sum+item.value,0)`,input.n*(input.n+1)/2], + ['filter_sort_project',`JSON.stringify(root.items.filter(x=>x.active&&x.value>${input.n/2}).sort((a,b)=>b.value-a.value).slice(0,50).map(x=>({name:x.name,value:x.value})))`,expectedFilter], + ['serialize_1000',`JSON.stringify(root.items.slice(0,1000).map(x=>({name:x.name,value:x.value,note:x.note})))`,expectedOutput], + ['cached_lookup_1000',`(()=>{let sum=0;for(let i=0;i<1000;i++)sum+=root.items[0].value;return sum})()`,1000] + ]; + for(const [name,source,expected] of workloads){ + const measurements=[];for(let i=0;i<12;i++){const before=input.stats();const start=clock();const value=evaluate(source);const ms=clock()-start;assert.equal(value,expected,name);const after=input.stats();measurements.push({ms,reads:after.reads-before.reads,bytes:after.bytesRead-before.bytesRead});} + console.error('completed',engine,input.n,name); + result.workloads.push({name,records:input.n,first:measurements[0],warmup:measurements.slice(1,3),samples:measurements.slice(3)}); + } + result.heapAfter=vm.heap();vm.dispose(); +} +if(ivm){const vm=create(data[0]);const start=clock();try{vm.evaluate('while(true){}')}catch(e){result.checks.timeout={ms:clock()-start,message:e.message}}vm.dispose();} +for(const f of data)f.close();snapshot?.release();writeFileSync(`${engine}-results.json`,JSON.stringify(result,null,2));console.log(JSON.stringify({engine,moduleLoadMs,startup:result.startup,workloads:result.workloads.map(w=>({name:w.name,records:w.records,median:w.samples.map(x=>x.ms).sort((a,b)=>a-b)[4],reads:w.samples[0].reads})),checks:result.checks},null,2)); diff --git a/benchmark/query-engines/comparison.md b/benchmark/query-engines/comparison.md new file mode 100644 index 0000000..8efcb14 --- /dev/null +++ b/benchmark/query-engines/comparison.md @@ -0,0 +1,93 @@ +# isolated-vm versus QuickJS: portable od-jsontag benchmark + +2026-09-24. The results favour isolated-vm: about 4.8–5.0× faster indexed scans, 4.6–5.0× faster result serialization, and 25–27× faster for the arithmetic loop. These gains include the host data and text encoding bridges and do not require snapshots. + +## Configuration + +- Node v24.7.0, Linux x64, Intel Core i7-1195G7, isolated-vm 6.2.0, quickjs-emscripten 0.32.0. +- Actual working-tree portable od-jsontag Parser bundled with esbuild 0.25.12. Same bundle and default 256-record cache in both engines. Guest execution is synchronous, with a 10-second timeout/interrupt deadline and a configured 64 MiB runtime limit. The limits are not equivalent total-process memory bounds. +- Identical generated UTF-8 data files with 1,000 records (158,610 bytes) and 10,000 records (1,623,374 bytes), containing numbers, booleans and Unicode strings. Byte-for-byte equality of engine fixtures was checked. +- The guest receives synchronous source reads bound to one host-opened dataset. Every range is validated against its index; it receives copied bytes. It receives no path, file descriptor, Node module, filesystem API, database API, or network API. +- Both engines use host TextEncoder/TextDecoder callbacks. Both use the same JSON-escaped string transport to preserve NUL, BOM, supplementary characters and lone surrogates across the QuickJS boundary. Checks against host encoding results passed. These are minimal adapters for the parser, not complete Web API implementations. +- isolated-vm uses Callback, which copies arguments/results, not guest-visible Reference objects. QuickJS uses its normal synchronous host-function API. +- Engines run sequentially. Each workload has three initial/warmup executions followed by nine measured executions. Timings include guest evaluation, file reads, parsing, callbacks, and returning the scalar or JSON string to the host. Correctness checks occur outside the timing window. All outputs were checked against native host expectations. +- File pages are warm in the OS cache. Fixtures and library bundling are prepared outside query timings. This measures engine/bridge/parser cost, not physical-disk throughput or a dataset larger than RAM. + +## Query measurements + +Milliseconds: median (minimum–maximum) across nine measured executions. All isolated-vm query measurements use an ordinary isolate without a snapshot. + +| Workload | Records | QuickJS ms | isolated-vm ms | Speedup | +|---|---:|---:|---:|---:| +| return_one | 1,000 | 0.006 (0.006–0.014) | 0.004 (0.004–0.004) | 1.56× | +| compute_million | 1,000 | 48.008 (46.464–52.414) | 1.942 (1.566–2.172) | 24.72× | +| scan | 1,000 | 253.232 (245.589–262.188) | 51.300 (50.382–60.338) | 4.94× | +| filter_sort_project | 1,000 | 277.797 (266.395–281.503) | 55.926 (52.856–59.153) | 4.97× | +| serialize_1000 | 1,000 | 253.887 (245.191–260.196) | 50.786 (46.852–52.695) | 5.00× | +| cached_lookup_1000 | 1,000 | 5.262 (5.082–5.332) | 0.920 (0.798–1.070) | 5.72× | +| return_one | 10,000 | 0.005 (0.004–0.005) | 0.004 (0.003–0.005) | 1.27× | +| compute_million | 10,000 | 47.482 (45.791–48.624) | 1.783 (1.414–1.904) | 26.62× | +| scan | 10,000 | 2549.732 (2511.493–2723.231) | 531.327 (521.024–549.788) | 4.80× | +| filter_sort_project | 10,000 | 4170.410 (4114.597–4212.484) | 646.141 (607.173–668.639) | 6.45× | +| serialize_1000 | 10,000 | 250.031 (243.741–256.129) | 54.260 (50.452–58.081) | 4.61× | +| cached_lookup_1000 | 10,000 | 5.257 (5.132–5.321) | 0.809 (0.771–1.894) | 6.50× | + +`scan` sums a property across all records. `filter_sort_project` filters active records in the upper half of the value range, sorts descending, and returns the first 50 names/values as JSON. `serialize_1000` reads and serializes 1,000 records' names, values and notes. `cached_lookup_1000` repeatedly accesses a single cached record. `compute_million` sums integers from zero through 999,999. Return values and result strings match the expected outputs. + +Read counts and bytes match exactly for the measured non-sort workloads. A 10,000-record scan performs 10,001 indexed source reads in both engines; the root record also needs rereading after cache eviction. A repeated 1,000-record serialization performs 1,001 source reads. Cached lookups perform none. + +### Sorting difference + +The sorted query is the same JavaScript and returns the same result, but the engines' sorting behaviour differs. A separate diagnostic run split the 10,000-record query into phases. Both filtered 1,667 records with 10,000 source reads. V8 sorted them with 1,666 comparator calls and 1,667 additional reads; QuickJS made 15,851 comparator calls and 6,294 reads. The result projection made zero additional reads in V8 and 50 in QuickJS. This explains why that query's speedup exceeds the scan speedup: the different access order and comparator count interact with od-jsontag's bounded cache. It is a workload-dependent benefit, not a general sorting speed guarantee. + +## Startup + +Fresh runtime/context, encoding bridge, portable library, 1,000-record offset index, and root parsing. Seven samples per configuration, within an already running Node process after engine module loading. These figures exclude Node process launch and engine module/WASM initialization. + +| Engine | Snapshot | Median ms | Range ms | +|---|---|---:|---:| +| quickjs | no | 14.115 | 12.258–37.106 | +| ivm | no | 5.053 | 4.651–6.238 | +| ivm | yes | 4.238 | 3.864–4.325 | + +One initial engine module load took 13.96 ms for QuickJS (including WASM initialization) and 4.00 ms for isolated-vm. These are single observations, not medians. The isolated-vm snapshot took 7.91 ms to build and occupied 513,743 bytes. It contains trusted library/bootstrap code only, with callbacks and dataset bound afterwards. + +Snapshot initialization worked, but the maintainer [currently advises against createSnapshot due to instability](https://github.com/laverdet/isolated-vm#ivmisolatecreatesnapshotscripts-warmup_script). Its small startup saving is unnecessary for the performance recommendation here. + +## Capability and timeout checks + +- A bare isolated-vm context has no `process`, `require`, `fetch`, TextEncoder or TextDecoder. Encoding APIs are explicitly supplied by the embedding. +- Static import is rejected in script execution; dynamic import rejects with `Not supported`. Dynamic import constructed through `Function()` also rejects. +- An infinite loop was interrupted at approximately 100 ms when configured with a 100 ms timeout. A subsequent `1 + 2` returned 3. The benchmark also exercised a 10-second timeout. +- These are smoke checks of the intended embedding, not a security audit, memory-exhaustion test, or proof of complete query/grant isolation. Production integration must keep host callbacks limited to authorized data and results. + +## Scope and recommendation + +Use isolated-vm for the next engine integration trial. Its advantage is substantial for the actual portable parser and synchronous lazy-source design, including before warmup: inspect the raw `first` values for first-query measurements. Snapshots are unnecessary. + +This does not yet exercise SimplyStore's complete request path, JAQT library, authorization rules, concurrent workers, cancellation across host callbacks, full Web API compatibility, or production result-size limits. The library is native and version-sensitive; its maintainer recommends keeping isolate execution in a different process from critical infrastructure. See the [isolated-vm documentation](https://github.com/laverdet/isolated-vm#security). Heap limits were configured, but RSS and OOM behaviour were not benchmarked. + +## Reproduction and artifacts + +Run from this directory with the existing portable od-jsontag checkout and its installed dependencies: + +```sh +npm ci --no-audit --no-fund +node --no-node-snapshot bench.mjs ivm > ivm-run.log 2>&1 +node --no-node-snapshot bench.mjs quickjs > quickjs-run.log 2>&1 +node --no-node-snapshot sort-trace.mjs ivm +node --no-node-snapshot sort-trace.mjs quickjs +node --no-node-snapshot guard-checks.mjs +python3 make-report.py +``` + +Run the two benchmark engines sequentially to avoid CPU contention. The benchmark imports esbuild and QuickJS from `/home/auke/git/slonl/spiral/od-jsontag/node_modules`; adjust the `repo` path/imports if relocating it. + +- [Benchmark source](bench.mjs) +- [QuickJS raw results](quickjs-results.json) +- [isolated-vm raw results](ivm-results.json) +- [Sort diagnostic source](sort-trace.mjs) +- [QuickJS sort trace](quickjs-sort-trace.json) +- [isolated-vm sort trace](ivm-sort-trace.json) +- [Capability-check source](guard-checks.mjs) +- [Capability-check results](guard-results.json) diff --git a/benchmark/query-engines/guard-checks.mjs b/benchmark/query-engines/guard-checks.mjs new file mode 100644 index 0000000..a7b02a4 --- /dev/null +++ b/benchmark/query-engines/guard-checks.mjs @@ -0,0 +1,18 @@ +import ivm from 'isolated-vm'; +import assert from 'node:assert/strict'; +import {writeFileSync} from 'node:fs'; +import {performance} from 'node:perf_hooks'; +const isolate=new ivm.Isolate({memoryLimit:64}); +const context=isolate.createContextSync(); +const result={globals:JSON.parse(context.evalSync('JSON.stringify({require:typeof require,process:typeof process,fetch:typeof fetch,TextEncoder:typeof TextEncoder,TextDecoder:typeof TextDecoder})'))}; +try{ + assert.equal(result.globals.require,'undefined');assert.equal(result.globals.process,'undefined');assert.equal(result.globals.fetch,'undefined'); + assert.throws(()=>context.evalSync("import x from 'node:fs'"),/Cannot use import/); + for(const [name,code] of [['dynamic',"import('node:fs')"],['generated',"Function(\"return import('node:fs')\")()"]]){ + context.evalSync(`globalThis.status='pending';${code}.then(()=>status='ALLOWED',e=>status=e.message);void 0;`); + result[name]=context.evalSync('status');assert.equal(result[name],'Not supported'); + } + const start=performance.now();assert.throws(()=>context.evalSync('while(true){}',{timeout:100}),/timed out/);result.timeoutMs=performance.now()-start; + assert.equal(context.evalSync('1+2'),3);result.afterTimeout=3; + writeFileSync('guard-results.json',JSON.stringify(result,null,2));console.log(JSON.stringify(result,null,2)); +}finally{isolate.dispose()} diff --git a/benchmark/query-engines/guard-results.json b/benchmark/query-engines/guard-results.json new file mode 100644 index 0000000..cefcfb0 --- /dev/null +++ b/benchmark/query-engines/guard-results.json @@ -0,0 +1,13 @@ +{ + "globals": { + "require": "undefined", + "process": "undefined", + "fetch": "undefined", + "TextEncoder": "undefined", + "TextDecoder": "undefined" + }, + "dynamic": "Not supported", + "generated": "Not supported", + "timeoutMs": 100.36332499999999, + "afterTimeout": 3 +} \ No newline at end of file diff --git a/benchmark/query-engines/ivm-results.json b/benchmark/query-engines/ivm-results.json new file mode 100644 index 0000000..b6e4d5a --- /dev/null +++ b/benchmark/query-engines/ivm-results.json @@ -0,0 +1,962 @@ +{ + "engine": "ivm", + "node": "v24.7.0", + "cpu": "11th Gen Intel(R) Core(TM) i7-1195G7 @ 2.90GHz", + "moduleLoadMs": 3.996423000000007, + "bundleBytes": 78474, + "fixtures": [ + { + "records": 1000, + "bytes": 158610 + }, + { + "records": 10000, + "bytes": 1623374 + } + ], + "startup": [ + { + "snapshot": false, + "createMs": 1.198990000000009, + "libraryMs": 1.385315999999989, + "parseRootMs": 3.6509550000000104, + "totalMs": 6.2378590000000145 + }, + { + "snapshot": false, + "createMs": 1.026549000000017, + "libraryMs": 1.1141509999999926, + "parseRootMs": 2.8477159999999913, + "totalMs": 4.988857000000024 + }, + { + "snapshot": false, + "createMs": 0.9395979999999895, + "libraryMs": 1.0721560000000068, + "parseRootMs": 2.638937999999996, + "totalMs": 4.651141999999993 + }, + { + "snapshot": false, + "createMs": 0.9297910000000229, + "libraryMs": 1.2503889999999842, + "parseRootMs": 2.872564000000011, + "totalMs": 5.053129000000013 + }, + { + "snapshot": false, + "createMs": 0.9568720000000042, + "libraryMs": 1.1046169999999904, + "parseRootMs": 2.6930090000000177, + "totalMs": 4.754865999999993 + }, + { + "snapshot": false, + "createMs": 0.9861010000000192, + "libraryMs": 1.0861919999999827, + "parseRootMs": 3.4575729999999965, + "totalMs": 5.530471000000006 + }, + { + "snapshot": false, + "createMs": 0.9929149999999822, + "libraryMs": 1.3105230000000176, + "parseRootMs": 2.96919299999999, + "totalMs": 5.273048999999986 + }, + { + "snapshot": true, + "createMs": 1.1894130000000018, + "libraryMs": 0.08663299999997776, + "parseRootMs": 2.9615340000000003, + "totalMs": 4.238011999999998 + }, + { + "snapshot": true, + "createMs": 1.1237950000000012, + "libraryMs": 0.07466299999998682, + "parseRootMs": 2.664658000000003, + "totalMs": 3.863636999999983 + }, + { + "snapshot": true, + "createMs": 1.2694860000000006, + "libraryMs": 0.08011899999999628, + "parseRootMs": 2.93457699999999, + "totalMs": 4.28479999999999 + }, + { + "snapshot": true, + "createMs": 1.1349170000000015, + "libraryMs": 0.07140400000000113, + "parseRootMs": 2.687543000000005, + "totalMs": 3.894159000000002 + }, + { + "snapshot": true, + "createMs": 1.1409439999999904, + "libraryMs": 0.08384499999999662, + "parseRootMs": 2.7108790000000056, + "totalMs": 3.935934000000003 + }, + { + "snapshot": true, + "createMs": 1.02902499999999, + "libraryMs": 0.06665000000000987, + "parseRootMs": 3.2201740000000143, + "totalMs": 4.316179000000005 + }, + { + "snapshot": true, + "createMs": 1.1422780000000046, + "libraryMs": 0.07876500000000419, + "parseRootMs": 3.1041219999999896, + "totalMs": 4.325441999999981 + } + ], + "workloads": [ + { + "name": "return_one", + "records": 1000, + "first": { + "ms": 0.02045000000001096, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 0.0067430000000001655, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004500000000007276, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 0.00392800000000193, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.003920000000022128, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0038839999999993324, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.003793999999999187, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0036589999999989686, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0036360000000001946, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0036079999999856227, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.003692999999998392, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0038319999999885113, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "compute_million", + "records": 1000, + "first": { + "ms": 2.2543229999999994, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 1.729075000000023, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.912637999999987, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 1.8716110000000015, + "reads": 0, + "bytes": 0 + }, + { + "ms": 2.0904930000000093, + "reads": 0, + "bytes": 0 + }, + { + "ms": 2.0281650000000013, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.5658640000000048, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.942448000000013, + "reads": 0, + "bytes": 0 + }, + { + "ms": 2.1716260000000034, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.8115789999999947, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.9441779999999937, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.917677999999995, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "scan", + "records": 1000, + "first": { + "ms": 68.01788299999998, + "reads": 1000, + "bytes": 157586 + }, + "warmup": [ + { + "ms": 53.526859, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 55.65964100000002, + "reads": 1001, + "bytes": 157609 + } + ], + "samples": [ + { + "ms": 50.42851400000001, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 51.299819000000014, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 50.65946900000006, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 50.38195700000006, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 56.57804699999997, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 50.86568999999997, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 60.337645000000066, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 51.71095700000001, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 54.12165300000004, + "reads": 1001, + "bytes": 157609 + } + ] + }, + { + "name": "filter_sort_project", + "records": 1000, + "first": { + "ms": 54.78922899999998, + "reads": 1123, + "bytes": 176930 + }, + "warmup": [ + { + "ms": 59.41758200000004, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 56.19389699999999, + "reads": 1123, + "bytes": 176930 + } + ], + "samples": [ + { + "ms": 59.15263200000004, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 52.85552799999982, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 56.5823519999999, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 54.07847800000013, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 56.144751999999926, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 56.32765799999993, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 55.92604499999993, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 52.85753700000009, + "reads": 1123, + "bytes": 176930 + }, + { + "ms": 55.60695499999997, + "reads": 1123, + "bytes": 176930 + } + ] + }, + { + "name": "serialize_1000", + "records": 1000, + "first": { + "ms": 48.50317300000006, + "reads": 1001, + "bytes": 157609 + }, + "warmup": [ + { + "ms": 50.19024799999988, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 47.07872700000007, + "reads": 1001, + "bytes": 157609 + } + ], + "samples": [ + { + "ms": 50.916590999999926, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 47.401325999999926, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 52.69494699999996, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 50.947032000000036, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 51.32365800000002, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 48.87475900000004, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 48.079004999999825, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 50.78590800000029, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 46.852388000000246, + "reads": 1001, + "bytes": 157609 + } + ] + }, + { + "name": "cached_lookup_1000", + "records": 1000, + "first": { + "ms": 1.1163190000002032, + "reads": 2, + "bytes": 137 + }, + "warmup": [ + { + "ms": 0.8505040000000008, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.8051439999999275, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 0.8280910000003132, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.919926999999916, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.99426799999992, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.023087999999916, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.0704550000000381, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.8471709999998893, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.9492270000000644, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.8445119999996678, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.7976589999998396, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "return_one", + "records": 10000, + "first": { + "ms": 0.02878000000009706, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 0.008854000000155793, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004107000000203698, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 0.0043350000000828, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004558000000088214, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0044849999999314605, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0037130000000615837, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0035889999999199063, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.003586999999697582, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.003602000000228145, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0034960000002683955, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.003568000000086613, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "compute_million", + "records": 10000, + "first": { + "ms": 1.8612069999999221, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 1.867482999999993, + "reads": 0, + "bytes": 0 + }, + { + "ms": 2.0366310000003978, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 1.718449999999848, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.7834210000000894, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.8092880000003788, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.7771999999999935, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.903943000000254, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.797005000000354, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.8123410000002877, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.7239260000001195, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.4137869999999566, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "scan", + "records": 10000, + "first": { + "ms": 555.9280679999997, + "reads": 10000, + "bytes": 1613349 + }, + "warmup": [ + { + "ms": 543.422775, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 526.3893119999998, + "reads": 10001, + "bytes": 1613373 + } + ], + "samples": [ + { + "ms": 539.4425779999997, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 524.8804869999994, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 549.7882899999995, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 521.0241149999993, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 530.5935200000004, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 533.2118090000004, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 531.3271690000001, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 535.7910619999993, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 531.3033669999995, + "reads": 10001, + "bytes": 1613373 + } + ] + }, + { + "name": "filter_sort_project", + "records": 10000, + "first": { + "ms": 628.5899939999999, + "reads": 11668, + "bytes": 1882014 + }, + "warmup": [ + { + "ms": 639.648846, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 621.3071120000004, + "reads": 11668, + "bytes": 1882014 + } + ], + "samples": [ + { + "ms": 649.8565660000004, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 668.6391380000005, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 662.2919500000007, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 646.1405500000001, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 639.1445430000003, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 643.757302, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 650.166365000001, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 607.1727519999986, + "reads": 11668, + "bytes": 1882014 + }, + { + "ms": 638.0564489999997, + "reads": 11668, + "bytes": 1882014 + } + ] + }, + { + "name": "serialize_1000", + "records": 10000, + "first": { + "ms": 54.15045199999986, + "reads": 1001, + "bytes": 157610 + }, + "warmup": [ + { + "ms": 48.42859299999873, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 54.32613700000002, + "reads": 1001, + "bytes": 157610 + } + ], + "samples": [ + { + "ms": 54.642030000002705, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 53.38431800000035, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 52.82633899999928, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 53.951076000001194, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 50.45152600000074, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 54.259805000001506, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 54.53064799999993, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 58.08115600000019, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 57.228831000000355, + "reads": 1001, + "bytes": 157610 + } + ] + }, + { + "name": "cached_lookup_1000", + "records": 10000, + "first": { + "ms": 1.2048549999999523, + "reads": 2, + "bytes": 138 + }, + "warmup": [ + { + "ms": 0.7947660000027099, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.7804890000006708, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 0.7714520000008633, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.8186019999993732, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.8858350000009523, + "reads": 0, + "bytes": 0 + }, + { + "ms": 1.8942759999990813, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.8088459999999031, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.7918370000006689, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.7856059999976424, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.8189520000014454, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.7942390000025625, + "reads": 0, + "bytes": 0 + } + ] + } + ], + "checks": { + "globals": { + "process": "undefined", + "require": "undefined", + "fetch": "undefined" + }, + "staticImport": "Cannot use import statement outside a module [:1:1]", + "dynamicImport": "Not supported", + "timeout": { + "ms": 10000.501776000001, + "message": "Script execution timed out." + } + }, + "snapshotBuildMs": 7.9066040000000015, + "snapshotBytes": 513743, + "heapAfter": { + "total_heap_size": 15622144, + "total_heap_size_executable": 524288, + "total_physical_size": 15872000, + "total_available_size": 64955248, + "used_heap_size": 5162208, + "heap_size_limit": 70254592, + "malloced_memory": 213552, + "peak_malloced_memory": 2825192, + "does_zap_garbage": 0, + "externally_allocated_size": 78482 + } +} \ No newline at end of file diff --git a/benchmark/query-engines/ivm-sort-trace.json b/benchmark/query-engines/ivm-sort-trace.json new file mode 100644 index 0000000..ea2b291 --- /dev/null +++ b/benchmark/query-engines/ivm-sort-trace.json @@ -0,0 +1,20 @@ +[ + { + "name": "filter", + "ms": 565.751063, + "reads": 10000, + "result": 1667 + }, + { + "name": "sort", + "ms": 91.27740399999993, + "reads": 1667, + "result": 1666 + }, + { + "name": "project", + "ms": 0.1880230000000438, + "reads": 0, + "result": "50 checked below" + } +] \ No newline at end of file diff --git a/benchmark/query-engines/make-report.py b/benchmark/query-engines/make-report.py new file mode 100644 index 0000000..d88d006 --- /dev/null +++ b/benchmark/query-engines/make-report.py @@ -0,0 +1,102 @@ +import json,statistics,hashlib,pathlib +q=json.load(open('quickjs-results.json'));v=json.load(open('ivm-results.json')) +def med(xs):return statistics.median(xs) +def stats(w): + a=[s['ms'] for s in w['samples']];return med(a),min(a),max(a) +rows=[] +for a,b in zip(q['workloads'],v['workloads']): + assert (a['name'],a['records'])==(b['name'],b['records']) + x,lo,hi=stats(a);y,vlo,vhi=stats(b) + rows.append(f"| {a['name']} | {a['records']:,} | {x:.3f} ({lo:.3f}–{hi:.3f}) | {y:.3f} ({vlo:.3f}–{vhi:.3f}) | {x/y:.2f}× |") + if a['name']!='filter_sort_project': + assert [s['reads'] for s in a['samples']]==[s['reads'] for s in b['samples']] + assert [s['bytes'] for s in a['samples']]==[s['bytes'] for s in b['samples']] +for n in [1000,10000]:assert pathlib.Path(f'ivm-fixture-{n}.odjt').read_bytes()==pathlib.Path(f'quickjs-fixture-{n}.odjt').read_bytes() +startup=[] +for r in [q,v]: + for snap in [False,True]: + a=[s['totalMs'] for s in r['startup'] if s['snapshot']==snap] + if a:startup.append(f"| {r['engine']} | {'yes' if snap else 'no'} | {med(a):.3f} | {min(a):.3f}–{max(a):.3f} |") +report='''# isolated-vm versus QuickJS: portable od-jsontag benchmark + +2026-09-24. The results favour isolated-vm: about 4.8–5.0× faster indexed scans, 4.6–5.0× faster result serialization, and 25–27× faster for the arithmetic loop. These gains include the host data and text encoding bridges and do not require snapshots. + +## Configuration + +- Node v24.7.0, Linux x64, Intel Core i7-1195G7, isolated-vm 6.2.0, quickjs-emscripten 0.32.0. +- Actual working-tree portable od-jsontag Parser bundled with esbuild 0.25.12. Same bundle and default 256-record cache in both engines. Guest execution is synchronous, with a 10-second timeout/interrupt deadline and a configured 64 MiB runtime limit. The limits are not equivalent total-process memory bounds. +- Identical generated UTF-8 data files with 1,000 records (158,610 bytes) and 10,000 records (1,623,374 bytes), containing numbers, booleans and Unicode strings. Byte-for-byte equality of engine fixtures was checked. +- The guest receives synchronous source reads bound to one host-opened dataset. Every range is validated against its index; it receives copied bytes. It receives no path, file descriptor, Node module, filesystem API, database API, or network API. +- Both engines use host TextEncoder/TextDecoder callbacks. Both use the same JSON-escaped string transport to preserve NUL, BOM, supplementary characters and lone surrogates across the QuickJS boundary. Checks against host encoding results passed. These are minimal adapters for the parser, not complete Web API implementations. +- isolated-vm uses Callback, which copies arguments/results, not guest-visible Reference objects. QuickJS uses its normal synchronous host-function API. +- Engines run sequentially. Each workload has three initial/warmup executions followed by nine measured executions. Timings include guest evaluation, file reads, parsing, callbacks, and returning the scalar or JSON string to the host. Correctness checks occur outside the timing window. All outputs were checked against native host expectations. +- File pages are warm in the OS cache. Fixtures and library bundling are prepared outside query timings. This measures engine/bridge/parser cost, not physical-disk throughput or a dataset larger than RAM. + +## Query measurements + +Milliseconds: median (minimum–maximum) across nine measured executions. All isolated-vm query measurements use an ordinary isolate without a snapshot. + +| Workload | Records | QuickJS ms | isolated-vm ms | Speedup | +|---|---:|---:|---:|---:| +'''+ '\n'.join(rows)+''' + +`scan` sums a property across all records. `filter_sort_project` filters active records in the upper half of the value range, sorts descending, and returns the first 50 names/values as JSON. `serialize_1000` reads and serializes 1,000 records' names, values and notes. `cached_lookup_1000` repeatedly accesses a single cached record. `compute_million` sums integers from zero through 999,999. Return values and result strings match the expected outputs. + +Read counts and bytes match exactly for the measured non-sort workloads. A 10,000-record scan performs 10,001 indexed source reads in both engines; the root record also needs rereading after cache eviction. A repeated 1,000-record serialization performs 1,001 source reads. Cached lookups perform none. + +### Sorting difference + +The sorted query is the same JavaScript and returns the same result, but the engines' sorting behaviour differs. A separate diagnostic run split the 10,000-record query into phases. Both filtered 1,667 records with 10,000 source reads. V8 sorted them with 1,666 comparator calls and 1,667 additional reads; QuickJS made 15,851 comparator calls and 6,294 reads. The result projection made zero additional reads in V8 and 50 in QuickJS. This explains why that query's speedup exceeds the scan speedup: the different access order and comparator count interact with od-jsontag's bounded cache. It is a workload-dependent benefit, not a general sorting speed guarantee. + +## Startup + +Fresh runtime/context, encoding bridge, portable library, 1,000-record offset index, and root parsing. Seven samples per configuration, within an already running Node process after engine module loading. These figures exclude Node process launch and engine module/WASM initialization. + +| Engine | Snapshot | Median ms | Range ms | +|---|---|---:|---:| +'''+ '\n'.join(startup)+f''' + +One initial engine module load took {q['moduleLoadMs']:.2f} ms for QuickJS (including WASM initialization) and {v['moduleLoadMs']:.2f} ms for isolated-vm. These are single observations, not medians. The isolated-vm snapshot took {v['snapshotBuildMs']:.2f} ms to build and occupied {v['snapshotBytes']:,} bytes. It contains trusted library/bootstrap code only, with callbacks and dataset bound afterwards. + +Snapshot initialization worked, but the maintainer [currently advises against createSnapshot due to instability](https://github.com/laverdet/isolated-vm#ivmisolatecreatesnapshotscripts-warmup_script). Its small startup saving is unnecessary for the performance recommendation here. + +## Capability and timeout checks + +- A bare isolated-vm context has no `process`, `require`, `fetch`, TextEncoder or TextDecoder. Encoding APIs are explicitly supplied by the embedding. +- Static import is rejected in script execution; dynamic import rejects with `Not supported`. Dynamic import constructed through `Function()` also rejects. +- An infinite loop was interrupted at approximately 100 ms when configured with a 100 ms timeout. A subsequent `1 + 2` returned 3. The benchmark also exercised a 10-second timeout. +- These are smoke checks of the intended embedding, not a security audit, memory-exhaustion test, or proof of complete query/grant isolation. Production integration must keep host callbacks limited to authorized data and results. + +## Scope and recommendation + +Use isolated-vm for the next engine integration trial. Its advantage is substantial for the actual portable parser and synchronous lazy-source design, including before warmup: inspect the raw `first` values for first-query measurements. Snapshots are unnecessary. + +This does not yet exercise SimplyStore's complete request path, JAQT library, authorization rules, concurrent workers, cancellation across host callbacks, full Web API compatibility, or production result-size limits. The library is native and version-sensitive; its maintainer recommends keeping isolate execution in a different process from critical infrastructure. See the [isolated-vm documentation](https://github.com/laverdet/isolated-vm#security). Heap limits were configured, but RSS and OOM behaviour were not benchmarked. + +## Reproduction and artifacts + +Run from this directory with the existing portable od-jsontag checkout and its installed dependencies: + +```sh +npm ci --no-audit --no-fund +node --no-node-snapshot bench.mjs ivm > ivm-run.log 2>&1 +node --no-node-snapshot bench.mjs quickjs > quickjs-run.log 2>&1 +node --no-node-snapshot sort-trace.mjs ivm +node --no-node-snapshot sort-trace.mjs quickjs +node --no-node-snapshot guard-checks.mjs +python3 make-report.py +``` + +Run the two benchmark engines sequentially to avoid CPU contention. The benchmark imports esbuild and QuickJS from `/home/auke/git/slonl/spiral/od-jsontag/node_modules`; adjust the `repo` path/imports if relocating it. + +- [Benchmark source](bench.mjs) +- [QuickJS raw results](quickjs-results.json) +- [isolated-vm raw results](ivm-results.json) +- [Sort diagnostic source](sort-trace.mjs) +- [QuickJS sort trace](quickjs-sort-trace.json) +- [isolated-vm sort trace](ivm-sort-trace.json) +- [Capability-check source](guard-checks.mjs) +- [Capability-check results](guard-results.json) +''' +pathlib.Path('comparison.md').write_text(report) +print('Report written; fixtures and non-sort read counts verified.') diff --git a/benchmark/query-engines/quickjs-results.json b/benchmark/query-engines/quickjs-results.json new file mode 100644 index 0000000..e8b1589 --- /dev/null +++ b/benchmark/query-engines/quickjs-results.json @@ -0,0 +1,895 @@ +{ + "engine": "quickjs", + "node": "v24.7.0", + "cpu": "11th Gen Intel(R) Core(TM) i7-1195G7 @ 2.90GHz", + "moduleLoadMs": 13.964016, + "bundleBytes": 78474, + "fixtures": [ + { + "records": 1000, + "bytes": 158610 + }, + { + "records": 10000, + "bytes": 1623374 + } + ], + "startup": [ + { + "snapshot": false, + "createMs": 2.4481010000000083, + "libraryMs": 22.263450000000006, + "parseRootMs": 12.392296999999985, + "totalMs": 37.10553300000001 + }, + { + "snapshot": false, + "createMs": 0.291671000000008, + "libraryMs": 7.358692999999988, + "parseRootMs": 8.075009000000023, + "totalMs": 15.726214999999996 + }, + { + "snapshot": false, + "createMs": 0.2421549999999968, + "libraryMs": 5.979744000000011, + "parseRootMs": 6.762436999999977, + "totalMs": 12.984955999999983 + }, + { + "snapshot": false, + "createMs": 0.24763699999999744, + "libraryMs": 5.964815999999985, + "parseRootMs": 6.044708000000014, + "totalMs": 12.257619999999974 + }, + { + "snapshot": false, + "createMs": 0.3245719999999892, + "libraryMs": 7.377217000000002, + "parseRootMs": 6.438749999999999, + "totalMs": 14.140948999999978 + }, + { + "snapshot": false, + "createMs": 0.22299999999998477, + "libraryMs": 7.567000000000007, + "parseRootMs": 6.323993999999999, + "totalMs": 14.114531999999997 + }, + { + "snapshot": false, + "createMs": 0.2174879999999746, + "libraryMs": 7.0547570000000235, + "parseRootMs": 5.790482999999995, + "totalMs": 13.063309000000004 + } + ], + "workloads": [ + { + "name": "return_one", + "records": 1000, + "first": { + "ms": 0.025333999999986645, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 0.008225999999979194, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.007723999999996067, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 0.013521999999966283, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.006881000000021231, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.006282999999996264, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.006018999999980679, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.005933999999967909, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.005862999999976637, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.005824999999958891, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.005785000000003038, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0058410000000321816, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "compute_million", + "records": 1000, + "first": { + "ms": 48.14075600000001, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 47.30291299999999, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.423343999999986, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 47.24331799999999, + "reads": 0, + "bytes": 0 + }, + { + "ms": 52.39911599999999, + "reads": 0, + "bytes": 0 + }, + { + "ms": 46.60196399999995, + "reads": 0, + "bytes": 0 + }, + { + "ms": 46.46365800000001, + "reads": 0, + "bytes": 0 + }, + { + "ms": 46.49573099999998, + "reads": 0, + "bytes": 0 + }, + { + "ms": 52.41403700000001, + "reads": 0, + "bytes": 0 + }, + { + "ms": 49.959663999999975, + "reads": 0, + "bytes": 0 + }, + { + "ms": 48.008005000000026, + "reads": 0, + "bytes": 0 + }, + { + "ms": 51.313267999999994, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "scan", + "records": 1000, + "first": { + "ms": 293.1870359999999, + "reads": 1000, + "bytes": 157586 + }, + "warmup": [ + { + "ms": 265.40995299999986, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 258.3449190000001, + "reads": 1001, + "bytes": 157609 + } + ], + "samples": [ + { + "ms": 258.020311, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 262.1884980000002, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 254.965776, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 248.76691000000028, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 253.2323590000001, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 250.24952499999972, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 249.16143599999987, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 245.58934, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 256.32855900000004, + "reads": 1001, + "bytes": 157609 + } + ] + }, + { + "name": "filter_sort_project", + "records": 1000, + "first": { + "ms": 295.39591500000006, + "reads": 1103, + "bytes": 173830 + }, + "warmup": [ + { + "ms": 302.2854950000001, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 283.295032, + "reads": 1103, + "bytes": 173830 + } + ], + "samples": [ + { + "ms": 281.193362, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 275.3405819999998, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 277.797286, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 266.3954300000005, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 276.269053, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 281.50339500000064, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 278.46840900000007, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 278.0714130000006, + "reads": 1103, + "bytes": 173830 + }, + { + "ms": 267.7469940000001, + "reads": 1103, + "bytes": 173830 + } + ] + }, + { + "name": "serialize_1000", + "records": 1000, + "first": { + "ms": 254.07522199999948, + "reads": 1001, + "bytes": 157609 + }, + "warmup": [ + { + "ms": 247.5461949999999, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 256.14939700000014, + "reads": 1001, + "bytes": 157609 + } + ], + "samples": [ + { + "ms": 259.8461670000006, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 260.1960049999998, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 253.8874169999999, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 255.63664499999868, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 249.07098700000097, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 255.09665499999937, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 250.23376000000098, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 245.19101200000114, + "reads": 1001, + "bytes": 157609 + }, + { + "ms": 248.87152400000014, + "reads": 1001, + "bytes": 157609 + } + ] + }, + { + "name": "cached_lookup_1000", + "records": 1000, + "first": { + "ms": 5.795607000000018, + "reads": 2, + "bytes": 137 + }, + "warmup": [ + { + "ms": 5.16904600000089, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.108635999999024, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 5.081598999999187, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.151323999998567, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.332480000000942, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.271886999998969, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.288945999998759, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.279581999999209, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.261912999998458, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.203145999999833, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.225366000000577, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "return_one", + "records": 10000, + "first": { + "ms": 0.02656600000045728, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 0.0055220000012923265, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004694999999628635, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 0.004515000000537839, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004558000000542961, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004404999999678694, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004474999999729334, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004430999999385676, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.005140000001119915, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.0051330000005691545, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004967999999280437, + "reads": 0, + "bytes": 0 + }, + { + "ms": 0.004579000000376254, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "compute_million", + "records": 10000, + "first": { + "ms": 47.21871300000021, + "reads": 0, + "bytes": 0 + }, + "warmup": [ + { + "ms": 47.69709200000034, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.07934699999896, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 48.62357900000097, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.19401400000061, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.60656899999958, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.94988700000067, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.28161600000021, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.4822720000011, + "reads": 0, + "bytes": 0 + }, + { + "ms": 46.50777899999957, + "reads": 0, + "bytes": 0 + }, + { + "ms": 45.79095299999972, + "reads": 0, + "bytes": 0 + }, + { + "ms": 47.698575000000346, + "reads": 0, + "bytes": 0 + } + ] + }, + { + "name": "scan", + "records": 10000, + "first": { + "ms": 2619.7832369999996, + "reads": 10000, + "bytes": 1613349 + }, + "warmup": [ + { + "ms": 2566.748624, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2530.8668479999997, + "reads": 10001, + "bytes": 1613373 + } + ], + "samples": [ + { + "ms": 2532.1640050000024, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2565.805106, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2568.6325260000012, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2613.2714699999997, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2535.6507029999993, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2548.669995, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2549.731780999995, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2723.230505000007, + "reads": 10001, + "bytes": 1613373 + }, + { + "ms": 2511.493094999998, + "reads": 10001, + "bytes": 1613373 + } + ] + }, + { + "name": "filter_sort_project", + "records": 10000, + "first": { + "ms": 4120.067822999998, + "reads": 16345, + "bytes": 2635761 + }, + "warmup": [ + { + "ms": 4186.921709000002, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4186.869650000001, + "reads": 16345, + "bytes": 2635761 + } + ], + "samples": [ + { + "ms": 4114.597240999996, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4191.183602000005, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4170.409766000004, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4175.420066000006, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4212.484142000001, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4182.956351999994, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4141.90956, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4162.852851999996, + "reads": 16345, + "bytes": 2635761 + }, + { + "ms": 4117.297793000005, + "reads": 16345, + "bytes": 2635761 + } + ] + }, + { + "name": "serialize_1000", + "records": 10000, + "first": { + "ms": 251.60221099999035, + "reads": 1001, + "bytes": 157610 + }, + "warmup": [ + { + "ms": 260.2654649999895, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 244.1964059999882, + "reads": 1001, + "bytes": 157610 + } + ], + "samples": [ + { + "ms": 253.0963880000054, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 256.12882199999876, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 247.7797850000061, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 250.78732799999125, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 247.66249000000244, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 250.03126899999916, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 243.74109199999657, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 250.65803099999903, + "reads": 1001, + "bytes": 157610 + }, + { + "ms": 248.5568220000132, + "reads": 1001, + "bytes": 157610 + } + ] + }, + { + "name": "cached_lookup_1000", + "records": 10000, + "first": { + "ms": 5.737930000002962, + "reads": 2, + "bytes": 138 + }, + "warmup": [ + { + "ms": 5.235473999986425, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.239010000004782, + "reads": 0, + "bytes": 0 + } + ], + "samples": [ + { + "ms": 5.193794999999227, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.321091000005254, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.313674000004539, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.1320120000018505, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.272683000002871, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.25749899999937, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.1932919999962905, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.2369719999987865, + "reads": 0, + "bytes": 0 + }, + { + "ms": 5.310794999997597, + "reads": 0, + "bytes": 0 + } + ] + } + ], + "checks": { + "globals": { + "process": "undefined", + "require": "undefined", + "fetch": "undefined" + }, + "staticImport": "{\"name\":\"ReferenceError\",\"message\":\"could not load module 'node:fs'\",\"stack\":\"\"}" + }, + "heapAfter": null +} \ No newline at end of file diff --git a/benchmark/query-engines/quickjs-sort-trace.json b/benchmark/query-engines/quickjs-sort-trace.json new file mode 100644 index 0000000..04bf1aa --- /dev/null +++ b/benchmark/query-engines/quickjs-sort-trace.json @@ -0,0 +1,20 @@ +[ + { + "name": "filter", + "ms": 2545.933258, + "reads": 10000, + "result": 1667 + }, + { + "name": "sort", + "ms": 1506.7481000000002, + "reads": 6294, + "result": 15851 + }, + { + "name": "project", + "ms": 12.264632000000347, + "reads": 50, + "result": "50 checked below" + } +] \ No newline at end of file diff --git a/benchmark/query-engines/sort-trace.mjs b/benchmark/query-engines/sort-trace.mjs new file mode 100644 index 0000000..aa3fe35 --- /dev/null +++ b/benchmark/query-engines/sort-trace.mjs @@ -0,0 +1,64 @@ +import {build} from '/home/auke/git/slonl/spiral/od-jsontag/node_modules/esbuild/lib/main.js'; +import {readFileSync,writeFileSync,openSync,closeSync,readSync} from 'node:fs'; +import {performance} from 'node:perf_hooks'; +import {cpus} from 'node:os'; +import assert from 'node:assert/strict'; +const repo='/home/auke/git/slonl/spiral/od-jsontag'; +const engine=process.argv[2]||'ivm'; +const clock=()=>performance.now(); +const loadStart=clock(); +const ivm=engine==='ivm'?(await import('isolated-vm')).default:null; +const QuickJS=engine==='quickjs'?await (await import(repo+'/node_modules/quickjs-emscripten/dist/index.mjs')).getQuickJS():null; +const moduleLoadMs=clock()-loadStart; +const bundle=(await build({stdin:{contents:"import Parser from './src/parse.mjs'; globalThis.library={Parser};",resolveDir:repo},bundle:true,platform:'neutral',mainFields:['main'],format:'iife',write:false})).outputFiles[0].text; +// Only copied primitive values/bytes cross the boundary. The callbacks are captured +// in closures, not exposed as raw isolated-vm Reference objects. +const bootstrap=`(()=>{let enc,dec,read; + globalThis.__bind=(a,b,c)=>{enc=a;dec=b;read=c;delete globalThis.__bind}; + globalThis.TextEncoder=class{encode(text=''){return new Uint8Array(enc(JSON.stringify(String(text))))}}; + globalThis.TextDecoder=class{decode(bytes=new Uint8Array()){return JSON.parse(dec(bytes.buffer,bytes.byteOffset,bytes.byteLength))}}; + globalThis.makeSource=byteLength=>({byteLength,read(a,b){return new Uint8Array(read(a,b))}}); +})();`; +const encoder=new TextEncoder(),decoder=new TextDecoder(); +function fixture(n){const index={},chunks=[],values=[];let offset=0; + for(let i=0;i<=n;i++){ + const item={name:'Padmé 𠮷 '+i,value:i,group:i%20,active:i%3===0,note:'Unicode café 日本語 '+('x'.repeat(i%80))}; + const body=i===0?`{"items":[~1-${n}]}`:`${JSON.stringify(item)}`; + if(i)values.push(item); + const bytes=Buffer.from(`(${Buffer.byteLength(body)})${body}`);index[i]=[offset,offset+bytes.length];chunks.push(bytes,Buffer.from('\n'));offset+=bytes.length+1; + } + const path=`${engine}-fixture-${n}.odjt`;writeFileSync(path,Buffer.concat(chunks));const fd=openSync(path,'r'); + const ranges=new Map(Object.values(index));let reads=0,bytesRead=0; + return {n,index,values,size:offset,read(a,b){assert.ok(Number.isSafeInteger(a)&&ranges.get(a)===b,'invalid source range');const bytes=Buffer.alloc(b-a);let used=0;while(used0);used+=count;}reads++;bytesRead+=bytes.length;return bytes;},stats(){return{reads,bytesRead}},close(){closeSync(fd)}}; +} +const data=[fixture(1000),fixture(10000)]; +function create(input,snapshot){ + const start=clock();let evaluate,dispose,bind,heap; + if(ivm){const isolate=new ivm.Isolate({memoryLimit:64,...(snapshot?{snapshot}:{})});const ctx=isolate.createContextSync(); + evaluate=code=>ctx.evalSync(code,{timeout:10000}); + bind=()=>ctx.evalClosureSync('__bind($0,$1,$2)',[ + new ivm.Callback(text=>encoder.encode(JSON.parse(text)).buffer), + new ivm.Callback((buffer,start,length)=>JSON.stringify(decoder.decode(new Uint8Array(buffer,start,length)))), + new ivm.Callback((a,b)=>Uint8Array.from(input.read(a,b)).buffer) + ]); + heap=()=>isolate.getHeapStatisticsSync();dispose=()=>isolate.dispose(); + }else{const q=QuickJS.newContext();q.runtime.setMemoryLimit(64*1024*1024);let deadline=0;q.runtime.setInterruptHandler(()=>Date.now()>deadline); + evaluate=code=>{deadline=Date.now()+10000;const result=q.evalCode(code);const h=result.error||result.value;try{const value=q.dump(h);if(result.error)throw Error(JSON.stringify(value));return value;}finally{h.dispose()}}; + function expose(name,fn){const h=q.newFunction(name,fn);q.setProp(q.global,name,h);h.dispose()} + bind=()=>{expose('__enc',text=>q.newArrayBuffer(encoder.encode(JSON.parse(q.getString(text))))); + expose('__dec',(buffer,start,length)=>{const bytes=q.getArrayBuffer(buffer);try{return q.newString(JSON.stringify(decoder.decode(bytes.value.subarray(q.getNumber(start),q.getNumber(start)+q.getNumber(length)))))}finally{bytes.dispose()}}); + expose('__read',(a,b)=>q.newArrayBuffer(input.read(q.getNumber(a),q.getNumber(b))));evaluate('__bind(__enc,__dec,__read);delete globalThis.__enc;delete globalThis.__dec;delete globalThis.__read;void 0;');}; + heap=()=>null;dispose=()=>q.dispose(); + } + const created=clock();if(!snapshot)evaluate(bootstrap);bind();if(!snapshot)evaluate(bundle+'\nvoid 0;');const loaded=clock(); + evaluate(`globalThis.parser=new library.Parser();globalThis.root=parser.parse(makeSource(${input.size}),${JSON.stringify(input.index)});void 0;`); + return {evaluate,dispose,heap,times:{createMs:created-start,libraryMs:loaded-created,parseRootMs:clock()-loaded,totalMs:clock()-start}}; +} + +const input=data[1],vm=create(input);const phases=[]; +for(const [name,code] of [ + ['filter','globalThis.selected=root.items.filter(x=>x.active&&x.value>5000);selected.length'], + ['sort','globalThis.comparisons=0;selected.sort((a,b)=>{comparisons++;return b.value-a.value});comparisons'], + ['project','JSON.stringify(selected.slice(0,50).map(x=>({name:x.name,value:x.value})))'] +]) {const before=input.stats(),start=clock(),value=vm.evaluate(code),ms=clock()-start,after=input.stats();phases.push({name,ms,reads:after.reads-before.reads,result:name==='project'?'50 checked below':value});if(name==='project')assert.equal(value,JSON.stringify(input.values.filter(x=>x.active&&x.value>5000).sort((a,b)=>b.value-a.value).slice(0,50).map(x=>({name:x.name,value:x.value}))));} +vm.dispose();for(const f of data)f.close();writeFileSync(engine+'-sort-trace.json',JSON.stringify(phases,null,2));console.log(JSON.stringify(phases,null,2)); diff --git a/benchmark/query-engines/worker-results.json b/benchmark/query-engines/worker-results.json new file mode 100644 index 0000000..7ae4627 --- /dev/null +++ b/benchmark/query-engines/worker-results.json @@ -0,0 +1,85 @@ +{ + "node": "v24.7.0", + "cpu": "11th Gen Intel(R) Core(TM) i7-1195G7 @ 2.90GHz", + "description": "Fresh isolates, host grants, file reads and worker messaging", + "results": [ + { + "records": 1000, + "fileBytes": 158610, + "results": [ + { + "name": "scan", + "samples": [ + 68.43726199999998, + 51.36380500000001, + 48.691156999999976, + 47.96151999999995, + 45.890286999999944 + ], + "median": 48.691156999999976 + }, + { + "name": "filter_sort_project", + "samples": [ + 52.851056000000085, + 53.430507000000034, + 54.524095999999986, + 52.483430999999996, + 52.780580999999984 + ], + "median": 52.851056000000085 + }, + { + "name": "serialize_1000", + "samples": [ + 101.06642399999987, + 84.88788999999997, + 79.31005200000004, + 81.33113400000002, + 79.86430799999994 + ], + "median": 81.33113400000002 + } + ] + }, + { + "records": 10000, + "fileBytes": 1623374, + "results": [ + { + "name": "scan", + "samples": [ + 472.56225600000016, + 453.27108099999987, + 436.78112199999987, + 458.0637080000006, + 417.0817109999998 + ], + "median": 453.27108099999987 + }, + { + "name": "filter_sort_project", + "samples": [ + 497.89933199999996, + 500.01015000000007, + 495.4254520000004, + 532.6655490000003, + 487.0213980000008 + ], + "median": 497.89933199999996 + }, + { + "name": "serialize_1000", + "samples": [ + 81.00112600000102, + 81.2441830000007, + 79.86988099999871, + 80.02495599999929, + 82.40059099999962 + ], + "median": 81.00112600000102 + } + ] + } + ] +} diff --git a/docs/query-runtime.md b/docs/query-runtime.md new file mode 100644 index 0000000..69f5b42 --- /dev/null +++ b/docs/query-runtime.md @@ -0,0 +1,98 @@ +# Query execution with isolated-vm + +SimplyStore runs each JavaScript query in a fresh V8 isolate using isolated-vm. +It supplies `root`, path-selected `data`, `request`, `meta.index.id.get/has`, +`meta.schema`, JSONTag and the existing JAQT helpers. Standard JavaScript objects +and array methods remain available. Queries are synchronous; Promise results +are rejected. + +Queries have no `import` resolver, `require`, `process`, `Buffer`, `fetch`, +filesystem, database, or networking API. Both static and dynamic imports fail, +including imports constructed with `Function`. WebAssembly and shared-memory +APIs are not exposed. A query cannot change the globals or prototypes seen by +another query. + +## Data and grants + +The trusted Node worker owns file handles, the indexed od-jsontag parser and +the configured access module. Queries see read-only object/array views. Private +callbacks return copied values and view descriptions; they never return Node +objects, functions, file descriptors, raw record bytes, parser internals or +isolated-vm references. Grants apply on the host to reads, enumeration, +descriptors and property existence. Access modules can keep their trusted Node +imports and closures. + +Record numbers and property paths let the host resolve views without pinning +every scanned record in memory. Tagged scalar values are copied into the guest; +changes to such copies do not modify stored data. Object and array mutations +throw. Committed source updates still serialize with queries, and replacement +workers initialize at the current committed head. + +This differs from the initial engine benchmark's raw-record bridge. Keeping the +existing parser and grants on the host avoids moving authority into the hostile +query realm. This integration uses od-jsontag 0.5.0 through its Node entry point +(`src/node.mjs`) for file-descriptor input. The package's portable parser is not +loaded into the query isolate. + +## Limits + +Pass these options when opening SimplyStore: + +| Option | Default | Meaning | +|---|---:|---| +| `timeout` | 1,000 ms | Normal query execution and serialization deadline | +| `slowTimeout` | 10,000 ms | Slow-query deadline | +| `queryMemoryLimit` | 64 MiB | isolated-vm memory budget, minimum 8 | +| `maxQueryResultBytes` | 10 MiB | Maximum UTF-8 response body and individual bridge value, minimum 256 bytes | + +Values must be positive safe integers, respecting the listed minima. Query +limits cannot be disabled. The pool allows 250 ms beyond the selected deadline +for timeout reporting, then terminates/replaces a stuck worker. An isolate is +disposed after every query. Execution/serialization/size/memory failures normally +return code 422; an outer worker timeout is code 504. Error messages are bounded. +Real storage-read failures return code 500 and stop mutation, even if query code +catches the read exception. Query-thrown storage flags have no such authority. + +Large property values or key enumerations may reach the bridge limit before +serialization. Result serialization itself runs under the execution deadline, +including user getters and `toJSON` methods. UTF-8 size is checked before the +result is copied from the isolate. + +The isolate memory budget is not a hard total-process RSS limit. Native V8, +Node worker state, indexes and trusted data reads have additional costs. The +worker threads share the server process; this change does not provide a separate +OS-process crash boundary or certify public internet exposure. Keep deployment +access controls appropriate to the existing operating envelope. + +## Installation and startup + +Use Node 22 or newer with an isolated-vm-compatible V8 version; this integration +was tested on Node 24.7.0 with isolated-vm 6.2.0. The addon needs a compatible +prebuilt native binary or the compiler prerequisites documented by +[isolated-vm](https://github.com/laverdet/isolated-vm#requirements). +Follow its startup requirement: + +```sh +node --no-node-snapshot myApp.mjs +``` + +The supplied npm start/test scripts and example start script include that flag. +It disables Node's startup snapshot; SimplyStore does not create isolate +snapshots. The pure query helper bundle is compiled once per worker by esbuild. +The guest receives the compiled JavaScript, with no module loading available. + +## Verification and performance + +`npm test` includes query compatibility and hostile-query checks. +Run the actual worker-path benchmark with: + +```sh +node --no-node-snapshot scripts/benchmark-query-runtime.mjs +``` + +It creates disposable datasets and checks every result. It includes fresh +isolates, access callbacks, lazy file reads, serialization and worker messaging. +Its warm filesystem cache and small synthetic datasets do not establish physical +disk throughput, total memory use, concurrent capacity, or universal speedups. +The initial [engine comparison](../benchmark/query-engines/comparison.md) retains +the separate raw-parser experiment that motivated engine selection. diff --git a/example/package.json b/example/package.json index b9de07e..eb790a3 100644 --- a/example/package.json +++ b/example/package.json @@ -3,7 +3,7 @@ "main": "server.mjs", "scripts": { "build": "node ../scripts/convert.mjs swdb.jsontag data.jsontag", - "start": "node server.mjs", + "start": "node --no-node-snapshot server.mjs", "init-integrity": "node ../scripts/recover.mjs init-integrity --store store.json" }, "dependencies": { diff --git a/package.json b/package.json index 40c18c5..65bddf7 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { "name": "@muze-nl/simplystore", - "version": "0.10.3", + "version": "0.11.0", "main": "src/server.mjs", "type": "module", "scripts": { - "start": "node src/run.mjs", - "test": "node --test test/required-integrity.mjs test/file-data.mjs test/index-ids.mjs test/store-runtime.mjs test/durability-startup.mjs test/conversion-offsets.mjs test/command-offsets.mjs", - "test:acid": "node --test test/acid-baseline.mjs", - "test:durability": "node --test test/durability-crash.mjs", - "test:handlers": "node --test test/after-change-handlers.mjs", - "test:power-loss": "node --test --test-reporter=tap test/power-loss-baseline.mjs", + "start": "node --no-node-snapshot src/run.mjs", + "test": "node --no-node-snapshot --test test/query-isolate.mjs test/required-integrity.mjs test/file-data.mjs test/index-ids.mjs test/store-runtime.mjs test/durability-startup.mjs test/conversion-offsets.mjs test/command-offsets.mjs", + "test:acid": "node --no-node-snapshot --test test/acid-baseline.mjs", + "test:durability": "node --no-node-snapshot --test test/durability-crash.mjs", + "test:handlers": "node --no-node-snapshot --test test/after-change-handlers.mjs", + "test:power-loss": "node --no-node-snapshot --test --test-reporter=tap test/power-loss-baseline.mjs", "test:power-loss:strict": "SIMPLYSTORE_BASELINE_STRICT=1 node --test --test-reporter=tap test/power-loss-baseline.mjs", - "test:recovery": "node --test test/storage.mjs test/admin-recovery.mjs", - "bench:durability": "node --test test/durability-latency.mjs" + "test:recovery": "node --no-node-snapshot --test test/storage.mjs test/admin-recovery.mjs", + "bench:durability": "node --no-node-snapshot --test test/durability-latency.mjs" }, "author": "auke@muze.nl", "license": "MIT", @@ -25,9 +25,10 @@ "dependencies": { "@muze-nl/jaqt": "^0.10.6", "@muze-nl/jsontag": "^0.10.4", - "@muze-nl/od-jsontag": "git+https://github.com/muze-nl/od-jsontag.git#24f47eb616c3a24391487197496804ed1c56837b", + "@muze-nl/od-jsontag": "0.5.0", + "esbuild": "0.25.12", "express": "^5.2.1", - "vm2": "^3.12.2" + "isolated-vm": "6.2.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -43,6 +44,10 @@ "LICENSE", "src/", "www/", - "scripts/" - ] + "scripts/", + "docs/query-runtime.md" + ], + "engines": { + "node": ">=22.0.0" + } } diff --git a/scripts/benchmark-query-runtime.mjs b/scripts/benchmark-query-runtime.mjs new file mode 100644 index 0000000..d3edc75 --- /dev/null +++ b/scripts/benchmark-query-runtime.mjs @@ -0,0 +1,99 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' +import StoreRuntime from '../src/store-runtime.mjs' +import { appendIntegrityRecord } from '../src/integrity.mjs' + +function median(values) { + return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] +} + +async function benchmark(count) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'query-bench-')) + let runtime + try { + const chunks = [] + const values = [] + for (let index = 0; index <= count; index++) { + let body = `{"items":[~1-${count}]}` + if (index) { + const value = { + name: 'Padmé 𠮷 ' + index, value: index, + group: index % 20, active: index % 3 === 0, + note: 'Unicode café 日本語 ' + 'x'.repeat(index % 80) + } + values.push(value) + body = `` + JSON.stringify(value) + } + chunks.push(`(${Buffer.byteLength(body)})${body}\n`) + } + const bytes = Buffer.from(chunks.join('')) + const datafile = path.join(directory, 'data.jsontag') + const integrityFile = path.join(directory, 'data.integrity.jsontag') + const access = path.join(directory, 'access.mjs') + await fs.writeFile(datafile, bytes) + await fs.writeFile(path.join(directory, 'command-log.jsontag'), '') + await fs.writeFile(path.join(directory, 'command-status.jsontag'), '') + await fs.writeFile(access, `export default (object, property) => { + return property !== 'denied' + }`) + await appendIntegrityRecord(integrityFile, datafile, bytes) + runtime = await StoreRuntime.open({ + datafile, integrityFile, access, maxWorkers: 1, timeout: 10000, + commandLog: path.join(directory, 'command-log.jsontag'), + commandStatus: path.join(directory, 'command-status.jsontag') + }) + const queries = [ + ['scan', 'data.items.reduce((s,x)=>s+x.value,0)', + count * (count + 1) / 2], + ['filter_sort_project', `data.items.filter(x=>x.active&& + x.value>${count / 2}).sort((a,b)=>b.value-a.value) + .slice(0,50).map(x=>({name:x.name,value:x.value}))`, + values.filter(value => value.active && value.value > count / 2) + .sort((a, b) => b.value - a.value).slice(0, 50) + .map(value => ({name: value.name, value: value.value}))], + ['serialize_1000', `data.items.slice(0,1000).map(x=>({ + name:x.name,value:x.value,note:x.note}))`, + values.slice(0, 1000).map(value => ({ + name: value.name, value: value.value, note: value.note + }))] + ] + const results = [] + // Finish worker initialization before measuring any query. + await runtime.runQuery({path: '/', body: '1', jsontag: false}) + for (const [name, body, expected] of queries) { + const samples = [] + for (let iteration = 0; iteration < 7; iteration++) { + const start = performance.now() + const result = await runtime.runQuery({ + path: '/', body, jsontag: false + }) + const duration = performance.now() - start + assert.equal(result.code, undefined, result.body) + assert.deepEqual(JSON.parse(result.body), expected) + if (iteration >= 2) { + samples.push(duration) + } + } + results.push({name, samples, median: median(samples)}) + } + return {records: count, fileBytes: bytes.length, results} + } + finally { + await runtime?.close() + await fs.rm(directory, {recursive: true, force: true}) + } +} + +const results = [] +for (const count of [1000, 10000]) { + results.push(await benchmark(count)) +} +console.log(JSON.stringify({ + node: process.version, + cpu: os.cpus()[0].model, + description: 'Fresh isolates, host grants, file reads and worker messaging', + results +}, null, 2)) diff --git a/src/file-data.mjs b/src/file-data.mjs index 58bd177..265844e 100644 --- a/src/file-data.mjs +++ b/src/file-data.mjs @@ -8,7 +8,7 @@ import idIndex, { import offsetIndex from './index.offset.mjs' import { loadStoredIndex } from './index-files.mjs' import JSONTag from '@muze-nl/jsontag' -import Parser from '@muze-nl/od-jsontag/src/parse.mjs' +import Parser from '@muze-nl/od-jsontag/src/node.mjs' import { RecoveryIntegrityError, assertChangesetExists } from './recovery.mjs' import { getDefaultIntegrityFile, loadIntegrityManifest, verifyDigest } from './integrity.mjs' diff --git a/src/query-guest.mjs b/src/query-guest.mjs new file mode 100644 index 0000000..348d7b7 --- /dev/null +++ b/src/query-guest.mjs @@ -0,0 +1,257 @@ +import JSONTag from '@muze-nl/jsontag' +import * as jaqt from '@muze-nl/jaqt' + +// Bundled as pure JavaScript. This module never imports host APIs. Callbacks +// remain private to this closure, and exchange only copied data. +export function prepare(read, validURL, initial) { + const symbols = [ + Symbol['JSONTag:Type'], + Symbol['JSONTag:Attributes'], + Symbol['JSONTag:Null'] + ] + const setPrototype = Object.setPrototypeOf + const freeze = Object.freeze + const WeakReference = WeakRef + const stringifyTagged = JSONTag.stringify + const stringify = JSON.stringify + const parse = JSONTag.parse + const charCode = Function.prototype.call.bind(String.prototype.charCodeAt) + const cache = new Map() + const getCached = cache.get.bind(cache) + const setCached = cache.set.bind(cache) + const dereference = Function.prototype.call.bind(WeakRef.prototype.deref) + const makeWeak = value => new WeakReference(value) + const metadataKey = key => symbols.indexOf(key) + const immutable = () => { + throw new Error('dataspace is immutable') + } + JSONTag.Parser.prototype.isUrl = function (url) { + if (!validURL(String(url), String(this.meta.baseURL))) { + this.typeError('url', url) + } + return true + } + + function wrap(description) { + setPrototype(description, null) + if ('tagged' in description) { + return parse(description.tagged) + } + if (!description.reference) { + return description.value + } + const reference = description.reference + setPrototype(reference, null) + freeze(reference) + const key = stringify(reference) + const cached = getCached(key) + if (cached) { + const existing = dereference(cached) + if (existing) { + return existing + } + } + let target = {} + if (description.array) { + target = new Array(description.length) + Object.defineProperty(target, 'length', { writable: false }) + } + const remoteKey = key => { + if (typeof key === 'symbol') { + return metadataKey(key) + } + return key + } + const proxy = new Proxy(target, { + get(target, key, receiver) { + const encoded = remoteKey(key) + if (encoded !== -1) { + const result = read('get', reference, encoded) + if (result.present) { + return wrap(result.entry) + } + } + return Reflect.get(target, key, receiver) + }, + has(target, key) { + const encoded = remoteKey(key) + return (encoded !== -1 && read('has', reference, encoded)) || + Reflect.has(target, key) + }, + ownKeys() { + return read('keys', reference).map(key => { + if (typeof key === 'number') { + return symbols[key] + } + return key + }) + }, + getOwnPropertyDescriptor(target, key) { + if (description.array && key === 'length') { + return Reflect.getOwnPropertyDescriptor(target, key) + } + const encoded = remoteKey(key) + if (encoded === -1) { + return undefined + } + const result = read('get', reference, encoded) + if (!result.present) { + return undefined + } + return { + value: wrap(result.entry), + enumerable: result.enumerable, + configurable: true, + writable: false + } + }, + set: immutable, + deleteProperty: immutable, + defineProperty: immutable, + setPrototypeOf: immutable, + preventExtensions: immutable + }) + setCached(key, makeWeak(proxy)) + return proxy + } + + const root = wrap(initial.root) + const meta = { + index: { + id: { + get(id) { + if (typeof id !== 'string' && typeof id !== 'number') { + return undefined + } + return wrap(read('id', null, id)) + }, + has(id) { + if (typeof id !== 'string' && typeof id !== 'number') { + return false + } + return read('hasId', null, id) + } + } + }, + schema: wrap(initial.schema) + } + const request = initial.request + const taggedResponse = Boolean(request.jsontag) + const browse = !request.body + const [data, path] = getDataSpace(request.path, root) + Object.assign(globalThis, jaqt, { root, data, meta, request, JSONTag }) + delete globalThis.WebAssembly + delete globalThis.SharedArrayBuffer + delete globalThis.Atomics + + // Keep the limit and length calculation outside query-writable globals. + const maxBytes = initial.maxBytes + function checkSize(text) { + if (text === undefined) { + return + } + let bytes = 0 + for (let index = 0; index < text.length; index++) { + const code = charCode(text, index) + if (code < 0x80) { + bytes++ + } + else if (code < 0x800) { + bytes += 2 + } + else if (code >= 0xd800 && code <= 0xdbff && + charCode(text, index + 1) >= 0xdc00 && + charCode(text, index + 1) <= 0xdfff) { + bytes += 4 + index++ + } + else { + bytes += 3 + } + if (bytes > maxBytes) { + throw new Error('Query result exceeds the result size limit') + } + } + } + return result => { + if (result && typeof result.then === 'function') { + throw new Error('Asynchronous queries are not supported') + } + if (browse) { + result = linkReplacer(result, path + '/') + } + let body + if (taggedResponse) { + parseAllObjects(result) + body = stringifyTagged(result) + } + else { + body = stringify(result) + } + if (body !== undefined && typeof body !== 'string') { + throw new Error('Invalid serialized query result') + } + checkSize(body) + return body + } +} + +function getDataSpace(path, root) { + if (!path) { + throw new Error('missing pointer parameter') + } + if (path.endsWith('/')) { + path = path.slice(0, -1) + } + let data = root + for (const part of path.split('/')) { + if (part && data) { + data = data[part] + } + } + if (data === undefined) { + data = JSONTag.parse('' + JSON.stringify({ + message: 'Path Not found', code: 404, path + })) + } + return [data, path] +} + +function parseAllObjects(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object' || seen.has(value)) { + return + } + seen.add(value) + for (const key of Object.keys(value)) { + parseAllObjects(value[key], seen) + } +} + +function linkReplacer(data, baseURL) { + if (Array.isArray(data)) { + return data.map((value, index) => { + return linkReplacer(value, baseURL + index + '/') + }) + } + if (!data || typeof data !== 'object' || JSONTag.getType(data) === 'link') { + return data + } + // Read through the authorized view, never through a parser source symbol. + const copy = {} + JSONTag.setAttributes(copy, JSONTag.getAttributes(data)) + for (const key of Object.keys(data)) { + const value = data[key] + if (Array.isArray(value)) { + copy[key] = new JSONTag.Link(baseURL + key + '/') + } + else if (value && typeof value === 'object' && + JSONTag.getType(value) === 'object') { + const id = JSONTag.getAttribute(value, 'id') + copy[key] = new JSONTag.Link(id || baseURL + key + '/') + } + else { + copy[key] = value + } + } + return copy +} diff --git a/src/query-isolate.mjs b/src/query-isolate.mjs new file mode 100644 index 0000000..ee591ab --- /dev/null +++ b/src/query-isolate.mjs @@ -0,0 +1,75 @@ +import ivm from 'isolated-vm' +import { buildSync } from 'esbuild' +import { fileURLToPath } from 'node:url' +import { performance } from 'node:perf_hooks' +import { QueryView } from './query-view.mjs' + +// Compile trusted helpers once per worker, outside query execution. esbuild +// resolves their imports here; the guest receives no module-loading capability. +const bundle = buildSync({ + entryPoints: [fileURLToPath(new URL('./query-guest.mjs', import.meta.url))], + bundle: true, + platform: 'neutral', + mainFields: ['main'], + format: 'iife', + globalName: 'guest', + write: false +}).outputFiles[0].text + +export function isolatedQuery(dataset, request, { + timeout = 1000, memoryLimit = 64, maxResultBytes = 10 * 1024 * 1024 +} = {}) { + const isolate = new ivm.Isolate({ memoryLimit }) + const start = performance.now() + const remaining = () => { + const duration = Math.ceil(timeout - (performance.now() - start)) + if (duration <= 0) { + throw new Error('Query execution timed out') + } + return duration + } + let render + let result + try { + const context = isolate.createContextSync() + const view = new QueryView(dataset, maxResultBytes) + const initial = { + root: view.describe(dataset.root, [0]), + schema: view.describe(dataset.parser.meta.schema, ['schema']), + request, + maxBytes: maxResultBytes + } + const read = new ivm.Callback((...args) => { + remaining() + return view.read(...args) + }) + const validURL = new ivm.Callback((url, base) => { + try { + return Boolean(new URL(url, base)) + } + catch { + return false + } + }) + const input = new ivm.ExternalCopy(initial).copyInto({release: true}) + render = context.evalClosureSync( + bundle + '\nreturn guest.prepare($0, $1, $2)', + [read, validURL, input], + { timeout: remaining(), result: { reference: true } } + ) + result = context.evalSync(request.body || 'data', { + timeout: remaining(), reference: true + }) + const body = render.applySync(undefined, [result.derefInto()], { + timeout: remaining(), result: { copy: true } + }) + return { jsontag: request.jsontag, body } + } + finally { + result?.release() + render?.release() + if (!isolate.isDisposed) { + isolate.dispose() + } + } +} diff --git a/src/query-view.mjs b/src/query-view.mjs new file mode 100644 index 0000000..bf060e9 --- /dev/null +++ b/src/query-view.mjs @@ -0,0 +1,128 @@ +import JSONTag from '@muze-nl/jsontag' +import { getIndex } from '@muze-nl/od-jsontag/src/symbols.mjs' + +// Only JSONTag's public metadata crosses the query boundary. Parser symbols +// contain source/cache state and must never be discoverable by a query. +const metadataSymbols = [ + Symbol['JSONTag:Type'], + Symbol['JSONTag:Attributes'], + Symbol['JSONTag:Null'] +] + +function property(key) { + if (typeof key === 'string') { + return key + } + if (Number.isInteger(key) && metadataSymbols[key]) { + return metadataSymbols[key] + } + throw new Error('Invalid query property') +} + +export class QueryView { + constructor(dataset, maxBytes) { + this.dataset = dataset + this.maxBytes = maxBytes + } + + resolve(reference) { + const [record, ...keys] = reference + let value + if (record === 'schema') { + value = this.dataset.parser.meta.schema + } + else { + value = this.dataset.parser.getLineProxy(record) + } + for (const key of keys) { + value = value[property(key)] + } + return value + } + + describe(value, reference) { + if (typeof value === 'function') { + throw new Error('Functions are not query data') + } + if (typeof value === 'symbol') { + return { value: undefined } + } + if (typeof value === 'string' && + Buffer.byteLength(value) > this.maxBytes) { + throw new Error('Query value exceeds the result size limit') + } + if (!value || typeof value !== 'object') { + return { value } + } + const type = JSONTag.getType(value) + if (type !== 'object' && type !== 'array' || JSONTag.isNull(value)) { + const tagged = JSONTag.stringify(value) + if (Buffer.byteLength(tagged) > this.maxBytes) { + throw new Error('Query value exceeds the result size limit') + } + return { tagged } + } + const record = value[getIndex] + if (Number.isSafeInteger(record)) { + reference = [record] + } + const result = { reference, array: Array.isArray(value) } + if (result.array) { + result.length = value.length + } + return result + } + + read(operation, reference, key) { + if (operation === 'id') { + const index = this.dataset.parser.meta.index?.id + if (!index?.has(key)) { + return { value: undefined } + } + const record = index.get(key) + return this.describe(this.dataset.parser.getLineProxy(record), + [record]) + } + if (operation === 'hasId') { + return this.dataset.parser.meta.index?.id?.has(key) || false + } + const value = this.resolve(reference) + if (operation === 'keys') { + if (Array.isArray(value) && value.length > this.maxBytes / 16) { + throw new Error('Query keys exceed the result size limit') + } + const keys = [] + let bytes = 0 + for (const name of Reflect.ownKeys(value)) { + let key = name + if (typeof name === 'symbol') { + key = metadataSymbols.indexOf(name) + if (key === -1) { + continue + } + } + bytes += String(key).length * 2 + 16 + if (bytes > this.maxBytes) { + throw new Error('Query keys exceed the result size limit') + } + keys.push(key) + } + return keys + } + const name = property(key) + // The parser's descriptor trap applies the same grants as reading. + // Never forward a host prototype or function into the isolate. + if (operation === 'has') { + return Reflect.has(value, name) + } + const descriptor = Reflect.getOwnPropertyDescriptor(value, name) + if (!descriptor) { + return { present: false } + } + return { + present: true, + enumerable: descriptor.enumerable, + entry: this.describe(value[name], [...reference, key]) + } + } +} diff --git a/src/query-worker-module.mjs b/src/query-worker-module.mjs index 84ce8a9..342397e 100644 --- a/src/query-worker-module.mjs +++ b/src/query-worker-module.mjs @@ -1,274 +1,75 @@ -import {VM} from 'vm2' import { memoryUsage } from 'node:process' import JSONTag from '@muze-nl/jsontag' -import {source} from '@muze-nl/od-jsontag/src/symbols.mjs' -import { FileDataset, FileParser } from './file-data.mjs' -import {_,from,not,anyOf,allOf,asc,desc,sum,count,avg,max,min,many,one,first,distinct} from '@muze-nl/jaqt' -import process from 'node:process' - -// vm2 protects array species before host calls. Give it the already-safe -// constructor descriptor on raw query arrays before the read-only proxy exists, -// so methods like map/filter need no temporary mutation through that proxy. -class QueryParser extends FileParser { - getArrayProxy(array, parent) { - Object.defineProperty(array, 'constructor', { - value: undefined, - writable: false, - enumerable: false, - configurable: false - }) - return super.getArrayProxy(array, parent) - } -} - -let dataspace -let metaProxy = { - index: { - } -} +import { FileDataset } from './file-data.mjs' +import { isolatedQuery } from './query-isolate.mjs' let dataset -let parser +let limits -const metaIdProxy = { - get: (id) => { - let index = parser.meta.index.id.get(id) - if (index || index===0) { - return parser.getLineProxy(index) - } - }, - has: (id) => { - return parser.meta.index.id.has(id) - } +function errorResponse(request, code, message) { + // Error text is untrusted too; never return an arbitrarily large throw. + const textLimit = Math.min(512, (limits?.maxResultBytes || 1024) / 8) + const error = { code, message: String(message).slice(0, textLimit) } + let body = JSON.stringify(error) + if (request.jsontag) { + JSONTag.setAttribute(error, 'class', 'Error') + body = JSONTag.stringify(error) + } + return { code, jsontag: request.jsontag, body } } -const tasks = { - init: async (task) => { - if (dataset) { - dataset.close() - } - dataset = new FileDataset(task.req.meta, true, QueryParser) - parser = dataset.parser +export default { + async init(task) { + dataset?.close() + dataset = new FileDataset(task.req.meta) if (task.req.access) { - task.req.access = await import(task.req.access) - task.req.access = task.req.access.default - parser.meta.access = task.req.access + const access = await import(task.req.access) + dataset.parser.meta.access = access.default } - if (task.req.meta.index) { - parser.meta.index = task.req.meta.index - } - if (task.req.meta.schema) { - parser.meta.schema = task.req.meta.schema - } - dataspace = dataset.open(task.req.sources) - metaProxy.index.id = metaIdProxy - metaProxy.schema = parser.meta.schema - //@TODO: add meta.index.references? and baseURL + limits = task.req.limits + dataset.open(task.req.sources) return true }, - update: async (task) => { + + async update(task) { if (task.req.meta.index) { - parser.meta.index = task.req.meta.index + dataset.parser.meta.index = task.req.meta.index } - dataspace = dataset.append(task.req.source) + dataset.append(task.req.source) return true }, - query: async (task) => { + + async query(task) { let response try { - response = runQuery( - task.req.path, task.req, task.req.body, task.timeout - ) + if (!dataset.parser.readFailure) { + response = isolatedQuery(dataset, task.req, { + ...limits, timeout: task.timeout + }) + } } catch (error) { - if (!parser.readFailure) { - throw error + let message = 'Query execution failed' + if (typeof error === 'string') { + message = error } + else if (typeof error?.message === 'string') { + message = error.message + } + response = errorResponse(task.req, 422, message) } - // Query code can catch errors or throw its own properties. Only the - // parser's host-owned failure state identifies a storage problem. - if (parser.readFailure) { - const error = {code: 500, message: 'Unable to read committed data'} + // A query may catch read errors or forge error properties. Storage + // failure is determined only by the host-owned parser state. + if (dataset.parser.readFailure) { return { - code: 500, - storageFailure: true, - jsontag: task.req.jsontag, - body: task.req.jsontag - ? JSONTag.stringify(error) - : JSON.stringify(error) + ...errorResponse(task.req, 500, 'Unable to read committed data'), + storageFailure: true } } return response }, - memoryUsage: async () => { - let result = memoryUsage() - console.log('memory',result) - return result - } -} - -export default tasks - -export function runQuery(pointer, request, query, timeout=1000) { - if (!pointer) { - throw new Error('missing pointer parameter') -} - if (!request) { - throw new Error('missing request parameter') -} - let response = { - jsontag: request.jsontag - } - let [result,path] = getDataSpace(pointer, dataspace) - - if (query) { - // @todo add text search: https://github.com/nextapps-de/flexsearch - // @todo replace VM with V8 isolate - const vm = new VM({ - timeout: timeout, - allowAsync: false, - sandbox: { - root: dataspace, - data: result, - meta: metaProxy, - _, - from, - not, - anyOf, - allOf, - asc, - desc, - sum, - count, - avg, - max, - min, - many, - one, - first, - distinct, -// console: connectConsole(res), - JSONTag, - request - }, - wasm: false - }) - try { - result = vm.run(query) - let used = Math.round(process.memoryUsage().heapUsed / 1024 / 1024); - console.log(`(${used} MB)`); - } - catch(err) { - console.log(err) - response.code = 422; - if (request.jsontag) { - response.body = '{"message":'+JSON.stringify(''+err)+',"code":422}' - } - else { - response.body = JSON.stringify({message:err, code: 422}) - } - } - } - else { - result = linkReplacer(result, path+'/') - } - if (!response.code) { - if (response.jsontag) { - try { - // JSONTag.stringify doesn't handle tagName/attributes well - // with od-jsontag until result entries have been parsed. - // only after parsing will these be available - // Force parsing before formatting the query response. - parseAllObjects(result) - response.body = JSONTag.stringify(result) - } - catch(err) { - console.log(err) - response.code = 500 - response.body = '{"message":'+JSON.stringify(''+err)+',"code":500}' - } - } - else { - //@FIXME: replace recursive links - response.body = JSON.stringify(result) - } - } - return response -} - -let seen = new WeakMap() -function parseAllObjects(o, reset=true) { - if (reset) { - seen = new WeakMap() - } - if (seen.has(o)) { - return - } - if (o && typeof o == 'object') { - seen.set(o, true) - if (Array.isArray(o)) { - for (let v of o) { - if (v && typeof v == 'object') { - parseAllObjects(v, false) - } - } - } - else if (o && typeof o == 'object') { - for (let k of Object.keys(o)) { - if (o[k] && typeof o[k]=='object') { - parseAllObjects(o[k], false) - } - } - } - } -} -export function getDataSpace(path, dataspace) { - if (path.substring(path.length-1)=='/') { - path = path.substring(0, path.length-1) - } - const pointer = path.split('/') - let result = dataspace - for (const part of pointer) { - if (part && result) { - result = result[part] - } - } - if (result===undefined) { - result = JSONTag.parse(`{"message":"Path Not found", "code":404, "path":"${path}"}`) + async memoryUsage() { + return memoryUsage() } - return [result,path] } - -export function linkReplacer(data, baseURL) { - let type = JSONTag.getType(data) - if (Array.isArray(data)) { - data = data.map((entry,index) => { - return linkReplacer(data[index], baseURL+index+'/') - }) - } - else if (type === 'link') { - // do nothing - } - else if (data && typeof data === 'object') { - if (data[source]) { - data = data[source] - } - data = JSONTag.clone(data?.[source] ?? data) - Object.keys(data).forEach(key => { - if (Array.isArray(data[key])) { - data[key] = new JSONTag.Link(baseURL+key+'/') - } - else if (data[key] && typeof data[key] === 'object') { - if (JSONTag.getType(data[key])!=='link') { - let id=JSONTag.getAttribute(data[key], 'id') - if (!id) { - id = baseURL+key+'/' - } - data[key] = new JSONTag.Link(id) - } - } - }) - } - return data -} \ No newline at end of file diff --git a/src/store-runtime.mjs b/src/store-runtime.mjs index 74605b0..724f350 100644 --- a/src/store-runtime.mjs +++ b/src/store-runtime.mjs @@ -50,6 +50,14 @@ function normalizeWorkerTimeout(name, value, defaultValue) { return timeout } +function queryLimit(name, value, fallback, minimum = 1) { + const limit = value ?? fallback + if (!Number.isSafeInteger(limit) || limit < minimum) { + throw new Error(`${name} must be an integer of at least ${minimum}`) + } + return limit +} + function createRuntimeConfiguration(options) { const datafile = options.datafile || './data.od-jsontag' const integrityFile = @@ -79,8 +87,15 @@ function createRuntimeConfiguration(options) { options.commandsFile || rootDirectory + '/src/commands.mjs', indexFile: options.indexFile || rootDirectory + '/src/index.mjs', access: options.access || null, - timeout: options.timeout || 1000, - slowTimeout: options.slowTimeout || 10000, + timeout: queryLimit('timeout', options.timeout, 1000), + slowTimeout: queryLimit('slowTimeout', options.slowTimeout, 10000), + queryMemoryLimit: queryLimit( + 'queryMemoryLimit', options.queryMemoryLimit, 64, 8 + ), + maxQueryResultBytes: queryLimit( + 'maxQueryResultBytes', options.maxQueryResultBytes, + 10 * 1024 * 1024, 256 + ), commandTimeout: normalizeWorkerTimeout( 'commandTimeout', options.commandTimeout, @@ -264,7 +279,11 @@ export class StoreRuntime { req: { sources: this.sources, meta: this.meta, - access: this.configuration.access + access: this.configuration.access, + limits: { + memoryLimit: this.configuration.queryMemoryLimit, + maxResultBytes: this.configuration.maxQueryResultBytes + } }, timeout } diff --git a/src/workerPool.mjs b/src/workerPool.mjs index 8ccb692..0608395 100644 --- a/src/workerPool.mjs +++ b/src/workerPool.mjs @@ -2,6 +2,7 @@ import { AsyncResource } from 'node:async_hooks' import { EventEmitter } from 'node:events' import path from 'node:path' import { Worker } from 'node:worker_threads' +import { WorkerTimeoutError } from './execute-worker.mjs' class QueryTask extends AsyncResource { constructor(task, resolve, reject) { @@ -42,9 +43,12 @@ export default class WorkerPool extends EventEmitter { addNewWorker() { const worker = new Worker(this.workerFile) - const state = { pending: [this.initTask], active: null, query: null } + const state = { + pending: [this.initTask], active: null, query: null, timer: null + } this.workers.set(worker, state) worker.on('message', result => { + clearTimeout(state.timer) const query = state.query state.active = null state.query = null @@ -76,6 +80,15 @@ export default class WorkerPool extends EventEmitter { task = query.task } state.active = task + if (task.name === 'query' && task.timeout > 0) { + // Allow the isolate to return its timeout response first. The + // outer deadline also covers host callbacks and message handling. + const deadline = task.timeout + 250 + state.timer = setTimeout(() => { + this.workerFailed(worker, + new WorkerTimeoutError('query worker', deadline)) + }, deadline) + } worker.postMessage(task) } @@ -84,6 +97,7 @@ export default class WorkerPool extends EventEmitter { if (!state) { return } + clearTimeout(state.timer) this.workers.delete(worker) const termination = worker.terminate() this.terminating.add(termination) @@ -148,6 +162,7 @@ export default class WorkerPool extends EventEmitter { query.done(error) } await Promise.all([...this.workers].map(async ([worker, state]) => { + clearTimeout(state.timer) await worker.terminate() if (state.query) { state.query.done(error) diff --git a/test/query-isolate.mjs b/test/query-isolate.mjs new file mode 100644 index 0000000..f65bb23 --- /dev/null +++ b/test/query-isolate.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' +import test from 'node:test' +import JSONTag from '@muze-nl/jsontag' +import StoreRuntime from '../src/store-runtime.mjs' +import WorkerPool from '../src/workerPool.mjs' +import { makeServerFixture } from './durability-helpers.mjs' + +async function open(t, options = {}) { + let runtime + t.after(() => runtime?.close()) + const fixture = await makeServerFixture(t, { + initialData: '{"persons":[' + + '{"name":"Padmé 𠮷", "gender":"private",' + + '"age":32,"existenceOnly":"private","url":"https://example.test/a"},' + + '{"name":"Bob","gender":"private","age":20}' + + '],"hidden":{"secret":"do not expose"}}' + }) + const access = path.join(fixture.dir, 'access.mjs') + await fs.writeFile(access, ` + import { basename } from 'node:path' + export default function access(object, property, method) { + if (property === 'existenceOnly') { + return method === 'has' + } + return property !== basename('/hidden/gender') && + property !== 'hidden' && + !(property === 'age' && method === 'has') + } + `) + const schemaFile = path.join(fixture.dir, 'schema.jsontag') + await fs.writeFile(schemaFile, '{"version":42,"nested":{"value":"ok"}}') + runtime = await StoreRuntime.open({ + ...fixture, access, schemaFile, maxWorkers: 1, ...options + }) + async function response(body, extra = {}) { + return runtime.runQuery({ path: '/', body, jsontag: false, ...extra }) + } + async function query(body, extra) { + const result = await response(body, extra) + assert.equal(result.code, undefined, result.body) + return JSON.parse(result.body) + } + return { runtime, fixture, query, response } +} + +test('isolated queries retain helpers, ID identity, tags and path browsing', + async t => { + const { query, response } = await open(t) + assert.deepEqual(await query(`from(data.persons) + .where({age: value => value > 25}).select({name: _})`), + [{ name: 'Padmé 𠮷' }]) + assert.equal(await query('data.persons[0] === meta.index.id.get("alice")'), + true) + assert.equal(await query('meta.index.id.has("absent")'), false) + assert.equal(await query('meta.index.id.has({})'), false) + assert.deepEqual(await query('[meta.schema.version,meta.schema.nested.value]'), + [42, 'ok']) + assert.deepEqual(await query('data.persons.map(x => x.name)'), + ['Padmé 𠮷', 'Bob']) + assert.equal(await query('String(data.persons[0].url)'), + 'https://example.test/a') + const tagged = await response('data.persons[0]', {jsontag: true}) + assert.equal(tagged.code, undefined, tagged.body) + const person = JSONTag.parse(tagged.body) + assert.equal(JSONTag.getAttribute(person, 'id'), 'alice') + assert.equal(person.name, 'Padmé 𠮷') + assert.equal(person.gender, undefined) + const format = await response( + 'request.jsontag=false;request.body="";data.persons[0]', + {jsontag: true} + ) + assert.equal(format.jsontag, true) + assert.match(format.body, /^/) + const json = await query('request.jsontag=true;data.persons[0]') + assert.equal(json.name, 'Padmé 𠮷') + const browse = await response(undefined, { + path: '/persons/', jsontag: true + }) + assert.equal(browse.code, undefined, browse.body) + assert.equal(JSONTag.parse(browse.body)[0].name, 'Padmé 𠮷') + const root = await response(undefined, {jsontag: true}) + assert.equal(JSONTag.getType(JSONTag.parse(root.body).persons), 'link') + }) + +test('host grants protect reads, reflection and parser internals', async t => { + const { query, response } = await open(t) + assert.deepEqual(await query(`(()=>{ + const p=data.persons[0]; + return { + keys: Object.keys(p), + hidden: 'gender' in p, + descriptor: Object.getOwnPropertyDescriptor(p,'gender'), + values: {...p}, + symbols: Object.getOwnPropertySymbols(p).map(x=>x.description), + rootKeys: Reflect.ownKeys(root).filter(x=>typeof x==='string') + }; + })()`), { + keys: ['name', 'age', 'url'], hidden: false, + values: {name: 'Padmé 𠮷', age: 32, url: 'https://example.test/a'}, + symbols: ['@attributes'], rootKeys: ['persons'] + }) + for (const body of [ + 'data.persons[0].name="changed"', + 'delete data.persons[0].name', + 'Object.defineProperty(data.persons[0],"name",{value:"changed"})', + 'Object.setPrototypeOf(data.persons[0],{})', + 'data.persons.push({name:"changed"})', + 'JSONTag.setAttribute(data.persons[0],"id","changed")' + ]) { + const result = await response(body) + assert.equal(result.code, 422, body) + } + assert.equal(await query('data.persons[0].name'), 'Padmé 𠮷') + assert.equal(await query('"age" in data.persons[0]'), false) + assert.equal(await query('data.persons[0].age'), 32) + assert.deepEqual(await query(`[ + 'existenceOnly' in data.persons[0], data.persons[0].existenceOnly + ]`), [true, null]) +}) + +test('queries have no Node or import capabilities, including generated imports', + async t => { + const { query, response } = await open(t) + assert.deepEqual(await query(`[ + typeof process,typeof require,typeof fetch,typeof Buffer, + typeof WebAssembly,typeof SharedArrayBuffer, + Function('return typeof process')() + ]`), Array(7).fill('undefined')) + const staticImport = await response('import fs from "node:fs"') + assert.equal(staticImport.code, 422) + assert.equal(await query(`globalThis.importError='pending'; + import('node:fs').catch(e=>importError=e.message); + ({toJSON(){return importError}})`), 'Not supported') + assert.equal(await query(`globalThis.importError='pending'; + Function('return import("node:fs")')() + .catch(e=>importError=e.message); + ({toJSON(){return importError}})`), 'Not supported') + assert.equal((await response('Promise.resolve(42)')).code, 422) + for (const body of ['throw null', 'throw "message"', 'throw {}']) { + assert.equal((await response(body)).code, 422) + } + }) + +test('fresh isolates prevent globals and prototypes leaking between queries', + async t => { + const { query } = await open(t) + assert.equal(await query(`globalThis.leak=123; + Array.prototype.leak=456; 1`), 1) + assert.deepEqual(await query('[typeof leak,typeof [].leak]'), + ['undefined', 'undefined']) + // A hostile inherited toJSON must never see/mutate the private record + // reference used by the bridge's identity cache. + assert.equal(await query(`let captures=0; + Array.prototype.toJSON=function(){captures++;return []}; + data.persons[0].name; captures`), 0) + assert.equal(await query(`Array.prototype.includes=()=>true; + Object.prototype.gender='fake'; data.persons[0].gender`), 'fake') + assert.equal(await query('"gender" in data.persons[0]'), false) + assert.equal(await query(`Object.prototype.reference=[0,'hidden']; + Object.prototype.tagged='{"secret":"fake"}'; + data.persons[0].name`), 'Padmé 𠮷') + }) + +test('execution and serialization timeouts leave later queries usable', + async t => { + const { query, response } = await open(t, {timeout: 80}) + for (const body of [ + 'while(true){}', + '({toJSON(){while(true){}}})', + '({get value(){while(true){}}})' + ]) { + const start = performance.now() + const result = await response(body) + assert.equal(result.code, 422, result.body) + assert.match(result.body, /timed out/) + assert.ok(performance.now() - start < 2000) + assert.equal(await query('1+2'), 3) + } + }) + +test('UTF-8 result limits cover JSON, JSONTag and hostile serialization', + async t => { + const { query, response } = await open(t, {maxQueryResultBytes: 1024}) + assert.equal((await query('"𠮷".repeat(200)')).length, 400) + for (const body of [ + '"x".repeat(1100)', + '"𠮷".repeat(300)', + 'String.prototype.charCodeAt=()=>0;"𠮷".repeat(300)', + 'JSON.stringify=()=>"";"x".repeat(1100)' + ]) { + for (const jsontag of [false, true]) { + const result = await response(body, {jsontag}) + assert.equal(result.code, 422, result.body) + assert.match(result.body, /size limit/) + } + } + const error = await response('throw new Error("x".repeat(10000))') + assert.ok(Buffer.byteLength(error.body) <= 1024) + }) + +test('isolate memory exhaustion does not poison the next query', async t => { + const { query, response } = await open(t, { + queryMemoryLimit: 8, timeout: 2000 + }) + const result = await response(`const values=[]; + while(true){values.push(new Array(100000).fill(42))}`) + assert.equal(result.code, 422, result.body) + assert.match(result.body, /memory|disposed|allocation/i) + assert.equal(await query('42'), 42) +}) + +test('query limit configuration rejects disabled or invalid bounds', async t => { + const fixture = await makeServerFixture(t) + for (const [key, value] of [ + ['timeout', 0], ['slowTimeout', Infinity], ['queryMemoryLimit', 1], + ['queryMemoryLimit', 1.5], ['maxQueryResultBytes', 0] + ]) { + await assert.rejects(StoreRuntime.open({...fixture, [key]: value}), + new RegExp(key)) + } +}) + +test('outer query deadline replaces a stuck worker at the committed head', + async t => { + const fixture = await makeServerFixture(t) + const worker = path.join(fixture.dir, 'stuck-worker.mjs') + await fs.writeFile(worker, ` + import {parentPort} from 'node:worker_threads'; + let count=0; + parentPort.on('message',task=>{ + if(task.name==='init'){count=task.req.sources.length} + if(task.name==='update'){count++} + if(task.req?.stuck){while(true){}} + parentPort.postMessage(count); + }); + `) + const pool = new WorkerPool(1, worker, { + name: 'init', req: {sources: [{}], meta: {}} + }) + t.after(() => pool.close()) + assert.equal(await pool.run('query', {}, {timeout: 50}), 1) + const failed = assert.rejects( + pool.run('query', {stuck: true}, {timeout: 50}), /timed out/ + ) + pool.update({name: 'update', req: {source: {}, meta: {}}}) + await failed + assert.equal(await pool.run('query', {}, {timeout: 50}), 2) + })