Store prediction data in a pack file read per record; node + browser builds - #103
Merged
Conversation
Importing @neaps/tide-database eagerly parsed all 6,000+ stations into live JS objects, costing ~118 MB of heap / ~660 MB RSS. This OOMs memory-constrained consumers (signalk-tides on a Victron Cerbo GX, openwatersio/signalk-tides#103) and dominates the tides API's serverless cold-start CPU. Split each station into light metadata (bundled eagerly as object literals, ~15 MB) and heavy fields — harmonic_constituents, datums, epoch — bundled as unparsed per-station JSON strings and parsed on demand via getters. Reading a station's harmonics parses just that one station; subordinate stations resolve to their reference's data. The ~15 MB MiniSearch text index is also deferred until the first search(). The public API stays synchronous. Adds a build-time `datums` export so consumers (the API's OpenAPI spec) can get the datum enum without a runtime scan that would re-parse every station. Import + one nearest() lookup: heap 118 MB -> 69 MB (84 MB once text search is used). All 31,862 tests pass. See docs/lazy-loading.md. Refs openwatersio/signalk-tides#103
commit: |
bkeepers
commented
Jul 15, 2026
Contributor
There was a problem hiding this comment.
Pull request overview
This PR reduces baseline memory and import-time overhead by splitting station records into eagerly bundled “light” metadata and lazily parsed “heavy” fields (harmonics/datums/epoch), while also deferring construction of the MiniSearch text index until search() is called. It also adds a build-time datums export to avoid downstream runtime scans that would force parsing every station.
Changes:
- Introduces a build-time station bundling step (
station-bundle.ts) to inline metadata as object literals and heavy fields as per-station JSON strings parsed on demand. - Defers MiniSearch text index loading until first text search; geo index remains eager.
- Adds
datumsexport + tests validating datum coverage and subordinate→reference lazy resolution behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| test/station-bundle.test.ts | Adds coverage for new datums export and lazy heavy-field resolution. |
| src/types.ts | Introduces StationMeta / StationMetaKey types for the “light” station shape. |
| src/stations.ts | Switches station construction to meta + lazily parsed heavy fields; adds datums export. |
| src/station-bundle.ts | New build-time splitter that emits meta and heavy arrays and datum enum. |
| src/search/text.ts | Builds text index from station metadata only (no heavy-field touches). |
| src/search/index.ts | Defers text index loading until search() is called. |
| src/search/geo.ts | Builds geo index from station metadata only. |
| docs/lazy-loading.md | Documents the Phase 0 approach and longer-term lazy-loading proposal. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Frame the async work as a non-breaking @neaps/tide-database/async subpath (main entry unchanged), not a major bump. - Two tiers: slim metadata (identity, bundled) + heavy pack (per station). - Derive source.id from the id (<source>/<source-id>, verified across all 8,290 stations) instead of storing it; build asserts the invariant. - Bundle the byte-range index (no separate .idx file) so the client is dependency-free. - Add a geo/text index section (geo ~66 KB eager; text ~15 MB lazy or linear-scan) and the expected ~10-15 MB pack baseline. - Reconcile: openapi datums export is done in Phase 0; fix station/test counts.
…wser builds Importing @neaps/tide-database eagerly parsed all 8,290 stations into live JS objects — 118 MB heap / 663 MB RSS. That OOMs memory-constrained consumers (signalk-tides on a Victron Cerbo GX, openwatersio/signalk-tides#103, a V8 heap-limit crash) and dominates the tides API's serverless cold-start CPU. Move the prediction data (harmonic_constituents, datums, epoch) out of the JavaScript heap: - A build step (scripts/generate-pack.mjs) writes stations.pack — the records concatenated as UTF-8 JSON — plus a bundled byte-range index (id -> [offset, length]). Metadata (identity + offsets/source/etc) stays inlined as object literals. - The Node build opens the pack once and readSyncs only the bytes for the station being loaded; nothing is held resident (external stays ~2 MB, and the OS page-caches touched pages). The read loops until the full range is filled so no uninitialized bytes reach JSON.parse. - The package is publicly distributed and may run in a browser (no filesystem), so a browser build bundles the records as JSON strings instead. The source is swapped per build behind the #station-data subpath import; exports conditions select dist/node vs dist/browser. The sync API is unchanged; subordinate stations still resolve to their reference's record (validated at build time). The text index is built lazily on first search() and its serialized string is freed afterward. A build-time `datums` export replaces @neaps/api's all-stations datum scan. Result: import + a nearest() lookup is 35.8 MB heap (prediction data off the heap) vs 118 MB before; reads all 8,290 stations under --max-old-space-size=40. ESM only: kdbush and geokdbush are ESM-only packages that can't be required cleanly from CJS, and all first-party consumers use ESM. A post-build smoke test (scripts/smoke.mjs) imports both built ESM entries, checks reference + subordinate resolution, and asserts the browser bundle has no node:fs — so a broken artifact fails the build. All 31,862 tests pass. Refs openwatersio/signalk-tides#103
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Importing
@neaps/tide-databaseeagerly parsed all 8,290 stations (viaimport.meta.glob({ eager: true })) into live JS objects — 118 MB heap / 663 MB RSS (the predictor itself is 4 MB). Two consequences:Reached heap limiterror (signalk-tides always causes signalk to restart after approx 30 minutes signalk-tides#103). It's not a leak: the prediction hot loop is flat over 600 iterations. The 118 MB baseline just consumes the constrained heap's headroom.Solution: move the prediction data off the V8 heap
The OOM is a
heapUsedvs--max-old-space-sizelimit. Anything bundled into JavaScript (object literals, JSON string literals, even a base64 literal → measured ~58 MB and OOMs under a 48 MB cap) lands on that heap. So the prediction data (harmonic_constituents,datums,epoch) ships as a file and is read by byte range instead.scripts/generate-pack.mjs): writesstations.pack— the records concatenated as UTF-8 JSON — and a bundled byte-range index (id → [offset, length]). Metadata (identity +offsets/source/etc.) stays inlined as object literals. Offsets are UTF-8 bytes. Subordinate → reference links are validated at build time.station-data.ts):openSyncthe pack once; each lookupreadSyncs only that record's range and parses it. Nothing resident —externalstays ~2 MB, the OS page-caches touched pages, and the parsed record is transient. The read loops until the full range is filled (no uninitializedallocUnsafebytes reachJSON.parse).station-data.browser.ts): no filesystem, so the records are bundled as JSON strings and parsed on demand. The source is swapped per build behind the#station-datasubpath import;exportsconditions selectdist/nodevsdist/browser.The public API is unchanged and still synchronous. Subordinate stations resolve to their reference's record. The MiniSearch text index is built lazily on the first
search()and its serialized string is freed afterward. A build-timedatumsexport replaces@neaps/api's all-stations datum scan.Results
Node build, import + one
nearest(), GC'd:The remaining ~36 MB is the sync API's own cost (8,290
Stationobjects + metadata + id maps), not the prediction data, which is never resident. Reads all 8,290 stations under--max-old-space-size=40; the old build needs ~69 MB just to start.ESM only
kdbushandgeokdbushare ESM-only packages that can't berequire()d cleanly from a CJS build (double-wrapped default →KDBush.from is not a function), and all first-party consumers use ESM. So the package ships ESM only — therequirecondition is removed.Testing
readAll()sorts by path;datumsenum is sorted.scripts/smoke.mjs, runs on every build) imports both built ESM entries, checks reference + subordinate resolution, and asserts the browser bundle contains nonode:fs— a broken artifact fails the build.dist/node/generated/stations.pack(17.7 MB; package 9.7 MB).Design notes and measurements:
docs/lazy-loading.md.Refs openwatersio/signalk-tides#103