Skip to content

feat: Parquet history provider for Signal K #152

Description

@mairas

feat: Parquet history provider for Signal K

Overview

Build halos-org/signalk-parquet-history-provider: a Signal K history provider that records to a
SQLite hot store, rolls it into a Parquet tree, and serves both history API surfaces by
spawning DuckDB over the tree plus the hot store.

It is a peer alternative to signalk-questdb-history-provider (sqhp), chosen per device.
Nothing in sqhp changes. No shared package is extracted.

Requirements

From _local/brainstorms/2026-08-22-parquet-history-provider-requirements.md.

  • R1. Full Signal K history provider (v1 and v2 surfaces).
  • R2. An alternative to sqhp, selected per device; not run together.
  • R3. Offering work upstream to signalk-parquet is optional, not a design constraint.
  • R4. The Signal K process performs only path filtering, rate capping and buffered socket writes.
  • R5. Flush on whichever comes first: a time interval or a batch-size threshold.
  • R6. A separate writer process owns the hot store.
  • R7. Crash tolerance is the flush window, no worse than sqhp.
  • R8. Bounded buffer when the writer is unreachable, dropping oldest first.
  • R9. SQLite for the hot store; DuckDB for roll and queries.
  • R10. Hot store rolled into the Parquet tree and truncated.
  • R11. The roll runs in a short-lived process that exits.
  • R12. Queries read hot store and Parquet tree together.
  • R13. Include/exclude glob path filter.
  • R14. Configurable default sampling-rate cap.
  • R15. Per-path sampling-rate overrides.
  • R16. User-defined ordered retention ladder (resolution + duration per step).
  • R17. No per-path retention overrides.

Success criteria

Measured on Pi-class hardware, 70–130 rows/second, with the three-window method
(300 s windows, 180 s settle, cgroup cpu.stat, /proc/<pid>/io, /proc/diskstats,
half-window means checked for steady state). Report dispersion, not point estimates.

  • Steady-state added resident memory across plugin and writer stays under 150 MB.
    Measured writer is 87 MB; QuestDB's standing cost is ~366 MB. Steady state is the only
    figure that represents a continuous cost.
  • The roll's transient peak is reported separately and must not exceed available
    memory with the full marine stack running. It is not added to the steady-state figure.
  • Total CPU across plugin, writer and amortised roll stays under +2.3 percentage points
    of one core
    — the measured sqhp + QuestDB figure.
  • Signal K process disk writes stay within noise of the 12 KB/s no-history control.
  • ~~A single-path range or bucket-aggregate query returns within sqhp's range (~34 ms for
    an hour-long request) against a fresh tree.~~ Measured and not met, by a factor of
    15.
    Unit 4a's real reader answers a single-path hour in 526–684 ms on a HALPI2, of
    which ~345 ms is the floor every spawned query pays before reading a row — ~220 ms of
    that is mapping the engine's native addon. The spike's 16–37 ms was in-engine time in an
    already-running process. This is a property of spawn-per-query, not of the tree, so the
    open question below replaces the criterion rather than restating it.
  • A long-range query against an aged tree holding a full retention window's file count
    returns within a stated bound. Measured in Unit 4a: per-file planning is about 1 ms, so a
    30-day whole-window query is dominated by its rows rather than by its files, and a
    date-scoped query answers in 360–444 ms whatever the tree's size.
  • Recording broadly does not require enumerating paths by hand.

Scope boundaries

  • No shared core package. If two implementations later make a seam obvious, extract then.
  • No changes to sqhp.
  • No cloud upload, analysis UI or GPX import.
  • The provider does not manage a container lifecycle.
  • Running alongside sqhp on one device is unsupported.

Key decisions

