Skip to content

Store prediction data in a pack file read per record; node + browser builds - #103

Merged
bkeepers merged 4 commits into
mainfrom
lazy-load-station-data
Jul 28, 2026
Merged

Store prediction data in a pack file read per record; node + browser builds#103
bkeepers merged 4 commits into
mainfrom
lazy-load-station-data

Conversation

@bkeepers

@bkeepers bkeepers commented Jul 15, 2026

Copy link
Copy Markdown
Member

Importing @neaps/tide-database eagerly parsed all 8,290 stations (via import.meta.glob({ eager: true })) into live JS objects — 118 MB heap / 663 MB RSS (the predictor itself is 4 MB). Two consequences:

  • OOM on constrained devices — signalk-tides crashes a Victron Cerbo GX with a V8 Reached heap limit error (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.
  • Serverless cold-start CPU — the ~358 ms module parse dominates the tides API's per-request cost (~95 % of its Fluid Active CPU).

Solution: move the prediction data off the V8 heap

The OOM is a heapUsed vs --max-old-space-size limit. 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.

  • Build (scripts/generate-pack.mjs): writes stations.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.
  • Node build (station-data.ts): openSync the pack once; each lookup readSyncs only that record's range and parses it. Nothing resident — external stays ~2 MB, the OS page-caches touched pages, and the parsed record is transient. The read loops until the full range is filled (no uninitialized allocUnsafe bytes reach JSON.parse).
  • Browser build (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-data subpath import; exports conditions select dist/node vs dist/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-time datums export replaces @neaps/api's all-stations datum scan.

Results

Node build, import + one nearest(), GC'd:

heapUsed external
eager (before) 118 MB
per-record pack (now) 35.8 MB 1.9 MB

The remaining ~36 MB is the sync API's own cost (8,290 Station objects + 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

kdbush and geokdbush are ESM-only packages that can't be require()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 — the require condition is removed.

Testing

  • All 31,862 tests pass; predictions and search unchanged.
  • Deterministic builds: readAll() sorts by path; datums enum is sorted.
  • A post-build smoke test (scripts/smoke.mjs, runs on every build) imports both built ESM entries, checks reference + subordinate resolution, and asserts the browser bundle contains no node:fs — a broken artifact fails the build.
  • Tarball ships 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

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
@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@neaps/tide-database@103

commit: 3a91ee9

Comment thread src/stations.ts Outdated
Comment thread src/stations.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 datums export + 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.

Comment thread src/station-bundle.ts Outdated
Comment thread src/station-bundle.ts Outdated
Comment thread src/stations.ts Outdated
Comment thread src/stations.ts Outdated
Comment thread src/types.ts Outdated
Comment thread src/search/index.ts Outdated
bkeepers added 2 commits July 15, 2026 10:22
- 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
@bkeepers bkeepers changed the title Load station harmonics/datums lazily to cut memory ~40% Store prediction data in a pack file read per record; node + browser builds Jul 15, 2026
@bkeepers
bkeepers merged commit 55ab7db into main Jul 28, 2026
3 checks passed
@bkeepers
bkeepers deleted the lazy-load-station-data branch July 28, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants