You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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
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.
feat: Parquet history provider for Signal K
Overview
Build
halos-org/signalk-parquet-history-provider: a Signal K history provider that records to aSQLite 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.signalk-parquetis optional, not a design constraint.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.
Measured writer is 87 MB; QuestDB's standing cost is ~366 MB. Steady state is the only
figure that represents a continuous cost.
memory with the full marine stack running. It is not added to the steady-state figure.
of one core — the measured sqhp + QuestDB figure.
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.
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.
Scope boundaries
Key decisions
All backed by measurements in the comment thread.
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.
Counted against the source, only
54 of 866 lines carry SQL — 29 of 444 in
history-v1.ts, 25 of 422 inhistory-v2.ts. The other ~94% is storage-agnostic contract behaviour:decodeValue,groupRowsIntoDeltasand its(ts, context, source)composite key, the ~160-linestreamHistorychunk/resume machine,computeSMA/computeEMA,normalizeContext, theper-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.execand hard-code three table names — which is why no package isextracted. But Unit 4 is the largest work in the plan, not the smallest.
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).
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.
Parquet file per roll under
parquet/date=<YYYY-MM-DD>/, no path partitioning, nocompaction 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
COPYdoes 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_scannerat 63 ms per 13 MB, unchanged by an index onts.Decision.
Open questions
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.
units: radpaths needs live-server metadata handed to the roll.behaviour. sqhp takes
last()per bucket and refuses per-axis position aggregation.apps/signalk-server/prestart.shalready carriesQuestDB-specific
historyApi.defaultProviderlogic.Risks
sqlite_scannerandspatialare not bundled — they installed over the networkDelta-supplied path and context strings become directory namesAIS-driven path and context cardinality inflates partition count, which sets the roll's peakSmall-file Parquet output inflates size and roll memorysignalk-server/src/index.ts:316is a single global slot, last registration wins, andunregisterHistoryProviderignores its argumenthistoryApi.defaultProvider; detect co-residency and refuse to guess (Unit 6)Implementation units
Foundations
sqlite_scanner, build the measurement harnessStorage 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.