Native N-API backend for libpg_query (PG 18) with jemalloc#1
Native N-API backend for libpg_query (PG 18) with jemalloc#1Ben Asher (benasher44) wants to merge 21 commits into
Conversation
Native Node addon wrapping libpg_query via N-API, as a memory-efficient alternative to the WASM build. Ships prebuilt platform binaries via esbuild-style optional dependencies (@ashbyhq/libpg-query-native-<platform>), so consumers never need node-gyp. API is a drop-in replacement for @libpg-query/parser: parse, fingerprint, normalize, scan, parsePlPgSQL (sync + async variants). For optimal memory behavior with large queries, jemalloc should be preloaded via LD_PRELOAD (Linux) or DYLD_INSERT_LIBRARIES (macOS). Includes memory and throughput benchmarks comparing allocators. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests system malloc, jemalloc, mimalloc, and tcmalloc via LD_PRELOAD. Supports multiple parse/free cycles (--cycles N) to reveal ratchet behavior, and auto-detects which allocator is active from environment variables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…kend Builds on the native N-API backend with the release/distribution plumbing: - platforms.json as the single source of truth for the 6 target platforms (os/cpu/libc + CI runner/container). package-platforms.mjs, the runtime loader in index.ts, and sync-optional-deps.mjs all read from it, so adding a platform is a one-line change. - Mark glibc vs musl Linux packages mutually exclusive via `libc`, so npm 10+ and Yarn Berry install only the binary matching the host. - native-build.yml: reusable workflow that derives its matrix from platforms.json, builds + tests on every platform, uploads prebuilds. - native-ci.yml: runs the build, then a package smoke test that installs the generated platform package in an isolated dir and exercises every public API. - native-release.yml: manual dispatch (with dry-run) that builds all platforms and publishes the main + per-platform packages to npm. - README: replace prior unverified memory figures with reproducible, methodology-labeled system-malloc-vs-jemalloc measurements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three issues surfaced on the first CI run:
- WASM CI (ci.yml) broke because `native` was added to pnpm-workspace.yaml
but the committed pnpm-lock.yaml has no native importer, so the
frozen-lockfile install failed. The native package is standalone (npm +
make, with unpublished platform optionalDependencies) and doesn't belong
in the pnpm WASM monorepo. Remove it from the workspace.
- glibc Linux jobs ran the Alpine `apk add` step and failed: the matrix
derived musl from `!!v.libc`, but glibc platforms now carry
`libc: ["glibc"]`. Detect musl via `(v.libc||[]).includes('musl')`.
- musl builds wrote to prebuilds/linux-x64 (no suffix) because the
Makefile's `ldd --version` parsing didn't detect musl in node:20-alpine,
while the JS loader correctly looked in linux-x64-musl. Detect musl via
the presence of /lib/ld-musl-*, make PLATFORM_KEY overridable, and pin it
from the CI matrix (make build PLATFORM_KEY=<platform>).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The package-test job only runs after builds pass, so it wasn't exercised on earlier runs. Reworked it to be robust and validated the full flow locally: - Install via `npm pack` tarballs (main + platform) instead of installing the local source dir. Installing the source pulls the unpublished platform optionalDependencies from the registry (404s); tarballs + `--omit=optional` avoid that and install the matching platform binary explicitly. - `npm pack --pack-destination` does not create its target dir, so `mkdir -p` it first (this was the concrete failure reproduced locally). - Drop `--install-strategy=nested`; default hoisting resolves correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- actions/checkout v4 -> v5 (Node 24 runtime) - actions/setup-node v4 -> v6 (latest, v6.2.0) - actions/upload-artifact v4 -> v5 - actions/download-artifact v4 -> v5 (kept in lockstep with upload; the artifact actions require matching majors for up/download) setup-node v5+ auto-enables dependency caching, which errors when no lockfile is present — and native/ doesn't commit package-lock.json. Set package-manager-cache: false on each setup-node to preserve v4 behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the native-only allocator table with a 3-way comparison (WASM, native system malloc, native jemalloc) using isolated per-process measurements: WASM peaks at 1359 MB and never releases (+1202 MB retained), native+jemalloc peaks at 498 MB and stabilizes (+377 MB), and native is ~11% faster on throughput. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against each action's releases/latest: - actions/checkout v5 -> v7 - actions/upload-artifact v5 -> v7 - actions/download-artifact v5 -> v8 (upload v7 + download v8 is the current compatible pair per the GitHub Actions changelog; default archive:true keeps existing zip behavior, so no flow change) - actions/setup-node stays v6 (latest is v6.4.0) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JavaScript actions (checkout/setup-node/upload-artifact) can't run inside an
Alpine container on arm64 runners — GitHub injects a glibc Node that fails
with "JavaScript Actions in Alpine containers are only supported on x64 Linux
runners." This broke the linux-arm64-musl build.
Drop the job-level `container: node:20-alpine` for both the build and
package-test jobs. The host actions now run on the glibc runner, and the musl
build/test/smoke runs via `docker run "${{ matrix.container }}"` — which pulls
the arch-matching Alpine image on both x64 and arm64 runners.
Validated the full arm64-musl flow locally (Apple Silicon → arm64 Alpine):
libpg_query + addon compile, 46 tests pass, output is a musl aarch64 ELF.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Bump all builds to Node 24: setup-node node-version 24, and the musl Alpine images to node:24-alpine (via platforms.json container). - Drop the darwin-x64 target. Apple Silicon (darwin-arm64) is the only macOS variant built; the sole remaining macOS runner (macos-14) is used only for that explicit variant. Everything else runs on Ubuntu. - Re-sync optionalDependencies and update the platform table accordingly. Targets are now: darwin-arm64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the WASM build/test workflows — ci.yml, build-wasm.yml, and build-wasm-no-docker.yaml — so no WASM builds run in CI. The WASM packages (full/, parser/, versions/, types/, enums/, protos/) and the rest of the monorepo are kept intact; they can still be built manually. Only the native backend's workflows (native-ci.yml, native-build.yml, native-release.yml) run in CI now. This reverts the full WASM removal from the previous commit, keeping WASM support while dropping just its automated builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3af7000 to
05f1fce
Compare
New native-benchmark.yml runs benchmark/ci-bench.mjs on a fixed runner (ubuntu-24.04, under jemalloc) for every PR and push to main, tracking four smaller-is-better metrics against a gh-pages baseline: - large-query parse time (ms) - large-query peak RSS (MB) - large-query retained RSS (MB) - small-query latency (us/parse) Per-metric diffs are posted to the run summary every run (summary-always); a regression beyond 2x the baseline comments on the PR and fails the check (fail-on-alert, alert-threshold: 200%) — conservative to tolerate shared-runner noise. Baseline is written to gh-pages only on push to main; PRs compare only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a prominent note at the top of the root README stating that this fork builds, tests, and publishes only @ashbyhq/libpg-query-native (a native N-API addon for Node.js, PG 18) — no WASM/browser/Deno/Worker builds and no WASM build in CI. The upstream WASM packages remain in-tree for reference but are not built or published here; point readers to native/README.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pin typescript to exact 5.9.3 in native devDependencies (was ^5.3.3) so the compiler version is deterministic across local and CI builds. - Replace every `npx tsc` in the workflows with `npm run build:ts`, which runs the pinned local binary from node_modules/.bin (no on-the-fly npx fetch). Audited the repo: these were the only npx usages; none remain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isMusl() shelled out to `ldd --version` and treated any failure as musl. Minimal images (distroless/scratch) ship no `ldd`, so the call throws and a glibc host is misdetected as musl — selecting a platform package/prebuild that doesn't exist and failing at load with "Native addon not found for linux-x64-musl". Use Node's process.report.header.glibcVersionRuntime (set on glibc, absent on musl), with a /lib/ld-musl-* fallback and a glibc default. Verified: distroless debian13 now resolves linux-x64; node:alpine still resolves musl. Bump to 0.1.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4ca5644 to
d71ddda
Compare
native: detect libc via process.report, not `ldd`
The release workflow already passes `npm publish --access public`, but the main package.json lacked publishConfig, so a manual `npm publish` would default to restricted for a scoped (@ashbyhq) package. Add publishConfig.access=public to match the generated platform packages and make scoped public publishing the default regardless of how it's invoked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughRemoves the legacy WASM workflow files and adds a native Node-API addon stack with build, packaging, testing, benchmarking, documentation, and release automation. ChangesNative N-API addon migration
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
The release workflow published everything to the 'latest' dist-tag, so a prerelease (e.g. 0.2.0-beta.0) would have hijacked 'latest'. Add dist-tag support: - New optional `tag` dispatch input. - When blank, auto-derive: 'latest' for X.Y.Z, or the leading alpha prerelease id for X.Y.Z-<id>.N (0.2.0-beta.1 -> beta, -rc -> rc, numeric prerelease -> beta). - Publish main + platform packages with `npm publish --access public --tag $NPM_TAG`. - Guard: refuse to publish a prerelease onto 'latest'. - Dry-run summary prints the resolved dist-tag. Derivation uses pure shell case/parameter-expansion (no grep/sed) for portability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 13
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/native-benchmark.yml:
- Around line 19-21: The workflow-level permissions block is too broad; move the
`contents: write` and `pull-requests: write` permissions into the `benchmark`
job only. Update the `.github/workflows/native-benchmark.yml` job definition so
the job that runs the benchmark, pushes the baseline, and comments on PRs owns
these permissions, while the rest of the workflow remains unprivileged.
In @.github/workflows/native-build.yml:
- Around line 1-9: Add a top-level least-privilege permissions block to the
Native Build reusable workflow so it runs with contents: read instead of default
token scopes. Also update the action references in this workflow to comply with
the repo’s pin-to-hash policy by replacing the unpinned version tags used by the
workflow steps with pinned references; check the workflow actions around the
reusable native build jobs and keep the change scoped to the existing workflow
definition.
- Around line 53-72: The native build workflow is injecting untrusted matrix
values directly into shell in the glibc and musl steps. Update the `Build and
test` jobs to move `matrix.platform` and `matrix.container` into step `env`
variables, then reference those shell variables inside `run` and `docker run`
instead of expanding `${{ matrix.* }}` in the command strings. Keep the fix
localized around the `Build and test (glibc / macOS)` and `Build and test (musl,
in Alpine container)` steps in `native-build.yml`.
In @.github/workflows/native-ci.yml:
- Around line 23-42: The package-test-matrix job duplicates the matrix-building
logic already defined in the native build workflow. Move the matrix generation
into the reusable workflow as a workflow_call output from the existing matrix
job, then update package-test-matrix to consume that output instead of
re-implementing the Node-based generation; use the matrix and
package-test-matrix job names to locate the change.
- Around line 74-106: The native CI workflow still interpolates untrusted matrix
values directly into shell commands, creating the same template-injection risk
as the other workflow. Update the Pack/install/smoke-test steps in native-ci to
pass matrix.platform and matrix.container through env: and reference only shell
variables inside the run scripts, including the docker run command and the
package directory/pack paths, so values sourced from platforms.json are not
expanded by GitHub Actions expressions in the shell.
In @.github/workflows/native-release.yml:
- Around line 55-60: The “Set version” and tag steps in the native-release
workflow interpolate inputs.version directly into shell commands, so bind it
once via env and reference a quoted VERSION variable in those run blocks
instead. Update the existing “Set version” step and the later tag step that uses
npm version / git tag logic to read from env rather than inline expression
interpolation, keeping the commands otherwise unchanged.
In `@native/benchmark/memory.mjs`:
- Around line 66-70: The parse/import failure handling in memory.mjs is
swallowing errors by returning null from the backend parsing paths, which causes
main() to silently skip requested benchmark backends. Update the affected
backend parse/import helpers and the main flow so requested backends fail
explicitly instead of converting failures to null, and use the existing
backend-specific functions in this file to surface the error and stop the run
when a requested backend cannot be benchmarked.
In `@native/Makefile`:
- Around line 63-65: The `$(LIBPG_QUERY_DIR)` target can be left in a partial
state if `git clone` is interrupted, causing Make to think the target is already
satisfied on later runs. Update the `$(LIBPG_QUERY_DIR)` recipe to clone into a
temporary directory and only move/rename it into `$(LIBPG_QUERY_DIR)` after a
successful clone. Keep the fix localized to the Makefile rule that currently
runs `git clone`, so repeated invocations recover cleanly from interrupted
downloads.
In `@native/README.md`:
- Around line 117-132: The README’s jemalloc preload example hard-codes an
x86_64 Linux path that won’t work on arm64 hosts, so update the native addon
documentation to use architecture-aware Linux paths or separate examples for x64
and arm64. Adjust the Linux preload snippet and the Dockerfile guidance together
so they match the supported Linux architectures mentioned in the native addon
docs and avoid referencing a single fixed /usr/lib/x86_64-gnu path.
In `@native/src/addon.cc`:
- Around line 9-23: The JSON escaping in EscapeJsonString is incomplete because
it only handles a few named escapes and leaves other control bytes raw, which
can break the error payload consumed by checkError in native/src/index.ts.
Update EscapeJsonString in addon.cc to escape every character below 0x20,
including cases like backspace, form feed, and any other control byte, so the
serialized error object always remains valid JSON. Use the existing
EscapeJsonString helper as the single place to fix this, and add the needed
low-level formatting support if required.
In `@native/src/index.ts`:
- Around line 138-140: The async wrappers in native/src/index.ts are only
compatibility shims because parse, parsePlPgSQL, fingerprint, normalize, and
scan immediately delegate to their sync counterparts like parseSync, so clarify
this behavior in the implementation or docs near those exported functions. If
you keep the shim, add a note that these Promise-returning APIs still block the
event loop and should not be treated as non-blocking; if real async behavior is
needed later, move the work off-thread via a worker or threadpool.
In `@native/test/errors.test.js`:
- Around line 7-15: The negative test around parseSync should explicitly fail if
no exception is thrown, since it currently only checks inside the catch block
and can pass silently when parseSync() stops throwing. Update the test to add an
immediate assert.fail("Expected error") after the parseSync call in the existing
try/catch, using the parseSync test case and the sqlDetails assertions to keep
the error contract covered.
In `@native/test/smoke.mjs`:
- Around line 25-33: The smoke test runner in test/smoke.mjs treats every case
as synchronous, so async tests like parse (async) works and loadModule is a
no-op can resolve or reject after the suite already reports success. Make test()
async, await fn() inside it, and ensure each test(...) call is awaited
(sequentially or via an awaited Promise collection) before the final
process.exit(exitCode), using the existing test() helper and exitCode flow.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: f4e1e76a-52f6-4cf7-b0ed-d0c4615fa552
📒 Files selected for processing (30)
.github/workflows/build-wasm-no-docker.yaml.github/workflows/build-wasm.yml.github/workflows/ci.yml.github/workflows/native-benchmark.yml.github/workflows/native-build.yml.github/workflows/native-ci.yml.github/workflows/native-release.ymlREADME.mdnative/.gitignorenative/Makefilenative/README.mdnative/benchmark/breakdown.mjsnative/benchmark/ci-bench.mjsnative/benchmark/compare-allocators.shnative/benchmark/memory.mjsnative/benchmark/run-with-jemalloc.shnative/package.jsonnative/platforms.jsonnative/scripts/package-platforms.mjsnative/scripts/sync-optional-deps.mjsnative/src/addon.ccnative/src/index.tsnative/test/errors.test.jsnative/test/fingerprint.test.jsnative/test/normalize.test.jsnative/test/parsing.test.jsnative/test/plpgsql.test.jsnative/test/scan.test.jsnative/test/smoke.mjsnative/tsconfig.json
💤 Files with no reviewable changes (3)
- .github/workflows/build-wasm-no-docker.yaml
- .github/workflows/build-wasm.yml
- .github/workflows/ci.yml
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
.github/workflows/native-release.yml (1)
106-124: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftMake the publish flow retry-safe. If one package publishes and a later
npm publishor the finalgit pushfails, rerunning the workflow for the same version will hit immutable npm versions and stop on the already-published packages. That leaves the release partially published and forces a manual version bump.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/native-release.yml around lines 106 - 124, Make the native release publish steps idempotent and retry-safe by changing the publish logic in the “Publish platform packages” loop and the “Publish main package” step so reruns skip versions already on npm instead of failing on immutable publishes. Add a pre-publish check around the existing npm publish calls that detects whether the current package/version is already published and only publishes when needed, then keep the final git push unchanged so a rerun after a partial release can complete cleanly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In @.github/workflows/native-release.yml:
- Around line 106-124: Make the native release publish steps idempotent and
retry-safe by changing the publish logic in the “Publish platform packages” loop
and the “Publish main package” step so reruns skip versions already on npm
instead of failing on immutable publishes. Add a pre-publish check around the
existing npm publish calls that detects whether the current package/version is
already published and only publishes when needed, then keep the final git push
unchanged so a rerun after a partial release can complete cleanly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2098868d-f875-4d80-a08a-e937bab690c1
📒 Files selected for processing (1)
.github/workflows/native-release.yml
Code correctness:
- addon.cc: escape control chars (<0x20) as \u00XX in EscapeJsonString so
error payloads stay valid JSON (was emitting raw \b/\f/\x01 -> JSON.parse
SyntaxError instead of SqlError).
- test/smoke.mjs: make the runner async and await each case (top-level await);
the two async tests were never awaited, so failures passed silently.
- test/errors.test.js: add assert.fail("Expected error") to the one negative
test missing it, so it can't pass vacuously.
- benchmark/memory.mjs: exit non-zero when a requested backend fails to load
instead of silently dropping it.
Security hardening (zizmor):
- Avoid template injection: pass matrix.platform/container and inputs.version/
tag through `env:` and reference shell vars in run/docker blocks instead of
inlining ${{ }} expressions.
- Add least-privilege permissions: contents: read on native-build.yml and
native-ci.yml; move native-benchmark.yml permissions to the job.
- Pin all actions to commit SHAs (checkout v7.0.0, setup-node v6.4.0,
upload-artifact v7.0.1, download-artifact v8.0.1, github-action-benchmark
v1.20.7).
Maintainability:
- native-ci.yml: drop the duplicated matrix-generation job; consume the build
workflow's matrix via a new workflow_call output.
- Makefile: clone libpg_query into a temp dir and mv into place on success so
an interrupted clone can't poison the cache.
- README: note the jemalloc path varies by distro/arch.
Skipped: the "async API is synchronous" note (by design, matches the WASM API).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
native/README.md (1)
134-137: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winThe warning note does not fix the broken copy-paste examples.
Supported Linux targets still get x86_64 Debian commands above, so users on arm64 or non-Debian hosts can still follow the README and fail immediately. Please make the example commands themselves architecture-aware, or replace them with an inline lookup step instead of only calling it out in prose.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@native/README.md` around lines 134 - 137, The README examples still hardcode x86_64 Debian/Ubuntu jemalloc paths, so update the installation commands in native/README.md to be architecture- and distro-aware instead of only mentioning the variation in prose. Use the existing jemalloc path guidance to revise the example command block or replace it with an inline lookup step (for example, via ldconfig -p) so readers following the commands on arm64 or RHEL/Fedora are guided to the correct libjemalloc.so.2 path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/native-benchmark.yml:
- Around line 24-28: The checkout step in the workflow is persisting credentials
even though the job already passes an explicit github-token to
github-action-benchmark, so update the actions/checkout usage to disable
credential persistence for this job. Keep the change scoped to the checkout step
in the benchmark workflow and ensure the later build and benchmark steps
continue to work without relying on the checkout token.
In @.github/workflows/native-build.yml:
- Line 24: The checkout step in the workflow still persists credentials in local
git config, which is unnecessary for these read-only build/test jobs. Update
both jobs that use actions/checkout to disable persisted credentials in the
checkout configuration so later steps can only access repository files, and keep
the change aligned with the existing checkout step in native-build workflow.
In @.github/workflows/native-ci.yml:
- Line 39: Disable credential persistence on the actions/checkout step used by
the package-test job in native-ci.yml, since this job only reads the repository
and does not push back to GitHub. Update the checkout configuration to avoid
leaving the token in git config, and keep the change scoped to the package-test
workflow step identified by actions/checkout so repo-controlled install/package
steps still work normally.
---
Duplicate comments:
In `@native/README.md`:
- Around line 134-137: The README examples still hardcode x86_64 Debian/Ubuntu
jemalloc paths, so update the installation commands in native/README.md to be
architecture- and distro-aware instead of only mentioning the variation in
prose. Use the existing jemalloc path guidance to revise the example command
block or replace it with an inline lookup step (for example, via ldconfig -p) so
readers following the commands on arm64 or RHEL/Fedora are guided to the correct
libjemalloc.so.2 path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3e2aa718-bf81-4652-aed1-27bb7b0239f6
📒 Files selected for processing (10)
.github/workflows/native-benchmark.yml.github/workflows/native-build.yml.github/workflows/native-ci.yml.github/workflows/native-release.ymlnative/Makefilenative/README.mdnative/benchmark/memory.mjsnative/src/addon.ccnative/test/errors.test.jsnative/test/smoke.mjs
Set persist-credentials: false on actions/checkout in the build, ci, and benchmark workflows. Those jobs only read repo files and run build/test/pack steps after checkout, so the GITHUB_TOKEN doesn't need to sit in local git config (zizmor artipacked). The benchmark action pushes via its explicit github-token input, not the checkout credentials, so auto-push still works. native-release.yml is intentionally left as-is: its publish job pushes the git tag and relies on the persisted checkout token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Published all 6 packages (main + 5 platform binaries) to npm under the 'beta' dist-tag at this version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds a native N-API backend for libpg_query (PG 18) under
native/, as amemory-efficient, drop-in alternative to the existing WASM build. It ships as
prebuilt platform binaries (esbuild-style) — no node-gyp or compiler toolchain at
install time — and is Node-only (no browser/Deno/Workers targets).
This is a draft: nothing is published to npm yet, and Linux/musl prebuilds are
produced in CI only (the release workflow is wired but unrun).
Native vs WASM
Head-to-head on the same machine (
darwin-arm64, Node 24), parsing a 3.31 MB SQLquery (1500×
UNION ALL, ~65 MB JSON parse tree), 3 parse/free cycles. Each backendmeasured in its own process. "Retained" is RSS after the result is dropped and GC
settles; small-query throughput is
SELECT id, name FROM users WHERE id = $1×10k.@libpg-query/parser)Per-cycle peak progression tells the real story:
the ~1.2 GB consumed by one big parse is held for the lifetime of the process. No
allocator choice can change this; it's a property of the WASM memory model.
under this workload and keeps climbing across cycles.
the OS, and stabilizes at ~498 MB instead of growing. It also has a far lower idle
baseline (55 MB vs WASM's 93 MB, since there's no preallocated heap).
across allocators — jemalloc is a pure memory win with no speed cost.
Reproduce:
node --expose-gc native/benchmark/memory.mjs --all --cycles 3(with@libpg-query/parserinstalled) andbash native/benchmark/compare-allocators.sh --cycles 3.jemalloc: why preload-only (not statically linked)
We tried to bake jemalloc into the
.nodebinary so users would get the benefit forfree. Every approach failed, and the failure is fundamental:
--with-jemalloc-prefix=je_+ remapping malloc/free in libpg_query: libpg_query'sinternal
strdup()still calls libcmalloc, butfreegot remapped toje_free— allocator mismatch → crash.
-fvisibility=hidden/-exported_symbols_list/__DATA,__interpose: either fails to intercept libc-internal allocations, or (on theinterpose path) hijacks malloc for the entire process including V8, which a
library addon has no business doing.
To work correctly, jemalloc must replace malloc/free for the whole process (so
libc-internal allocations like
strdupare consistent), and that can only be donesafely at process startup via
LD_PRELOAD/DYLD_INSERT_LIBRARIES— not by linkinginto a dylib. So the addon uses the system allocator by default and documents the
one-line preload to opt into jemalloc:
MALLOC_CONF=dirty_decay_ms:0,muzzy_decay_ms:0trims peak a little further (~477 MBin the run above) and is worth setting in memory-sensitive deployments.
What's in the PR
Native addon (
native/src/addon.cc)pg_query_parse,_fingerprint,_normalize,_scan,_parse_plpgsql.{error, result}to JS rather than throwing from C++ — throwing N-API errorswith attached detail objects segfaulted when Node's inspector/
asserttraversed them.The JS layer parses the error and throws a plain
SqlError.Distribution (esbuild-style)
platforms.jsonis the single source of truth for all 5 targets (darwinarm64, linux x64/arm64, linux x64/arm64-musl): os/cpu/libc plus CI runner/container.
The runtime loader, the platform-package generator, and the optionalDependencies sync
script all read from it. Adding a platform is a one-line change.
libc, so npm 10+and Yarn Berry install only the binary matching the host. Cross-arch install flags
documented for Docker-on-Mac builds. At runtime, libc is detected via Node's
process.reportglibc marker (reliable in distroless/scratch images that ship noldd).@ashbyhqscope as public packages: the main package(
@ashbyhq/libpg-query-native) and all per-platform packages setpublishConfig.access: public, and the release workflow runsnpm publish --access public.Requires an
NPM_TOKENwith publish rights to the@ashbyhqorg.CI/CD (
.github/workflows/)native-build.yml— reusable workflow; derives its build matrix fromplatforms.json,builds + tests on every platform, uploads prebuilds.
native-ci.yml— runs the build, then a package smoke test: packs the main +platform tarballs, installs them in an isolated dir, and exercises every public API on
each platform.
native-release.yml— manual dispatch with a dry-run toggle; builds all platformsand publishes the main + per-platform packages to npm. Needs an
NPM_TOKENsecret.Supports prerelease publishes via npm dist-tags: pass a
tag(e.g.beta) or leaveit blank to auto-derive (
latestforX.Y.Z, the prerelease id forX.Y.Z-beta.N), witha guard that refuses to push a prerelease onto
latest.native-benchmark.yml— tracks 4 smaller-is-better metrics (large-query parse time,peak RSS, retained RSS, small-query latency) on a fixed runner under jemalloc, against
a
gh-pagesbaseline. Posts per-metric diffs to the run summary every PR; fails only ona conservative 2× regression (
alert-threshold: 200%).Tests — 46 unit tests (parsing/fingerprint/normalize/plpgsql/scan/errors) + the
cross-platform smoke test.
Performance investigation (negative results, for the record)
Two further optimizations beyond the allocator were explored and rejected:
pg_query_parse_protobuf→ decode in JS): 3× slowerend-to-end. The C-side protobuf serialization is ~7× costlier than libpg_query's JSON
string builder, dwarfing the faster JS-side decode. (Useful only for compact
storage/IPC — 74% smaller wire size — so not included.)
JSON.parsefor full materialization. The costis creating ~4.2M V8 objects through N-API, which V8's internal JSON fast-path beats.
For large queries the dominant cost is V8 building the object graph (
JSON.parse≈ 66%of end-to-end), already at V8's floor. The allocator is the lever that matters;
everything else is noise for realistic query sizes (~5 µs).
Not included / follow-ups
NPM_TOKENsecret needs to be added to the repo before the release workflow can publish.Test plan
memory.mjs --all --cycles 3,compare-allocators.sh)🤖 Generated with Claude Code