All backed by measurements in the comment thread.

  • SQLite hot store, DuckDB roll and query. 87 MB steady state against 159 MB, 3.3× less
    ingest CPU. SQLite's larger store (135 MB per 763k rows vs 20 MB) is answered by roll
    frequency, and the Parquet output is identical either way. The roll reads the hot
    store directly while the writer still holds it, because SQLite in WAL mode takes
    concurrent readers.
  • No shared package, but most of the history surfaces are copied, not re-derived.
    Counted against the source, only
    54 of 866 lines carry SQL — 29 of 444 in history-v1.ts, 25 of 422 in
    history-v2.ts. The other ~94% is storage-agnostic contract behaviour: decodeValue,
    groupRowsIntoDeltas and its (ts, context, source) composite key, the ~160-line
    streamHistory chunk/resume machine, computeSMA/computeEMA, normalizeContext, the
    per-spec column assembly and timestamp union. That is copied like path-matcher.ts;
    only query construction is rewritten. The files still cannot be lifted whole — they call
    queryClient.exec and hard-code three table names — which is why no package is
    extracted. But Unit 4 is the largest work in the plan, not the smallest.
  • The roll is a short-lived process. Peak is ~218 MB and is dominated by holding one
    writer per partition, not by data volume: 10× the rows costs 26% more memory. DuckDB's
    allocator does not return it in-process, so the process must exit. Chunking the roll
    inside one process makes the peak worse (218 → 325 → 473 MB for 1, 2, 4 chunks).
  • Queries run in a query service that holds the engine. A process per query was
    built and measured first: starting an engine is ~345 ms on a HALPI2 and answering is
    96–246 ms, so it spent six times longer starting than working. The service is a
    separate process — never the writer and never the server — and it is kept for the
    life of the plugin. That buys a single-path hour at a median of 148 ms instead of
    526–684 ms, and costs 92 MB idle rising to the high-water mark of the largest answer
    it has served (164 MB after fifteen ordinary queries, 317 MB after one of 82,000
    rows). Recycling it against an idle timeout or an RSS ceiling is Recycle the query service instead of holding one for the life of the plugin #178.
  • The tree is flat and the roll is hourly. Settled by measurement in Unit 3a: one
    Parquet file per roll under parquet/date=<YYYY-MM-DD>/, no path partitioning, no
    compaction pass, plus a cumulative last-value sidecar. A partitioned write's peak
    follows its row count and runs out of memory at 552 partitions; a streaming COPY
    does not, and stays under 250 MB at a day's rows. The interval is not a memory lever —
    it is the hot store's ceiling, and every recent query full-scans that store through
    sqlite_scanner at 63 ms per 13 MB, unchanged by an index on ts.
    Decision.

Open questions

  • ~~Whether a ~0.5 s history query is acceptable.~~ Decided 2026-08-23: keep the
    engine.
    A process per query answered a single-path hour in 526–684 ms; the query
    service answers it in 135–175 ms. That is still four to five times sqhp's ~34 ms
    rather than fifteen, and the remainder is not startup — the same statement on an open
    engine runs in 39–45 ms, and the rest is the rows crossing a pipe as JSON. The
    standing cost this accepts is tracked in Recycle the query service instead of holding one for the life of the plugin #178.
  • Vector averaging for units: rad paths needs live-server metadata handed to the roll.
  • Non-numeric aggregation: strings, booleans and positions have no defined ladder
    behaviour. sqhp takes last() per bucket and refuses per-axis position aggregation.
  • How an operator picks a provider. apps/signalk-server/prestart.sh already carries
    QuestDB-specific historyApi.defaultProvider logic.
  • Grafana's datasource is provisioned per datastore; a Parquet tree has none.
  • Switching providers strands existing history. No migration path is in scope.

Risks

Risk Mitigation
sqlite_scanner and spatial are not bundled — they installed over the network Bundle at build time in Unit 1; devices may be offline
Delta-supplied path and context strings become directory names Closed by Unit 3a: only the roll's own UTC date is a directory, so no delta-supplied string reaches the filesystem
Writer socket is unauthenticated if it lands as TCP Unix domain socket, mode 0600, owner-only (Unit 2)
DuckDB extension autoload fetches binaries at query time Disable autoinstall/autoload, restrict file access, lock config after startup (Unit 4)
AIS-driven path and context cardinality inflates partition count, which sets the roll's peak Closed by Unit 3a: there are no partitions, and a streaming roll's peak does not follow cardinality
Small-file Parquet output inflates size and roll memory Closed by Unit 3a: one file per roll, 24 a day. The per-path alternative measured 10,337 files and 43 MB of disk for data one file holds in 3.2 MB
Benchmarks are from one device and one workload Unit 7 reports dispersion and states the workload
History v1 has no provider registry — signalk-server/src/index.ts:316 is a single global slot, last registration wins, and unregisterHistoryProvider ignores its argument Gate v1 registration on being the configured historyApi.defaultProvider; detect co-residency and refuse to guess (Unit 6)
Spawn-per-query turns a multi-series panel into many ~120 MB processes One request compiles to one process; cap concurrent query processes (Unit 4a)
Nothing admission-controls a roll (~218 MB), a query (~120 MB) and a playback session at once Unit 7 measures the summed transient; Unit 4a states whether they may overlap
Extension load, catalog setup and Parquet metadata reads are paid per spawned process Unit 4a measures end-to-end wall time from spawn, not in-engine query time

Implementation units

Foundations

Storage layout

Query

Retention and ship

After verification

4b and 4c are independent of each other; both depend on 4a. Unit 5b is sequenced after
Unit 7 because it serves no success criterion, carries the worst correctness risks, and
runs inside the roll process whose transient peak the design is judged on — a peak
measured without aggregation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions