signalk-questdb 2.1.8 → 2.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,48 +1,127 @@
1
1
  # signalk-questdb
2
2
 
3
- QuestDB history provider for Signal K -- a drop-in replacement for signalk-to-influxdb and signalk-to-influxdb2.
3
+ QuestDB history provider for Signal K — a drop-in replacement for
4
+ signalk-to-influxdb and signalk-to-influxdb2.
4
5
 
5
- Stores all vessel data in QuestDB running as a managed container (via [signalk-container](https://github.com/dirkwa/signalk-container)). Implements both the modern v2 History API and the legacy v1 playback API.
6
+ Records every vessel's data in [QuestDB](https://questdb.com), running as a
7
+ container managed for you by
8
+ [signalk-container](https://github.com/dirkwa/signalk-container), and serves it
9
+ back through Signal K's v2 History API and the legacy v1 playback API. Existing
10
+ InfluxDB history can be imported.
6
11
 
7
12
  ## Features
8
13
 
9
- - **Automatic container management** -- QuestDB runs in Podman/Docker, managed by signalk-container
10
- - **ILP ingestion** -- writes via InfluxDB Line Protocol over raw TCP (no client library needed)
11
- - **v2 History API** -- `getValues`, `getPaths`, `getContexts` with all aggregate methods
12
- - **Source-aware history** -- read one source's rows with `path|sourceRef`, or split a path into one column per recording source with `sourcePolicy=all` (opt-in)
13
- - **v1 Legacy API** -- `hasAnyData`, `streamHistory`, `getHistory` for WebSocket playback
14
- - **Path filtering** -- include/exclude paths with glob patterns
15
- - **Sampling rates** -- per-path throttling to control write volume
16
- - **Retention policy** -- automatic partition drop after N days
17
- - **AIS recording** -- optionally record other vessels
18
- - **Position tracking** -- separate optimized table for lat/lon
19
- - **On-disk compression** -- LZ4 (fast) or ZSTD (smaller) via QuestDB WAL segment compression
20
- - **Parquet export** -- native QuestDB Parquet export with configurable compression
21
- - **CSV export** -- download historical data via REST endpoint
22
- - **InfluxDB migration** -- detect InfluxDB 1.x/2.x, browse its buckets and measurements, and import history into QuestDB with original timestamps
23
- - **One-click updates** -- check for new QuestDB releases and update from the config panel
24
- - **Console webapp** -- QuestDB's own SQL console embedded in the Signal K admin UI (admin only)
25
- - **Config panel** -- status dashboard with row counts, version picker, update check, collapsible compression/migration/export sections
26
- - **SQL injection protection** -- strict input validation on all query endpoints
27
- - **Container lifecycle** -- container stops when plugin is disabled, starts on enable
28
-
29
- ## Config Panel
30
-
31
- The plugin embeds a React config panel in the Signal K Admin UI showing:
32
-
33
- - **QuestDB Status** -- running/not running indicator, total rows, active paths today
34
- - **Update check** -- compares running version against latest GitHub release, one-click update
35
- - **Image Version** -- dropdown with latest, pre-releases, and last 3 stable releases
36
- - **Connection** -- managed container toggle, host/ports, PostgreSQL port for Grafana
37
- - **Recording** -- record self, record AIS targets, startup restore, console webapp, retention days
38
- - **History** -- allow `sourcePolicy=all`, letting callers split a path by recording source
39
- - **Path filtering** (collapsible) -- exclude or include-only paths with glob patterns
40
- - **Compression** (collapsible) -- LZ4/ZSTD codec selection for on-disk storage
41
- - **InfluxDB Migration** (collapsible) -- detect or enter a URL, pick a bucket/database and time range, then run the import with live progress
42
- - **Data Export** (collapsible) -- date range picker, Parquet/CSV format, download button
43
- - **Danger zone** (collapsible) -- "Remove container & all data" to fully reset QuestDB (deletes data Signal K's plugin-uninstall can't, on rootless Podman)
44
-
45
- ## QuestDB Schema
14
+ - **Managed QuestDB** — the container is pulled, created, updated and
15
+ resource-capped for you; or point the plugin at an external QuestDB
16
+ - **v2 History API** — `getValues`, `getPaths`, `getContexts` with every
17
+ aggregate method, per-source reads (`path|sourceRef`) and per-source splitting
18
+ (`sourcePolicy=all`)
19
+ - **v1 playback API** — WebSocket replay at configurable speed
20
+ - **Recording controls** — per-path sampling rates, include/exclude filters with
21
+ globs, own vessel and AIS targets separately, retention by days, LZ4/ZSTD
22
+ on-disk compression
23
+ - **Startup restore** — AIS targets back on the chart right after a server
24
+ restart, in their ship-type colour
25
+ - **WAL watchdog** — a table QuestDB has silently stopped applying is detected,
26
+ alerted, and resumed
27
+ - **InfluxDB migration** — detect InfluxDB 1.x/2.x, pick a bucket, a vessel and
28
+ a range, import with original timestamps; resumable
29
+ - **Export** — Parquet or CSV by date range, and a per-table full export for
30
+ backup tooling
31
+ - **Console webapp** — QuestDB's own SQL console inside the Signal K admin UI,
32
+ admin only
33
+ - **Config panel** — status, row counts, one-click QuestDB updates, and every
34
+ setting in one place
35
+
36
+ ## Getting started
37
+
38
+ ### Requirements
39
+
40
+ - Node.js 22 or newer
41
+ - Signal K server — 2.31 or newer to choose the default history provider in the
42
+ admin UI
43
+ - For managed mode: the
44
+ [signalk-container](https://github.com/dirkwa/signalk-container) plugin, and
45
+ Podman 5.4 or newer (what Debian 13 "trixie" ships) or Docker.
46
+ signalk-container 1.29.0 or newer is recommended; the plugin runs with 1.14.0
47
+ or newer, but older versions lose parts of it — below 1.19.0 the Danger zone
48
+ cannot delete data kept on a named volume, below 1.26 the macOS open-files
49
+ problem shows as a generic error, below 1.29.0 a container the runtime refuses
50
+ to recreate is not reported (see Troubleshooting). On rootless Podman below
51
+ 5.5 the plugin's open-files request is inherited from the podman service
52
+ rather than granted per container
53
+ ([containers/podman#25881](https://github.com/containers/podman/issues/25881));
54
+ signalk-container accounts for this.
55
+
56
+ ### Install
57
+
58
+ 1. Install **signalk-container** from the App Store and enable it (skip this for
59
+ an external QuestDB).
60
+ 2. Install **signalk-questdb** and enable it. On the first start the plugin
61
+ pulls `questdb/questdb` at the tag chosen under **QuestDB image version**
62
+ (`latest` by default), creates the container — named `signalk-questdb` behind
63
+ the runtime's prefix, `sk-` unless configured otherwise — with its ports
64
+ bound to loopback, and starts recording. The data lives under Signal K's
65
+ configuration directory, in `plugin-config-data/signalk-questdb` (see Data
66
+ storage).
67
+ 3. Open the plugin's config panel: the status card shows **Running** and
68
+ **Recording**, the total row count and the paths active today.
69
+
70
+ For an external QuestDB, switch **Manage QuestDB container** off and enter its
71
+ host and ports.
72
+
73
+ ### Make it the history provider
74
+
75
+ Which registered provider answers `/signalk/v2/api/history/` is the operator's
76
+ choice: pick it once under **Data → Preferences → Default History Provider**
77
+ (server 2.31 or newer); the server persists it as `historyApi.defaultProvider`
78
+ in `settings.json`, and it survives restarts and plugin load order. If no
79
+ default is configured, the server uses whichever provider registers first.
80
+ Versions 2.0.0 and earlier asked the server to make QuestDB the default on every
81
+ start; that is gone as of 2.0.1 — it could silently override a default you had
82
+ chosen, and on servers with security enabled it never worked at all.
83
+
84
+ ## Configuration
85
+
86
+ The config panel groups the settings as: the **status card** (running state,
87
+ rows, active paths, restore outcome, WAL state), the **update check** and
88
+ **image version** picker (latest, pre-releases and the last three stable
89
+ releases), **Connection**, **Recording**, **History**, and the collapsible
90
+ **Path filtering**, **Compression (on-disk)**, **InfluxDB Migration**, **Data
91
+ Export** and **Danger zone** sections. The Danger zone removes the container and
92
+ all of its data — including what Signal K's plugin uninstall cannot reach on
93
+ rootless Podman.
94
+
95
+ | Setting | Default | Description |
96
+ | ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
+ | QuestDB image version | `latest` | Image tag; the dropdown lists the latest release, pre-releases, and the last three stable releases |
98
+ | Manage QuestDB container | `true` | Let signalk-container run QuestDB; off = connect to an external QuestDB |
99
+ | QuestDB host | `127.0.0.1` | External mode only; in managed mode the address is resolved automatically |
100
+ | HTTP port | `9000` | External mode, or the host binding when "Bind to 0.0.0.0" is on; otherwise signalk-container allocates it |
101
+ | ILP port | `9009` | External mode, or the host binding when "Bind to 0.0.0.0" is on; otherwise signalk-container allocates it |
102
+ | PostgreSQL port | `8812` | Host binding for Grafana/psql when "Bind to 0.0.0.0" is on |
103
+ | QuestDB memory limit | `768m` | Hard cgroup cap on the container (empty = unlimited); the JVM sizes its heap to a fraction of it, so heap plus off-heap stays inside |
104
+ | QuestDB CPU limit (cores) | `1.5` | Fractional cores (0 = unlimited) |
105
+ | Default sampling rate (ms) | `2000` | Minimum ms between writes for any one path (0 = every update) |
106
+ | Per-path sampling rates (ms) | _(empty)_ | Glob → ms overrides, e.g. `{ "environment.wind.*": 200, "tanks.*": 10000 }` |
107
+ | Write batch interval (ms) | `5000` | How often buffered samples are committed — one WAL transaction per table per commit (see Performance) |
108
+ | Record own vessel | `true` | Record the `self` context |
109
+ | Record AIS targets | `true` | Record other vessels |
110
+ | Restore vessels on startup | `false` | Replay each vessel's last recorded position after a restart (see Startup restore) |
111
+ | Restore max age (minutes) | `9` | Only replay values recorded within this window |
112
+ | Allow `sourcePolicy=all` | `false` | Let History API callers split a path into one column per recording source (see History API) |
113
+ | QuestDB console webapp | `true` | Serve QuestDB's console inside the Signal K admin UI, admin only (see Console webapp) |
114
+ | Retention (days) | `0` | Drop daily partitions older than this (0 = keep forever) |
115
+ | Filter mode | `exclude` | `exclude` the matching paths, or `include` only the matching paths |
116
+ | Path patterns | _(empty)_ | Glob patterns, one per line, e.g. `environment.wind.*`; empty records everything |
117
+ | Compression codec | `lz4` | On-disk WAL segments and Parquet exports: `none`, `lz4` (fast) or `zstd` (smaller) |
118
+ | Compression level | `3` | ZSTD level 1–22, used only with `zstd` |
119
+ | Container network | `sk-network` | Shared Podman/Docker network QuestDB joins in managed mode, so the companion signalk-grafana reaches it by container DNS; host-port publication is separate ("Bind to 0.0.0.0") |
120
+ | Bind to 0.0.0.0 | `false` | Publish QuestDB's ports on all interfaces (see Connectivity) |
121
+
122
+ ## Recording
123
+
124
+ ### Schema
46
125
 
47
126
  Three tables, all with WAL mode, daily partitioning, and deduplication:
48
127
 
@@ -52,6 +131,10 @@ Three tables, all with WAL mode, daily partitioning, and deduplication:
52
131
  | `signalk_str` | String values | `ts`, `path` (SYMBOL), `context` (SYMBOL), `source` (SYMBOL), `value_str` (VARCHAR), `value_kind` (SYMBOL) |
53
132
  | `signalk_position` | Positions | `ts`, `context` (SYMBOL), `source` (SYMBOL), `lat` (DOUBLE), `lon` (DOUBLE) |
54
133
 
134
+ The own vessel is stored as context `self`, whatever its identity; other vessels
135
+ keep their `vessels.urn:…` context. The history API translates `self` back to
136
+ `vessels.self`.
137
+
55
138
  `ts` is the **server receive time**, not the timestamp a source claims. Marine
56
139
  sources carry independent clocks, and storing their timestamps makes commits
57
140
  land out of order — QuestDB then rewrites partition tails on every merge
@@ -64,55 +147,102 @@ units feeding the same server interleave in storage, and without the column a
64
147
  track drawn from history zigzags between them. Rows recorded before the column
65
148
  existed have `source` null; they replay unattributed and cannot be filtered.
66
149
 
67
- ## History API
68
-
69
- ### v2 (REST -- `/signalk/v2/api/history/`)
150
+ ### What is recorded
151
+
152
+ Every value on the stream is routed by its type: numbers go to `signalk`,
153
+ strings and booleans to `signalk_str` (booleans tagged `value_kind=boolean`),
154
+ and `navigation.position` — that path only — to `signalk_position`. A vessel's
155
+ name, which arrives as an empty-path `{name}` delta, is stored in `signalk_str`
156
+ under the path `name` with `value_kind=identity`, and replayed in its original
157
+ shape. An object value is recorded as its scalar leaves, one level deep:
158
+ `navigation.attitude {roll, pitch, yaw}` becomes `navigation.attitude.roll` and
159
+ so on, and another lat/lon object such as `navigation.anchor.position` becomes
160
+ `.latitude` and `.longitude` leaves rather than rows in the track table. Nested
161
+ objects — notifications, resource documents — are not descended into; a value no
162
+ table can hold is skipped and reported in the status endpoint and the panel
163
+ rather than dropped silently.
164
+
165
+ **Sampling.** Each path is written at most once per **Default sampling rate**
166
+ (2000 ms), with per-path overrides by glob for the few paths that need finer
167
+ resolution and the many that need less. **Record own vessel** and **Record AIS
168
+ targets** switch the two kinds of context independently, and **Path patterns**
169
+ exclude (or include only) paths by glob.
170
+
171
+ **Batching.** Samples are buffered and committed every **Write batch interval**
172
+ (5 s), one WAL transaction per table per commit. The buffer holds 100 000 lines;
173
+ if QuestDB is unreachable for longer than that covers, the oldest lines are
174
+ dropped and counted. A clean Signal K shutdown flushes first; a hard crash loses
175
+ at most one interval.
176
+
177
+ **Retention** drops whole daily partitions once they are older than the
178
+ configured days; 0 keeps everything. **Compression** applies LZ4 or ZSTD to the
179
+ WAL segments on disk (and to Parquet exports).
70
180
 
71
- Registered via `app.registerHistoryApiProvider()`. Supports all aggregate methods:
72
-
73
- | Method | QuestDB mapping |
74
- | --------- | ------------------------------------------------------------------------------- |
75
- | `average` | `avg(value)` |
76
- | `min` | `min(value)` |
77
- | `max` | `max(value)` |
78
- | `first` | `first(value)` |
79
- | `last` | `last(value)` |
80
- | `mid` | `(min + max) / 2` |
81
- | `sma` | the `average` bucket, then a moving window over N buckets (`sma:N`, default 5) |
82
- | `ema` | the `average` bucket, then an exponential moving average (`ema:α`, default 0.2) |
181
+ ## History API
83
182
 
84
- A sample for `sma` and `ema` is one row of the series the window runs over: a resolution bucket (its average) when the request names a resolution, a raw row otherwise. So `sma:5` at `resolution=180` is a 15-minute moving average, on the same grid as every other column in the response. An empty bucket keeps its place in the window: the average there is of what the window still holds, and a value leaves it N buckets after it arrived. `ema` carries its last value across an empty bucket instead.
183
+ ### v2 (REST — `/signalk/v2/api/history/`)
184
+
185
+ Registered via `app.registerHistoryApiProvider()`. Supports all aggregate
186
+ methods:
187
+
188
+ | Method | QuestDB mapping |
189
+ | -------------- | ------------------------------------------------------------------------------- |
190
+ | `average` | `avg(value)` |
191
+ | `min` | `min(value)` |
192
+ | `max` | `max(value)` |
193
+ | `first` | `first(value)` |
194
+ | `last` | `last(value)` |
195
+ | `mid` | `(min + max) / 2` |
196
+ | `middle_index` | the middle row of each bucket, by time — a recorded value, not a computed one |
197
+ | `sma` | the `average` bucket, then a moving window over N buckets (`sma:N`, default 5) |
198
+ | `ema` | the `average` bucket, then an exponential moving average (`ema:α`, default 0.2) |
199
+
200
+ A sample for `sma` and `ema` is one row of the series the window runs over: a
201
+ resolution bucket (its average) when the request names a resolution, a raw row
202
+ otherwise. So `sma:5` at `resolution=180` is a 15-minute moving average, on the
203
+ same grid as every other column in the response. An empty bucket keeps its place
204
+ in the window: the average there is of what the window still holds, and a value
205
+ leaves it N buckets after it arrived. `ema` carries its last value across an
206
+ empty bucket instead.
207
+
208
+ `navigation.position` answers `first`, `last` and `middle_index` — each a point
209
+ the vessel was at. Any other method runs `first`, and the column's `method` says
210
+ so. A text path (rows in `signalk_str`) answers `first` and `last`;
211
+ `middle_index` and the moving averages read the numeric table only.
85
212
 
86
213
  Query example:
87
214
 
88
- ```
215
+ ```http
89
216
  GET /signalk/v2/api/history/values?paths=navigation.speedOverGround&duration=PT1H&resolution=60
90
217
  ```
91
218
 
219
+ A `navigation.position` value is a `[longitude, latitude]` pair — GeoJSON order,
220
+ as the History API defines it and as signalk-to-influxdb2 and signalk-parquet
221
+ return it — not the `{latitude, longitude}` object of the data model; a bucket
222
+ without a fix is `null`. The v1 playback API is unaffected: it replays deltas,
223
+ which carry the object.
224
+
225
+ **Limits.** A request may produce at most 1 000 000 sample buckets across all of
226
+ its columns (range ÷ resolution × columns); a finer one is rejected with an
227
+ error that says so — use a coarser resolution or a shorter range. Without a
228
+ resolution a column is its raw rows, capped at 10 000 (50 000 for
229
+ `middle_index`).
230
+
231
+ #### Reading one source
232
+
92
233
  Append `|<sourceRef>` to a path to read one source's rows only (server 2.29+,
93
- [signalk-server#2737](https://github.com/SignalK/signalk-server/pull/2737)).
94
- The same path may appear once per source, giving one column per receiver:
234
+ [signalk-server#2737](https://github.com/SignalK/signalk-server/pull/2737)). The
235
+ same path may appear once per source, giving one column per receiver:
95
236
 
96
- ```
237
+ ```http
97
238
  GET /signalk/v2/api/history/values?paths=navigation.position|gps.main,navigation.position|gps.backup&duration=PT1H
98
239
  ```
99
240
 
100
- Without a sourceRef a path returns all sources mixed, as before.
101
-
102
- A `navigation.position` value is a `[longitude, latitude]` pair — GeoJSON
103
- order, as the History API defines it and as signalk-to-influxdb2 and
104
- signalk-parquet return it — not the `{latitude, longitude}` object of the data
105
- model; a bucket without a fix is `null`. The v1 playback API is unaffected: it
106
- replays deltas, which carry the object.
107
-
108
- > **Breaking change in the v2 values response.** The per-column source is
109
- > reported as `$source`, not `sourceRef`. That is the key
110
- > [signalk-server#2817](https://github.com/SignalK/signalk-server/pull/2817)
111
- > settled on — its own contract test asserts `$source` — and it matches what
112
- > v1 playback has always emitted. It applies to every source-bearing column,
113
- > including the explicit `path|sourceRef` filter that shipped in 2.0.1, so a
114
- > consumer reading `sourceRef` from a v2 response must be updated. The
115
- > REQUEST side is unchanged: `paths=<path>|<sourceRef>` still uses that name.
241
+ Without a sourceRef a path returns all sources mixed. A source-bearing column
242
+ reports its source as `$source` in its `values` entry — the key
243
+ [signalk-server#2817](https://github.com/SignalK/signalk-server/pull/2817)
244
+ settled on, and what v1 playback has always emitted — while the request side
245
+ keeps the name `sourceRef`.
116
246
 
117
247
  #### sourcePolicy=all
118
248
 
@@ -140,19 +270,19 @@ Rows whose source is unset — recorded before the `source` column existed, or
140
270
  from a delta that carried none — form their own trailing column with no
141
271
  `$source` claim.
142
272
 
143
- **Off by default**, behind the plugin setting that allows the policy. Expansion multiplies the work one request can ask for: a path
144
- recorded by four receivers becomes four queries and four columns, which on a
145
- Pi-class host turns a cheap request into an expensive one. The sample-bucket
146
- cap counts expanded columns, so a resolution that is fine unexpanded may be
147
- rejected once expanded — use a coarser resolution or a shorter range.
273
+ **Off by default**, behind the plugin setting that allows the policy. Expansion
274
+ multiplies the work one request can ask for: a path recorded by four receivers
275
+ becomes four queries and four columns, which on a Pi-class host turns a cheap
276
+ request into an expensive one. The sample-bucket cap counts expanded columns, so
277
+ a resolution that is fine unexpanded may be rejected once expanded.
148
278
 
149
- Two ceilings bound the fan-out. A single path expands into a limited number of
150
- columns; if more sources recorded it in range, the first named sources in
151
- sorted order are returned and the rest are dropped, which is reported in the
152
- plugin debug log only. A whole request is bounded too, and one that exceeds
153
- that limit is rejected outright rather than truncated — returning some of the
154
- asked-for series without saying so would be worse than refusing a request that
155
- is too broad. The error names the limit that was hit.
279
+ Two ceilings bound the fan-out, and a request past either is rejected rather
280
+ than truncated — returning some of the asked-for series without saying so would
281
+ be worse than refusing a request that is too broad. A single path may expand
282
+ into at most 16 columns: recorded by more sources in range, it fails the
283
+ request, and the error names the count, the ceiling and the way out (name the
284
+ sources with `path|sourceRef`, or ask for a shorter range). A whole request is
285
+ bounded at 64 columns, with its own error naming the limit.
156
286
 
157
287
  An explicit `path|sourceRef` stays a filter and takes precedence over the
158
288
  policy, matching the upstream contract. Requests that name their sources are
@@ -160,181 +290,20 @@ unaffected by the setting either way.
160
290
 
161
291
  ### v1 (WebSocket playback)
162
292
 
163
- Registered via `app.registerHistoryProvider()`. Supports playback at configurable speed multipliers using chunked reads from QuestDB. Replayed updates carry the recorded sourceRef as `$source`, one update per source, so consumers see the same attribution the live stream had.
164
-
165
- ## REST Endpoints
166
-
167
- All mounted at `/plugins/signalk-questdb/api/`:
168
-
169
- | Method | Path | Description |
170
- | ------ | ---------------------------------------- | ----------------------------------------------------------------------------------------- |
171
- | GET | `/status` | QuestDB health, row counts, active paths |
172
- | GET | `/query?sql=...` | Read-only SQL proxy (DDL/DML blocked) |
173
- | GET | `/paths` | All recorded paths with row counts and time range |
174
- | GET | `/versions` | QuestDB releases from GitHub (for version picker) |
175
- | GET | `/update/check` | Compare running version against latest release |
176
- | POST | `/update/apply` | Pull latest image, recreate container, reconnect |
177
- | POST | `/purge-data` | Remove the QuestDB container and delete all its data (rootless-Podman-safe) |
178
- | GET | `/migration/detect` | Detect InfluxDB (supports `?url=` for remote) |
179
- | POST | `/migration/buckets` | List buckets (2.x) or databases (1.x); credentials in the body, never the query string |
180
- | POST | `/migration/measurements` | List measurements and their field keys in a bucket/database |
181
- | POST | `/migration/contexts` | List the vessels (`context` tag values) a bucket/database holds, and which is this server |
182
- | POST | `/migration/start` | Start an import, or continue a stopped one with `{"resume": true}`; returns immediately |
183
- | GET | `/migration/status` | Progress and state of the current/last import, and any stopped import that can resume |
184
- | POST | `/migration/cancel` | Cancel the running import; any saved position is kept |
185
- | POST | `/migration/discard` | Forget a stopped import's saved position, so the next start begins again |
186
- | GET | `/migration/legacy-rows` | Count rows an import made before 2.1.5 filed under suffixed paths |
187
- | POST | `/migration/legacy-rows/remove` | Rebuild the string table without those rows; recording continues meanwhile |
188
- | GET | `/export?from=...&to=...&format=parquet` | Parquet or CSV export of the `signalk` numeric table (date range required) |
189
- | GET | `/full-export/tables` | List tables exposed by the per-table full-export route |
190
- | GET | `/full-export/:table?from=...&to=...` | Stream a table as Parquet. Optional half-open `[from, to)` range for slicing into shards |
191
-
192
- ### `/full-export/:table` (since 0.4.0)
193
-
194
- Designed for snapshot/backup tooling that needs the full table content but
195
- wants to slice it into kopia-dedup-friendly shards. Allowed tables:
196
- `signalk`, `signalk_str`, `signalk_position`.
197
-
198
- - Both `from` and `to` are **optional but must be set together**: omit both for a full-table export, or pass both as ISO 8601 timestamps for a windowed export. Half-open `[from, to)` interval — no row appears in two adjacent windows.
199
- - Repeated query params (`?from=A&from=B`) and empty strings (`?from=`) are rejected with HTTP 400 — silently downgrading to a full-table export would hide bugs in the caller.
200
- - Output format and compression follow the plugin's `compression` config (LZ4_RAW or ZSTD), same as `/export`.
201
-
202
- ## Migrating from InfluxDB
203
-
204
- The **InfluxDB Migration** section of the config panel copies history out of an
205
- existing InfluxDB into QuestDB. It supports both InfluxDB 1.x (InfluxQL) and
206
- 2.x (Flux).
207
-
208
- 1. **Detect** finds an InfluxDB on `localhost:8086`, or enter a URL for a
209
- remote one (loopback and private-network addresses only).
210
- 2. Select the detected instance, supply credentials — an API token and
211
- organisation for 2.x, or username/password for 1.x if authentication is
212
- enabled — and list its buckets/databases. Credentials are used for the
213
- import only, are never written to the plugin's settings, and travel in the
214
- request body rather than the query string (Signal K logs full request URLs).
215
- 3. Pick a bucket/database and a time range, then **Start import**. Progress is
216
- polled while it runs and the import can be cancelled at any point.
217
-
218
- How the data maps:
219
-
220
- - A measurement with the conventional `value` field becomes the Signal K path
221
- of the same name. So do the typed fields signalk-to-influxdb 1.x writes
222
- (`stringValue`, `boolValue`, `jsonValue`). A measurement with any other named
223
- fields becomes `measurement.field` paths, so two fields cannot overwrite each
224
- other.
225
- - Numbers go to `signalk`, strings and booleans to `signalk_str` (booleans
226
- tagged `value_kind=boolean`). A `jsonValue` is decoded first: an object is
227
- recorded as its scalar leaves, the same as live data.
228
- - Positions go to `signalk_position`, whether stored as a `jsonValue`
229
- (signalk-to-influxdb 1.x) or as a `lat`/`lon` or `latitude`/`longitude` field
230
- pair (signalk-to-influxdb2).
231
- - Both writers tag every point with the **context** of the vessel it belongs
232
- to, so a source that recorded other vessels holds several. The panel lists
233
- them and asks which is yours, and that vessel's history is imported as
234
- `self`; the others are imported under their own contexts, as the history
235
- API serves them, or left out if you untick that. The choice is made for you
236
- when the source says: signalk-to-influxdb2 tags its own vessel's points with
237
- `self=true`, so that vessel is preselected — as is the one matching this
238
- server's identity, or the only one there is. A signalk-to-influxdb 1.x
239
- source carries no such tag, so there the choice is yours whenever it holds
240
- several vessels and none is this server's identity — the usual case, since
241
- the recording server's identity is usually not this server's (a new install
242
- has a new one).
243
- - Rows keep their **original nanosecond timestamps**, so imported history sorts
244
- and aggregates alongside live data.
245
- - History is **streamed**: read in batches and written before more is asked
246
- for, pausing while QuestDB catches up. Memory use stays flat however dense
247
- the source is, so a large import is safe on a small board.
248
- - Every imported row is tagged `source=influxdb-import`, which makes it
249
- distinguishable from live recording — and because the tables deduplicate on
250
- `(ts, path, context, source)`, **re-running the same range overwrites rather
251
- than duplicating**.
252
-
253
- ### Imports made before 2.1.5
254
-
255
- Imports from a signalk-to-influxdb 1.x database made before 2.1.5 filed every
256
- string, boolean and position under a path suffixed with the InfluxDB field
257
- name — `navigation.state.stringValue`, `steering.autopilot.engaged.boolValue`,
258
- `navigation.position.jsonValue` — which nothing reads. Running the import again
259
- writes them under their real paths but cannot remove the old rows: the path is
260
- part of the deduplication key, and QuestDB has no row delete. When such rows
261
- exist, the panel says how many and offers **Remove these rows**, which rebuilds
262
- `signalk_str` without them. Recording continues while it runs; only the swap
263
- of the rebuilt table for the old one holds the writer, for a few seconds, and
264
- rows recorded during the rebuild are carried across. Should recording be fast
265
- enough to fill the writer's buffer during those seconds, the samples it could
266
- not keep are counted and the panel says how many. Run the import again
267
- afterwards.
268
-
269
- ### Resuming an interrupted import
270
-
271
- A large import runs for hours. If it stops part-way — cancelled, failed, or the
272
- Signal K server restarted — the panel shows what was interrupted and offers
273
- **Resume import**, which continues from the saved position instead of starting
274
- again at the first measurement. It needs only the credentials, if InfluxDB asks
275
- for any: the source and range are remembered, credentials never are. Starting
276
- the identical import again the ordinary way continues it too. **Discard saved
277
- position** starts over.
278
-
279
- A position is saved only once QuestDB has been asked for the newest row written
280
- before it and has it — ILP gives no acknowledgment, so the plugin reads the row
281
- back — and once the position is about a minute old. The first keeps a stalled
282
- or restarted QuestDB from letting the position past rows it never took, however
283
- long the stall. The minute covers what a committed row still has to survive: a
284
- crash of either process, and on a power cut the kernel's default write-back
285
- timing for a QuestDB running on its default `nosync` — an assumption, not a
286
- guarantee, which is why an external QuestDB should run with `sync` like the
287
- managed container does (see
288
- [Durability on power loss](#durability-on-power-loss)). The position file is
289
- synced and renamed into place, so a power cut leaves the previous position or
290
- the new one, never a damaged one; should none survive, the import starts again,
291
- which is always safe. The cost is that a resumed import repeats up to a minute
292
- or so of work, which the deduplication above makes harmless. An import shorter
293
- than that saves nothing, and is simply run again.
294
-
295
- Anything that cannot be mapped (a gap, an unsupported value type, a `jsonValue`
296
- that is not valid JSON, a latitude with no matching longitude) is counted in the
297
- run's `skipped` total rather than
298
- being dropped silently.
299
-
300
- ## Configuration
301
-
302
- | Setting | Default | Description |
303
- | -------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
304
- | QuestDB version | `latest` | Docker image tag (dropdown shows stable + pre-releases) |
305
- | Managed container | `true` | Let signalk-container manage QuestDB, or connect to external |
306
- | QuestDB host | `127.0.0.1` | External QuestDB host (only used when managed=false) |
307
- | HTTP port | `9000` | External mode, or the host binding when "Bind to 0.0.0.0" is on |
308
- | ILP port | `9009` | External mode, or the host binding when "Bind to 0.0.0.0" is on |
309
- | PostgreSQL port | `8812` | Host binding for Grafana/psql when "Bind to 0.0.0.0" is on |
310
- | Sampling rate (ms) | `2000` | Default min ms between writes per path (0 = every update) |
311
- | Write batch interval (ms) | `5000` | How often buffered samples are committed — one WAL transaction per table per commit (see Performance) |
312
- | Memory limit | `768m` | Hard cgroup cap on QuestDB container RAM (empty = unlimited) |
313
- | CPU limit (cores) | `1.5` | Max CPU cores QuestDB can use (0 = unlimited) |
314
- | Record own vessel | `true` | Record self context |
315
- | Record AIS targets | `true` | Record other vessels |
316
- | Restore vessels on startup | `false` | Replay each vessel's last recorded position after a restart (see Startup restore) |
317
- | Restore max age (minutes) | `9` | Only replay values recorded within this window |
318
- | QuestDB console webapp | `true` | Serve QuestDB's console in the Signal K admin UI, admin only (see Console webapp) |
319
- | Allow `sourcePolicy=all` | `false` | Let History API callers split a path into one column per source (see History API) |
320
- | Retention (days) | `0` | Auto-delete old partitions (0 = keep forever) |
321
- | Path filter mode | `exclude` | `exclude` matching paths, or `include` only matching paths |
322
- | Path filter paths | _(empty)_ | Glob patterns, one per line (e.g. `notifications.*`); empty = record everything, which is the default |
323
- | Compression codec | `lz4` | On-disk WAL compression: `none`, `lz4`, or `zstd` |
324
- | Compression level | `3` | ZSTD level 1-22 (only when codec is zstd) |
325
- | Container network | `sk-network` | Shared network for QuestDB (only applied when binding to 0.0.0.0) |
326
- | Bind to 0.0.0.0 | `false` | Expose QuestDB's ports on the LAN (see Connectivity below) |
293
+ Registered via `app.registerHistoryProvider()`. Supports playback at
294
+ configurable speed multipliers using chunked reads from QuestDB. Replayed
295
+ updates carry the recorded sourceRef as `$source`, one update per source, so
296
+ consumers see the same attribution the live stream had.
327
297
 
328
298
  ## Console webapp
329
299
 
330
300
  QuestDB ships its own web console — a full SQL workbench with schema browsing,
331
- query history and CSV import. It normally lives on QuestDB's HTTP port (9000),
332
- which is bound to loopback unless you turn on **Bind to 0.0.0.0**, so from any
333
- other device on the boat it is effectively unreachable.
334
-
335
- With **QuestDB console webapp** enabled (the default) the plugin appears in the
336
- Signal K webapp list and serves that console inside the admin UI, with Signal
337
- K's own navigation panel still in place.
301
+ query history and CSV import. With **QuestDB console webapp** enabled (the
302
+ default) the plugin appears in the Signal K webapp list and serves that console
303
+ inside the admin UI, with Signal K's own navigation panel still in place. The
304
+ **[Web Console user guide](doc/web-console.md)** walks through it with
305
+ ready-to-paste queries: speed in knots, temperatures in °C, distance per day,
306
+ track export, and more.
338
307
 
339
308
  **It is admin only.** The console is proxied at
340
309
  `/plugins/signalk-questdb/console/`, and Signal K gives plugin routes an
@@ -351,10 +320,17 @@ someone an admin account.
351
320
  If you would rather not serve it at all, switch the option off and the route is
352
321
  not registered.
353
322
 
354
- > Note this is _safer_ than the alternative it replaces. Reaching the console
355
- > from another device previously meant enabling **Bind to 0.0.0.0**, which
356
- > publishes an unauthenticated SQL endpoint on your network. The webapp route
357
- > requires a Signal K admin session.
323
+ The console is also reachable directly on QuestDB's HTTP port. On a bare-metal
324
+ Signal K with "Bind to 0.0.0.0" off, signalk-container binds it to loopback at
325
+ the address the status card shows (the port is the one it bound, not necessarily
326
+ 9000); from another machine, tunnel that port over SSH. It is 9000 on the host
327
+ only with "Bind to 0.0.0.0" on. In managed mode a containerized Signal K reaches
328
+ QuestDB over the container network, so there is no host port to open unless
329
+ "Bind to 0.0.0.0" is on; in external mode the console is at the configured
330
+ QuestDB host and HTTP port, whatever the deployment. The HTTP port carries
331
+ QuestDB's REST API and ingestion too, with no authentication; enabling "Bind to
332
+ 0.0.0.0" to reach it from the LAN publishes all of that, which is what the
333
+ webapp route exists to avoid.
358
334
 
359
335
  ## Startup restore
360
336
 
@@ -378,22 +354,190 @@ applies to any other target, and the deltas are tagged
378
354
  `$source: signalk-questdb.restore`.
379
355
 
380
356
  Values outside the **Restore max age** window are not replayed at all. The
381
- 9-minute default matches Freeboard's AIS expiry, so a restored target is one that
382
- would still have been on the chart had the server never stopped. Raising it puts
383
- progressively staler positions in front of you — treat the window as a
357
+ 9-minute default matches Freeboard's AIS expiry, so a restored target is one
358
+ that would still have been on the chart had the server never stopped. Raising it
359
+ puts progressively staler positions in front of you — treat the window as a
384
360
  collision-avoidance setting, not a convenience one.
385
361
 
386
362
  Only navigation and identity paths are replayed, so stale tank levels and engine
387
363
  temperatures are not resurrected as though they were current readings. A vessel
388
- with no position in the window is skipped, as is one that has already transmitted
389
- since startup. Restore honours the recording toggles: with **Record AIS targets**
390
- off, no AIS target is restored.
364
+ with no position in the window is skipped, as is one that has already
365
+ transmitted since startup. Restore honours the recording toggles: with **Record
366
+ AIS targets** off, no AIS target is restored.
391
367
 
392
- The config panel reports the outcome once startup finishes: **Vessels Restored** with the count, or **Restore failed** if the replay could not run. Neither appears when the option is off.
368
+ The config panel reports the outcome once startup finishes: **Vessels Restored**
369
+ with the count, or **Restore failed** if the replay could not run. Neither
370
+ appears when the option is off.
393
371
 
394
- ## Connectivity
372
+ ## Migrating from InfluxDB
395
373
 
396
- In **managed mode** the plugin no longer needs `QuestDB host` to be correct for
374
+ The **InfluxDB Migration** section of the config panel copies history out of an
375
+ existing InfluxDB into QuestDB. It supports InfluxDB 1.x (InfluxQL, what
376
+ signalk-to-influxdb wrote) and 2.x (Flux, what signalk-to-influxdb2 writes).
377
+
378
+ ### Running an import
379
+
380
+ 1. **Detect** finds an InfluxDB on `localhost:8086`, or enter its URL —
381
+ `localhost` or a loopback/private-network IP literal; anything else is
382
+ refused.
383
+ 2. Supply credentials — an API token and organisation for 2.x, or
384
+ username/password for 1.x if authentication is enabled — and list the buckets
385
+ (2.x) or databases (1.x). Credentials are used for the import only, are never
386
+ written to the plugin's settings, and travel in the request body rather than
387
+ the query string (Signal K logs full request URLs).
388
+ 3. Pick a bucket. The panel then lists the vessels it holds (**Listing the
389
+ vessels in the bucket…** — on a large bucket this takes a while) and shows
390
+ **Which vessel in the source is this one**: that vessel's history is imported
391
+ as `self`. The choice is made for you when the source says:
392
+ signalk-to-influxdb2 tags its own vessel's points with `self=true`, so that
393
+ vessel is preselected and marked "(recorded as the own vessel)" — as is the
394
+ one matching this server's identity, marked "(this server)", or the only one
395
+ there is. A signalk-to-influxdb 1.x source carries no such tag, so there the
396
+ choice is yours whenever it holds several vessels and none is this server's
397
+ identity — the usual case, since the recording server's identity is usually
398
+ not this one's (a new install has a new one). Tick **Also import the other
399
+ vessels (AIS targets), each under its own context** to bring those along; the
400
+ history API serves them as it does live-recorded targets.
401
+ 4. Pick a time range and **Start import**. Progress is polled while it runs;
402
+ **Cancel** stops it, keeping the saved position (see Resuming).
403
+
404
+ After the import, check the row counts on the status card or in the console
405
+ (`SELECT count() FROM signalk WHERE source = 'influxdb-import'`), make sure this
406
+ plugin is the default history provider (see Getting started), and disable the
407
+ InfluxDB plugin once nothing else needs it — a consumer such as a track viewer
408
+ reads whichever provider is the default, not both.
409
+
410
+ ### How the data maps
411
+
412
+ - A measurement with the conventional `value` field becomes the Signal K path of
413
+ the same name. So do the typed fields signalk-to-influxdb 1.x writes
414
+ (`stringValue`, `boolValue`, `jsonValue`). A measurement with any other named
415
+ fields becomes `measurement.field` paths, so two fields cannot overwrite each
416
+ other.
417
+ - Numbers go to `signalk`, strings and booleans to `signalk_str` (booleans
418
+ tagged `value_kind=boolean`). A `jsonValue` is decoded first: an object is
419
+ recorded as its scalar leaves, the same as live data.
420
+ - Positions go to `signalk_position`, whether stored as a `jsonValue`
421
+ (signalk-to-influxdb 1.x) or as a `lat`/`lon` or `latitude`/`longitude` field
422
+ pair (signalk-to-influxdb2).
423
+ - Both writers tag every point with the **context** of the vessel it belongs to;
424
+ the vessel you picked is imported as `self`, the others under their own
425
+ contexts or left out.
426
+ - Rows keep their **original timestamps**, to the microsecond (InfluxDB stores
427
+ nanoseconds; QuestDB's `TIMESTAMP` holds microseconds), so imported history
428
+ sorts and aggregates alongside live data.
429
+ - History is **streamed**: read in batches and written before more is asked for,
430
+ pausing while QuestDB catches up. Memory use stays flat however dense the
431
+ source is, so a large import is safe on a small board.
432
+ - Every imported row is tagged `source=influxdb-import`, which makes it
433
+ distinguishable from live recording — and because the tables deduplicate on
434
+ `(ts, path, context, source)`, **re-running the same range overwrites rather
435
+ than duplicating**.
436
+ - Anything that cannot be mapped (a gap, an unsupported value type, a
437
+ `jsonValue` that is not valid JSON, a latitude with no matching longitude) is
438
+ counted in the run's `skipped` total rather than being dropped silently.
439
+
440
+ ### Resuming an interrupted import
441
+
442
+ A large import runs for hours. If it stops part-way — cancelled, failed, or the
443
+ Signal K server restarted — the panel shows what was interrupted and offers
444
+ **Resume import**, which continues from the saved position instead of starting
445
+ again at the first measurement. It needs only the credentials, if InfluxDB asks
446
+ for any: the source and range are remembered, credentials never are. Starting
447
+ the identical import again the ordinary way continues it too. **Discard saved
448
+ position** starts over.
449
+
450
+ A position is saved only once QuestDB has been asked for the newest row written
451
+ before it and has it — ILP gives no acknowledgment, so the plugin reads the row
452
+ back — and once the position is about a minute old. The first keeps a stalled or
453
+ restarted QuestDB from letting the position past rows it never took, however
454
+ long the stall. The minute covers what a committed row still has to survive: a
455
+ crash of either process, and on a power cut the kernel's default write-back
456
+ timing for a QuestDB running on its default `nosync` — an assumption, not a
457
+ guarantee, which is why an external QuestDB should run with `sync` like the
458
+ managed container does (see Durability on power loss). The position file is
459
+ synced and renamed into place, so a power cut leaves the previous position or
460
+ the new one, never a damaged one; should none survive, the import starts again,
461
+ which is always safe. A position is a window boundary — the import reads each
462
+ measurement a window at a time, a day of source data by default — so a resumed
463
+ import repeats the window it was in plus up to a minute, which the deduplication
464
+ above makes harmless. An import shorter than a window saves nothing, and is
465
+ simply run again.
466
+
467
+ ### Imports made before 2.1.5
468
+
469
+ Imports from a signalk-to-influxdb 1.x database made before 2.1.5 filed every
470
+ string, boolean and position under a path suffixed with the InfluxDB field name
471
+ — `navigation.state.stringValue`, `steering.autopilot.engaged.boolValue`,
472
+ `navigation.position.jsonValue` — which nothing reads. Running the import again
473
+ writes them under their real paths but cannot remove the old rows: the path is
474
+ part of the deduplication key, and QuestDB has no row delete. When such rows
475
+ exist, the panel says how many and offers **Remove these rows**, which rebuilds
476
+ `signalk_str` without them. Recording continues while it runs; only the swap of
477
+ the rebuilt table for the old one holds the writer, for a few seconds, and rows
478
+ recorded during the rebuild are carried across. Should recording be fast enough
479
+ to fill the writer's buffer during those seconds, the samples it could not keep
480
+ are counted and the panel says how many. Run the import again afterwards.
481
+
482
+ ## Export and backup
483
+
484
+ **Data Export** in the panel, and
485
+ `GET /plugins/signalk-questdb/api/export?from=…&to=…&format=parquet|csv`,
486
+ download the `signalk` numeric table for a date range (both bounds required) as
487
+ Parquet — QuestDB's native export, compressed with the configured codec — or
488
+ CSV.
489
+
490
+ `GET /plugins/signalk-questdb/api/full-export/:table?from=…&to=…` streams one
491
+ whole table as Parquet, for snapshot and backup tooling that wants the full
492
+ content sliced into dedup-friendly shards (kopia, for instance). Tables:
493
+ `signalk`, `signalk_str`, `signalk_position` (`/full-export/tables` lists them).
494
+
495
+ - `from` and `to` are optional but must be set together: omit both for the whole
496
+ table, or pass both as ISO 8601 timestamps for a half-open `[from, to)` window
497
+ — no row appears in two adjacent windows.
498
+ - Repeated query parameters (`?from=A&from=B`) and empty strings (`?from=`) are
499
+ rejected with HTTP 400; silently downgrading to a full-table export would hide
500
+ bugs in the caller.
501
+ - Format and compression follow the plugin's compression setting (LZ4_RAW or
502
+ ZSTD), the same as `/export`.
503
+
504
+ ## REST endpoints
505
+
506
+ All under `/plugins/signalk-questdb/api/`; the console is mounted beside them at
507
+ `/plugins/signalk-questdb/console/`. Plugin routes are admin-only by Signal K's
508
+ default.
509
+
510
+ | Method | Path | Description |
511
+ | ------ | ---------------------------------------- | ----------------------------------------------------------------------------------------- |
512
+ | GET | `/status` | QuestDB health, row counts, active paths, restore outcome, WAL state, unstorable paths |
513
+ | GET | `/query?sql=...` | Read-only SQL proxy (DDL/DML blocked) |
514
+ | GET | `/paths` | All recorded paths with row counts and time range |
515
+ | GET | `/versions` | QuestDB releases from GitHub (for the version picker) |
516
+ | GET | `/update/check` | Compare the running version against the latest release |
517
+ | POST | `/update/apply` | Pull the latest image, recreate the container, reconnect |
518
+ | GET | `/wal-diagnosis` | Suspended tables with the engine's reason and a bounded skip plan (see WAL suspension) |
519
+ | POST | `/resume-wal` | Lossless `RESUME WAL` of the suspended tables |
520
+ | POST | `/resume-wal/skip` | Guided skip of the unreadable segment — explicit, quantified, and only on request |
521
+ | POST | `/purge-data` | Remove the QuestDB container and delete all its data (rootless-Podman-safe) |
522
+ | GET | `/migration/detect` | Detect InfluxDB (supports `?url=` for a remote one) |
523
+ | POST | `/migration/buckets` | List buckets (2.x) or databases (1.x); credentials in the body, never the query string |
524
+ | POST | `/migration/measurements` | List measurements and their field keys in a bucket/database |
525
+ | POST | `/migration/contexts` | List the vessels a bucket/database holds, and which is this server's |
526
+ | POST | `/migration/start` | Start an import, or continue a stopped one with `{"resume": true}`; returns immediately |
527
+ | GET | `/migration/status` | Progress and state of the current/last import, and any stopped import that can resume |
528
+ | POST | `/migration/cancel` | Cancel the running import; any saved position is kept |
529
+ | POST | `/migration/discard` | Forget a stopped import's saved position, so the next start begins again |
530
+ | GET | `/migration/legacy-rows` | Count rows an import made before 2.1.5 filed under suffixed paths |
531
+ | POST | `/migration/legacy-rows/remove` | Rebuild the string table without those rows; recording continues meanwhile |
532
+ | GET | `/export?from=...&to=...&format=parquet` | Parquet or CSV export of the `signalk` numeric table (date range required) |
533
+ | GET | `/full-export/tables` | List the tables the full-export route serves |
534
+ | GET | `/full-export/:table?from=...&to=...` | Stream a table as Parquet; optional half-open `[from, to)` window for slicing into shards |
535
+
536
+ ## Deployment
537
+
538
+ ### Connectivity
539
+
540
+ In **managed mode** the plugin does not need `QuestDB host` to be correct for
397
541
  your deployment — signalk-container resolves the right address automatically,
398
542
  whether Signal K runs on bare metal or is itself containerized:
399
543
 
@@ -405,16 +549,44 @@ whether Signal K runs on bare metal or is itself containerized:
405
549
  companion [signalk-grafana](https://github.com/dirkwa/signalk-grafana) plugin
406
550
  still reaches it by container DNS. This is the recommended setup and fixes
407
551
  connectivity for Signal K in a container.
408
- - **Bind to 0.0.0.0 = on.** QuestDB's HTTP/ILP/PostgreSQL ports are published on
409
- all interfaces using the configured port numbers, on the shared
410
- `Container network`. Enable this only to reach QuestDB from another machine or
411
- from a Grafana running in a separate Docker instance. When Signal K itself is
412
- containerized it reaches the published ports via `host.containers.internal`.
552
+ - **Bind to 0.0.0.0 = on.** QuestDB stays attached to the shared
553
+ `Container network`; the setting publishes its HTTP/ILP/PostgreSQL ports on
554
+ all host interfaces, using the configured port numbers. Enable this only to
555
+ reach QuestDB from another machine or from a Grafana running in a separate
556
+ Docker instance. When Signal K itself is containerized, the plugin probes
557
+ which address reaches the published ports — its own loopback first, then the
558
+ runtime's `host.containers.internal` gateway — and uses the one QuestDB
559
+ answers on.
413
560
 
414
- In **external mode** (`Managed container` off) the plugin connects to the
415
- QuestDB you point it at via `QuestDB host` + the HTTP/ILP ports.
561
+ In **external mode** (`Manage QuestDB container` off) the plugin connects to the
562
+ QuestDB you point it at via `QuestDB host` and the HTTP/ILP ports.
416
563
 
417
- ## Durability on power loss
564
+ ### Data storage
565
+
566
+ QuestDB's data root is the plugin's own data directory,
567
+ `<config>/plugin-config-data/signalk-questdb`, whatever the deployment. Data
568
+ survives container restarts, image upgrades, and plugin disable/enable cycles.
569
+ How it reaches the container differs:
570
+
571
+ - **Bare-metal Signal K**: that directory, mounted at `/var/lib/questdb`.
572
+ - **Signal K in a container with its config directory bind-mounted**: the exact
573
+ host path of that directory, mounted at `/var/lib/questdb`.
574
+ - **Signal K in a container with its config directory on a named volume**: the
575
+ volume cannot be mounted from a subdirectory, so it is mounted whole at
576
+ `/signalk-vols/<volume>` and QuestDB is pointed at the plugin's directory
577
+ inside it (`QUESTDB_DATA_DIR`). A database found at the volume's root — where
578
+ a whole-volume mount put it — is moved into the plugin's directory on start;
579
+ both are the same volume, so nothing is copied. A purge deletes the directory
580
+ from the Signal K process, since the runtime cannot mount the volume by Signal
581
+ K's path; where QuestDB's user owns it and the Signal K user cannot delete it,
582
+ the purge says so and the directory is deleted by hand.
583
+
584
+ QuestDB's entrypoint makes its data root its own on start, so the plugin keeps
585
+ the one file it writes alongside — the import checkpoint,
586
+ `signalk-questdb.influx-import-checkpoint.json` — next to the directory rather
587
+ than in it. A purge removes it, whether or not the data could be deleted.
588
+
589
+ ### Durability on power loss
418
590
 
419
591
  The managed container runs QuestDB with `cairo.commit.mode=sync`.
420
592
 
@@ -422,80 +594,120 @@ QuestDB's default is `nosync`: nothing on the ingest path is ever fsynced, and
422
594
  durability is left to the OS page cache. On a power cut an arbitrary subset of
423
595
  recent writes survives — including the case where a commit record reaches disk
424
596
  while the row data it describes does not. QuestDB then fails while _opening_
425
- that table's partition, which suspends the table permanently: neither a
426
- lossless `RESUME WAL` nor a bounded `RESUME WAL FROM TXN` skip can get past it,
427
- because the failure happens before any transaction is read. Repair means
428
- hand-patching the commit record.
597
+ that table's partition, which suspends the table permanently: neither a lossless
598
+ `RESUME WAL` nor a bounded `RESUME WAL FROM TXN` skip can get past it, because
599
+ the failure happens before any transaction is read. Repair means hand-patching
600
+ the commit record.
429
601
 
430
602
  With `sync`, partition columns are fsynced before the commit record is written
431
- and WAL segments are fsynced on commit, so the stored state can never claim
432
- data that isn't durable. The cost is a few fsyncs per commit interval — writes
433
- are already batched (see `ilpFlushIntervalMs`), so at boat data rates it is
603
+ and WAL segments are fsynced on commit, so the stored state can never claim data
604
+ that isn't durable. The cost is a few fsyncs per commit interval — writes are
605
+ already batched (see **Write batch interval**), so at boat data rates it is
434
606
  negligible against losing a table.
435
607
 
436
608
  **Running an external QuestDB?** Set this yourself in `server.conf`:
437
609
 
438
- ```
610
+ ```ini
439
611
  cairo.commit.mode=sync
440
612
  ```
441
613
 
442
- ## QuestDB Web Console
614
+ ### WAL suspension
615
+
616
+ QuestDB ingestion is two-stage: the sequencer durably commits every batch (the
617
+ writer sees success), then a background job applies the transactions to the
618
+ partitions. When that job hits an error it **suspends the table** — the
619
+ sequencer keeps accepting rows, nothing is applied, queries serve stale data,
620
+ and no client-visible error is raised anywhere. Left alone, recording stalls
621
+ silently for as long as nobody looks.
622
+
623
+ The plugin watches for this. A suspended table of its own (in external mode,
624
+ only the three tables above — never someone else's) is logged loudly, raised as
625
+ the Signal K notification `notifications.signalk-questdb.walSuspended`, and
626
+ shown on the status card as **WAL suspended**. Two causes need two remedies:
627
+
628
+ - **Transient** — disk full since cleared, out of memory, file-descriptor
629
+ exhaustion: a plain `RESUME WAL` replays every pending transaction,
630
+ losslessly. The plugin does this by itself, once per stall point; the panel's
631
+ **Resume** button does the same on request, and the banner clears as the
632
+ backlog drains.
633
+ - **Unreadable segment** — an unclean container stop mid-write corrupts the
634
+ in-flight segment's files, and every apply attempt fails on them within
635
+ milliseconds, so a plain resume re-suspends at once, forever. The only way
636
+ forward is `RESUME WAL FROM TXN` past the unreadable data, which loses what it
637
+ skips. The plugin prepares that but never applies it on its own:
638
+ **/wal-diagnosis** scrapes the engine log for the real reason (the
639
+ `wal_tables()` error fields often carry none, and a container recreate wipes
640
+ the engine's memory of it) and computes the minimal skip — the first
641
+ transaction of the segment after the stuck one — with the number of
642
+ transactions and the wall-clock window of ingestion it would drop. The panel
643
+ shows those numbers and offers **Skip**; if the stuck segment is the newest
644
+ one, skipping drops the entire pending backlog, and the panel says so.
645
+
646
+ ### Performance (Pi and other low-power hosts)
647
+
648
+ - **Default sampling rate** of 2000 ms limits each path to one write per two
649
+ seconds, keeping write volume modest on busy NMEA 2000 buses; per-path
650
+ overrides allow faster rates for critical paths
651
+ (`{ "environment.wind.*": 200 }`) while slow-changing ones stay throttled.
652
+ - **Resource caps** of 768 MB RAM and 1.5 CPU cores (cgroup limits via
653
+ signalk-container) keep QuestDB from squeezing co-resident containers like
654
+ Grafana, mayara, or signalk-backup. The JVM sizes its heap to a fraction of
655
+ the memory cap, so total footprint (heap plus off-heap) is bounded. Set the
656
+ memory limit to empty or the CPU limit to `0` to lift the cap on roomier
657
+ hosts.
658
+ - **Worker tuning** cuts idle CPU. QuestDB sizes its worker pools from the host
659
+ core count and busy-polls before sleeping — sensible on a server, wasteful on
660
+ a mostly idle boat database. The plugin pins its worker pools down and quiets
661
+ QuestDB's per-commit logging, keeping the messages that matter. One caveat
662
+ when upgrading the QuestDB image: an unrecognised setting is ignored silently,
663
+ so a property renamed between releases looks exactly like one that worked.
664
+ - **Batching.** Each commit is one WAL transaction per table, and with
665
+ deduplicated tables the apply cost of a transaction grows with partition size
666
+ — frequent tiny commits eventually outpace what a Pi can apply and recording
667
+ stalls. Bigger batches keep the WAL healthy; the cost is that at most one
668
+ interval of buffered samples is lost on a hard crash.
443
669
 
444
- QuestDB ships a web console (SQL editor + import UI) on its HTTP port. See the
445
- **[Web Console user guide](doc/web-console.md)** for an end-user walkthrough
446
- with ready-to-paste sample queries (speed in knots, temperatures in °C,
447
- distance per day, track export, and more). On the Signal K host it is at:
670
+ The console's Monitoring view flags any table whose 90th-percentile WAL
671
+ transaction stays under QuestDB's recommended batch size (100 rows) as **"Small
672
+ transactions — consider batching"**. `signalk_position` triggers this
673
+ structurally: it holds exactly one path, so its share of every commit is just
674
+ the handful of GPS fixes since the last one, while the other tables spread
675
+ hundreds of paths across each transaction. At vessel data rates the alert is
676
+ cosmetic; the numbers that matter are on the same page — **Write Amplification**
677
+ near 1x, **Pending Rows** 0 and **Transaction Lag** 0 mean the WAL apply is
678
+ healthy. Clearing the alert for the position table would need roughly 100 fixes
679
+ per commit, a batch window in the minutes; the trade-off is up to one interval
680
+ of buffered data lost on a hard crash, and history that is that much less live.
681
+
682
+ ### Grafana
683
+
684
+ Connect Grafana to QuestDB through the PostgreSQL data source (user `admin`,
685
+ password `quest`, database `qdb`).
448
686
 
449
- ```
450
- http://localhost:9000
451
- ```
687
+ - The companion [signalk-grafana](https://github.com/dirkwa/signalk-grafana)
688
+ plugin wires this up for you: it runs Grafana as a managed container and
689
+ reaches QuestDB by container DNS on the shared `sk-network`, so no port needs
690
+ publishing.
691
+ - A Grafana **on the Signal K host** can reach the PostgreSQL port without
692
+ binding: with "Bind to 0.0.0.0" off, signalk-container binds it to loopback at
693
+ a host port of its choosing — `podman port <container>` shows which (the
694
+ container is `signalk-questdb` behind the `sk-` prefix). With "Bind to
695
+ 0.0.0.0" on it is the configured port, 8812 by default.
696
+ - A Grafana in a **separate Docker instance or on another machine** needs "Bind
697
+ to 0.0.0.0" and the host's LAN address (`192.168.0.122:8812`, say). That
698
+ publishes QuestDB's ports to your entire network; only enable it behind a
699
+ firewall you trust.
452
700
 
453
- By default ("Bind to 0.0.0.0" off) that port is bound to loopback only, so it
454
- is **not** reachable from another machine. To open the console from your
455
- laptop, either tunnel over SSH:
701
+ Example query:
456
702
 
703
+ ```sql
704
+ SELECT ts AS time, avg(value) AS sog
705
+ FROM signalk
706
+ WHERE path = 'navigation.speedOverGround'
707
+ AND context = 'self'
708
+ AND ts BETWEEN $__timeFrom() AND $__timeTo()
709
+ SAMPLE BY $__interval
457
710
  ```
458
- ssh -L 9000:127.0.0.1:9000 <user>@<signalk-host>
459
- ```
460
-
461
- then browse to `http://localhost:9000`, or enable **"Bind to 0.0.0.0"** in the
462
- plugin config and use `http://<signalk-host-ip>:9000` (this exposes QuestDB to
463
- your network — see the warning under Grafana Integration).
464
-
465
- ### The "Small transactions — consider batching" alert
466
-
467
- The console's Monitoring view flags any table whose 90th-percentile WAL
468
- transaction stays under QuestDB's recommended batch size (100 rows).
469
- `signalk_position` triggers this **structurally**: the plugin commits its
470
- write buffer every "Write batch interval", each commit is one WAL transaction
471
- per table, and the position table holds exactly one path — so its share of
472
- every commit is just the handful of GPS fixes since the last one. The other
473
- tables spread hundreds of paths across each transaction and are rarely
474
- flagged.
475
-
476
- At vessel data rates this alert is cosmetic. The numbers that actually matter
477
- are on the same page: **Write Amplification** near 1x, **Pending Rows** 0, and
478
- **Transaction Lag** 0 mean the WAL apply is perfectly healthy — QuestDB merges
479
- many small transactions per apply cycle anyway.
480
-
481
- If you want bigger transactions regardless, raise **"Write batch interval"**
482
- in the plugin config. Clearing the alert for the position table needs roughly
483
- 100 fixes per commit — a batch window in the minutes, not seconds (the exact
484
- numbers depend on your sampling rate; the config panel states the current
485
- bounds). The trade-off is honest: up to one batch interval of buffered data is
486
- lost on a hard crash (a clean Signal K shutdown flushes first), and live-ish
487
- history queries see new data that much later.
488
-
489
- ## Performance (Pi / Low-Power Devices)
490
-
491
- The plugin is optimized for Raspberry Pi and similar low-power devices:
492
-
493
- - **Default sampling rate** of 2000ms limits each path to 1 write per 2 seconds, keeping write volume modest on busy NMEA 2000 buses
494
- - **Resource caps** of 768 MB RAM and 1.5 CPU cores (cgroup limits via signalk-container) keep QuestDB from squeezing co-resident containers like Grafana, mayara, or signalk-backup. The JVM auto-sizes its heap to a fraction of the memory cap, so total footprint (heap + off-heap) is bounded
495
- - **QuestDB worker tuning** cuts idle CPU on low-power hosts. QuestDB sizes its worker pools from the host core count and busy-polls before sleeping — sensible on a server, wasteful on a mostly idle boat database, where threads burn CPU waiting for work that never arrives. The plugin pins its worker pools down and quiets QuestDB's per-commit logging, keeping the messages that matter. One caveat worth knowing when upgrading the QuestDB image: an unrecognised setting is ignored silently, so a property renamed between releases looks exactly like one that worked
496
- - **ILP batching** commits every 5s (configurable via "Write batch interval"; a large backlog of buffered rows commits early). Each commit is one WAL transaction per table, and with deduplicated tables the apply cost of a transaction grows with partition size — frequent tiny commits eventually outpace what a Pi can apply and recording stalls. Bigger batches keep the WAL healthy; the cost is that at most one interval of buffered samples is lost on a hard crash
497
- - Per-path overrides allow faster rates for critical paths (e.g. `{ "environment.wind.*": 200 }`) while keeping slow-changing paths throttled
498
- - Set the memory limit to empty or CPU limit to `0` to disable the cap entirely on roomier hosts
499
711
 
500
712
  ## Troubleshooting
501
713
 
@@ -503,9 +715,9 @@ The plugin is optimized for Raspberry Pi and similar low-power devices:
503
715
 
504
716
  Right after installing or updating the plugin, the admin UI can show
505
717
  `Module "signalk-questdb" is not available. Make sure the webapp is installed.`
506
- It is installed — the browser tab is still running the admin UI it loaded
507
- before the update, which looks for the panel bundle it knew then. A hard
508
- refresh of the tab (Shift-Reload) fixes it.
718
+ It is installed — the browser tab is still running the admin UI it loaded before
719
+ the update, which looks for the panel bundle it knew then. A hard refresh of the
720
+ tab (Shift-Reload) fixes it.
509
721
 
510
722
  ### "Cannot apply a change: the QuestDB container cannot be stopped or removed"
511
723
 
@@ -514,13 +726,13 @@ image tag, a network or resource setting, a raised file-descriptor limit — but
514
726
  the container runtime refused to stop or remove the running container. The
515
727
  recreate is **deferred, not abandoned**: QuestDB keeps running and keeps
516
728
  recording, on its **previous** configuration. The plugin reports the error
517
- rather than a "Recording" status, because the change you asked for has not
518
- been applied and reporting success would be a lie.
729
+ rather than a "Recording" status, because the change you asked for has not been
730
+ applied and reporting success would be a lie.
519
731
 
520
732
  The usual cause on rootless Podman is an orphaned user namespace left behind
521
- when the Podman service restarted underneath a running container. The error
522
- text carries the specific remedy; the general one is to clear the stale
523
- namespace and remove the container so the plugin can recreate it:
733
+ when the Podman service restarted underneath a running container. The error text
734
+ carries the specific remedy; the general one is to clear the stale namespace and
735
+ remove the container so the plugin can recreate it:
524
736
 
525
737
  ```bash
526
738
  podman system migrate # re-attaches orphaned user namespaces
@@ -536,24 +748,24 @@ Look the name up rather than assuming it: the plugin's container is
536
748
  `signalk-questdb` behind a prefix that defaults to `sk-` but is configurable
537
749
  (`SIGNALK_CONTAINER_NAMESPACE`), so it is not always `sk-signalk-questdb`.
538
750
 
539
- Then disable and re-enable the plugin, or restart Signal K. The plugin
540
- recreates the container with the settings that were pending. Recorded data
541
- lives in a bind mount under Signal K's data directory, not inside the
542
- container, so removing the container does not touch it.
751
+ Then disable and re-enable the plugin, or restart Signal K. The plugin recreates
752
+ the container with the settings that were pending. Recorded data lives under
753
+ Signal K's data directory, not inside the container (see Data storage), so
754
+ removing the container does not touch it.
543
755
 
544
756
  **Do not** mask `podman.socket` to work around this. That socket is how the
545
757
  plugin reaches Podman at all, so masking it turns a deferred recreate into a
546
- container the plugin cannot manage in any way. (`podman.service` is the unit
547
- the socket activates, and masking that has the same effect.) If the socket
548
- itself is unhealthy, restart it rather than masking it:
758
+ container the plugin cannot manage in any way. (`podman.service` is the unit the
759
+ socket activates, and masking that has the same effect.) If the socket itself is
760
+ unhealthy, restart it rather than masking it:
549
761
 
550
762
  ```bash
551
763
  systemctl --user restart podman.socket
552
764
  ```
553
765
 
554
- This error is reported by signalk-container 1.29.0 or later. On older
555
- versions the wedge still happens, but appears only as a repeating drift
556
- message in the Signal K server log with no plugin error to go with it.
766
+ This error is reported with signalk-container 1.29.0 or later. On older versions
767
+ the wedge still happens, but appears only as a repeating drift message in the
768
+ Signal K server log with no plugin error to go with it.
557
769
 
558
770
  ### "QuestDB keeps dropping the write connection — the container may be unhealthy or out of memory"
559
771
 
@@ -572,12 +784,12 @@ cat /sys/fs/cgroup/cgroup.controllers
572
784
  # memory ABSENT -> the cap is silently dropped (see A)
573
785
  ```
574
786
 
575
- When Signal K's containers run under rootless Podman, the kernel only enforces
576
- a resource limit whose cgroup controller has been delegated to the user
577
- session. Many distributions delegate `cpu`, `cpuset`, `io`, and `pids` by
578
- default but **not** `memory`. signalk-container drops a limit whose controller
579
- is missing rather than failing the container, so a configured cap can silently
580
- have no effect.
787
+ When Signal K's containers run under rootless Podman, the kernel only enforces a
788
+ resource limit whose cgroup controller has been delegated to the user session.
789
+ Many distributions delegate `cpu`, `cpuset`, `io`, and `pids` by default but
790
+ **not** `memory`. signalk-container drops a limit whose controller is missing
791
+ rather than failing the container, so a configured cap can silently have no
792
+ effect.
581
793
 
582
794
  **A. `memory` not delegated — the cap is silently dropped.** QuestDB grows
583
795
  without bound and the host kernel's OOM killer eventually kills it under
@@ -612,10 +824,12 @@ After the session restart, recreate the QuestDB container so the cap applies.
612
824
  **B. `memory` is delegated — the 768 MB cap is enforced but too tight.** As the
613
825
  database grows, QuestDB's peak memory (JVM heap plus off-heap memory-mapped
614
826
  files, which spike during out-of-order merges) exceeds 768 MB and the cgroup
615
- OOM-kills it. Raise **Memory limit** in the plugin config (e.g. `1g` or `1.5g`,
616
- or empty to remove the cap on a roomier host), which recreates the container.
827
+ OOM-kills it. Raise **QuestDB memory limit** in the plugin config (e.g. `1g` or
828
+ `1.5g`, or empty to remove the cap on a roomier host), which recreates the
829
+ container.
617
830
 
618
- **Confirm the diagnosis:**
831
+ **Confirm the diagnosis** (substitute the container's name if the prefix is not
832
+ `sk-`):
619
833
 
620
834
  ```bash
621
835
  podman inspect sk-signalk-questdb \
@@ -628,23 +842,22 @@ podman events --since 2h --stream=false --filter container=sk-signalk-questdb \
628
842
 
629
843
  ### History queries slow or timing out / "out-of-memory" errors with free RAM
630
844
 
631
- QuestDB memory-maps every partition column file and every pending WAL segment
632
- it touches. The Linux kernel caps how many memory mappings one process may
633
- hold (`vm.max_map_count`), and the stock value on Debian, Ubuntu ≤ 22.04, and
634
- RHEL (65530) is far below what QuestDB recommends (1048576). A fresh database
635
- fits easily; months of daily partitions across the three tables — plus a
636
- segment backlog if a table's WAL is suspended — can exhaust the limit. When
637
- that happens `mmap` fails with out-of-memory errors (errno 12) **even though
638
- plenty of RAM is free**: queries error out or crawl, and the WAL apply job can
639
- suspend a table.
845
+ QuestDB memory-maps every partition column file and every pending WAL segment it
846
+ touches. The Linux kernel caps how many memory mappings one process may hold
847
+ (`vm.max_map_count`), and the stock value on Debian, Ubuntu ≤ 22.04, and RHEL
848
+ (65530) is far below what QuestDB recommends (1048576). A fresh database fits
849
+ easily; months of daily partitions across the three tables — plus a segment
850
+ backlog if a table's WAL is suspended — can exhaust the limit. When that happens
851
+ `mmap` fails with out-of-memory errors (errno 12) **even though plenty of RAM is
852
+ free**: queries error out or crawl, and the WAL apply job can suspend a table.
640
853
 
641
854
  The QuestDB Web Console shows the same warning
642
- (`vm.max_map_count limit is too low`), the plugin's config panel shows a
643
- banner, and the server log gets a warning at plugin startup. Fix it in a
644
- shell **on the host machine itself** — not inside the QuestDB container via
645
- `podman exec`: the limit is kernel-global, a container cannot change it, and
646
- the QuestDB image has neither `sudo` nor `/etc/sysctl.d`. Being kernel-global
647
- also means this works no matter how Signal K itself is deployed:
855
+ (`vm.max_map_count limit is too low`), the plugin's config panel shows a banner,
856
+ and the server log gets a warning at plugin startup. Fix it in a shell **on the
857
+ host machine itself** — not inside the QuestDB container via `podman exec`: the
858
+ limit is kernel-global, a container cannot change it, and the QuestDB image has
859
+ neither `sudo` nor `/etc/sysctl.d`. Being kernel-global also means this works no
860
+ matter how Signal K itself is deployed:
648
861
 
649
862
  ```bash
650
863
  echo 'vm.max_map_count=1048576' | sudo tee /etc/sysctl.d/99-questdb.conf
@@ -657,47 +870,46 @@ for background.
657
870
 
658
871
  ### QuestDB container never starts on macOS (podman machine)
659
872
 
660
- On macOS the QuestDB container is created but may never start. What you
661
- see depends on the signalk-container version: with signalk-container 1.26
662
- or later, the manager names the problem outright (an open-files limit the
663
- host refuses) and starts the container on the runtime's default limits
664
- instead, with this plugin's config panel showing a **"request rejected by
665
- the host"** banner — the remediation below applies unchanged. Older
666
- versions never start the container at all and show either
667
- a misleading **"Permission denied"** (the runtime's error text contains
668
- "operation not permitted") or only a generic **"Unexpected error. See logs
669
- for details."** — and the container log view stays empty, because a
873
+ On macOS the QuestDB container is created but may never start. What you see
874
+ depends on the signalk-container version: with signalk-container 1.26 or later,
875
+ the manager names the problem outright (an open-files limit the host refuses)
876
+ and starts the container on the runtime's default limits instead, with this
877
+ plugin's config panel showing a **"request rejected by the host"** banner — the
878
+ remediation below applies unchanged. Older versions never start the container at
879
+ all and show either a misleading **"Permission denied"** (the runtime's error
880
+ text contains "operation not permitted") or only a generic **"Unexpected error.
881
+ See logs for details."** — and the container log view stays empty, because a
670
882
  container that never started has no logs.
671
883
 
672
- The cause is the plugin's open-files request. On macOS, Signal K runs on the
673
- Mac while podman runs inside a Fedora CoreOS VM ("podman machine"). The
674
- plugin asks for 1048576 open files, and signalk-container normally clamps
675
- that request to what the host can grant — but from macOS it cannot read the
676
- VM's limits, so the full request reaches the VM, exceeds its default hard
677
- limit (524288), and the OCI runtime refuses to start the container. On
678
- current Fedora CoreOS the VM's `vm.max_map_count` already meets QuestDB's
679
- 1048576, so usually only the file-descriptor limit needs raising — but an
680
- older machine image may sit lower, so check it first:
884
+ The cause is the plugin's open-files request. On macOS, Signal K runs on the Mac
885
+ while podman runs inside a Fedora CoreOS VM ("podman machine"). The plugin asks
886
+ for 1048576 open files, and signalk-container normally clamps that request to
887
+ what the host can grant — but from macOS it cannot read the VM's limits, so the
888
+ full request reaches the VM, exceeds its default hard limit (524288), and the
889
+ OCI runtime refuses to start the container. On current Fedora CoreOS the VM's
890
+ `vm.max_map_count` already meets QuestDB's 1048576, so usually only the
891
+ file-descriptor limit needs raising — but an older machine image may sit lower,
892
+ so check it first:
681
893
 
682
894
  ```bash
683
895
  podman machine ssh -- sysctl -n vm.max_map_count
684
896
  ```
685
897
 
686
- If that prints less than 1048576, raise it inside the VM before relying on
687
- the file-descriptor fix alone (`podman machine ssh`, then
898
+ If that prints less than 1048576, raise it inside the VM before relying on the
899
+ file-descriptor fix alone (`podman machine ssh`, then
688
900
  `sudo tee /etc/sysctl.d/99-signalk-questdb.conf <<< 'vm.max_map_count=1048576'`
689
901
  and `sudo sysctl --system`).
690
902
 
691
- Raise it inside the VM. From a macOS terminal:
903
+ Raise the file-descriptor limit inside the VM. From a macOS terminal:
692
904
 
693
905
  ```bash
694
906
  podman machine ssh
695
907
  ```
696
908
 
697
- Inside the VM, add a systemd drop-in for every user manager (the
698
- `user@.service` template covers each user instance, including the machine's
699
- `core` user, whose session runs rootless containers) and the same drop-in
700
- for `podman.service` to cover the rootful connection:
909
+ Inside the VM, add a systemd drop-in for every user manager (the `user@.service`
910
+ template covers each user instance, including the machine's `core` user, whose
911
+ session runs rootless containers) and the same drop-in for `podman.service` to
912
+ cover the rootful connection:
701
913
 
702
914
  ```bash
703
915
  sudo mkdir -p /etc/systemd/system/user@.service.d /etc/systemd/system/podman.service.d
@@ -722,105 +934,29 @@ and verify the new limit is grantable:
722
934
  podman run --rm --ulimit nofile=1048576:1048576 docker.io/library/alpine sh -c 'ulimit -n -H'
723
935
  ```
724
936
 
725
- This should print `1048576`. Finally, remove the half-created container so it
726
- is recreated with the full limit — the limit is set at create time, so a
727
- restart is not enough:
937
+ This should print `1048576`. Finally, remove the half-created container so it is
938
+ recreated with the full limit — the limit is set at create time, so a restart is
939
+ not enough:
728
940
 
729
941
  ```bash
730
942
  podman rm -f sk-signalk-questdb
731
943
  ```
732
944
 
733
- then restart Signal K — on the next plugin start the container is recreated
734
- with the full limit. (The container manager's **Start** button only starts an
945
+ then restart Signal K — on the next plugin start the container is recreated with
946
+ the full limit. (The container manager's **Start** button only starts an
735
947
  existing container, so it cannot replace this step.)
736
948
 
737
- The verify and `rm` commands run on podman's default connection — normally
738
- the rootless one the plugin uses. Rootless and rootful connections keep
739
- separate container stores, so on a setup with a non-default connection add
740
- the same `--connection <name>` (list them with
741
- `podman system connection list`) to both commands, so they hit the store
742
- where `sk-signalk-questdb` actually lives (`podman ps -a` shows it).
743
-
744
- ## History API Provider
745
-
746
- QuestDB registers as a Signal K v2 History API provider. Which registered
747
- provider answers `/signalk/v2/api/history/` by default is the operator's
748
- choice: pick it once under **Data → Preferences → Default History Provider**
749
- (server ≥ 2.31); the server persists it as `historyApi.defaultProvider` in
750
- `settings.json`, and it survives restarts and plugin load order. If no default
751
- is configured, the server uses whichever provider registers first.
752
-
753
- Versions 2.0.0 and earlier asked the server to make QuestDB the default on
754
- every start. That is gone as of 2.0.1 — it could silently override a default
755
- you had chosen, and on servers with security enabled it never worked at all.
756
-
757
- ## Data Storage
758
-
759
- QuestDB's data root is the plugin's own data directory,
760
- `<config>/plugin-config-data/signalk-questdb`, whatever the deployment. Data
761
- survives container restarts, image upgrades, and plugin disable/enable cycles.
762
- How it reaches the container differs:
763
-
764
- - **Bare-metal Signal K**: that directory, mounted at `/var/lib/questdb`.
765
- - **Signal K in a container with its config directory bind-mounted**: the
766
- exact host path of that directory, mounted at `/var/lib/questdb`.
767
- - **Signal K in a container with its config directory on a named volume**:
768
- the volume cannot be mounted from a subdirectory, so it is mounted whole at
769
- `/signalk-vols/<volume>` and QuestDB is pointed at the plugin's directory
770
- inside it (`QUESTDB_DATA_DIR`). A database found at the volume's root — where
771
- a whole-volume mount put it — is moved into the plugin's directory on start;
772
- both are the same volume, so nothing is copied. A purge deletes the
773
- directory from the Signal K process, since the runtime cannot mount the
774
- volume by Signal K's path; where QuestDB's user owns it and the Signal K
775
- user cannot delete it, the purge says so and the directory is deleted by
776
- hand.
777
-
778
- QuestDB's entrypoint makes its data root its own on start, so the plugin keeps
779
- the one file it writes alongside — the import checkpoint,
780
- `signalk-questdb.influx-import-checkpoint.json` — next to the directory rather
781
- than in it. A purge removes it, whether or not the data could be deleted.
782
-
783
- ## Grafana Integration
784
-
785
- Connect Grafana to QuestDB via the PostgreSQL data source (user `admin`,
786
- password `quest`, database `qdb`).
787
-
788
- The companion [signalk-grafana](https://github.com/dirkwa/signalk-grafana)
789
- plugin wires this up for you: it runs Grafana as a managed container and
790
- reaches QuestDB by its container DNS name on the shared `sk-network`, so no
791
- host port needs to be exposed.
792
-
793
- For a **self-hosted Grafana on the host or in Podman**, point it at
794
- `localhost:<HTTP/PostgreSQL port>` — but note this only works when **"Bind to
795
- 0.0.0.0"** is enabled (otherwise QuestDB is not published on a host port).
796
-
797
- For Grafana in a **separate Docker instance**, enable **"Bind to 0.0.0.0"** and
798
- use your machine's LAN IP (e.g. `192.168.0.122:8812`) as the host in Grafana.
799
-
800
- **Warning:** Binding to 0.0.0.0 exposes QuestDB's ports to your entire network. Only enable this if necessary, and ensure your firewall is configured appropriately.
801
-
802
- Example query:
803
-
804
- ```sql
805
- SELECT ts AS time, avg(value) AS sog
806
- FROM signalk
807
- WHERE path = 'navigation.speedOverGround'
808
- AND context = 'self'
809
- AND ts BETWEEN $__timeFrom() AND $__timeTo()
810
- SAMPLE BY $__interval
811
- ```
812
-
813
- ## Requirements
814
-
815
- - Node.js >= 22
816
- - [signalk-container](https://github.com/dirkwa/signalk-container) >= 1.14.0 plugin (for managed mode; older versions still work but fall back to loopback connectivity)
817
- - Podman >= 5.4 (the version Debian 13 "trixie" ships) or Docker, for managed mode. On rootless Podman below 5.5 the plugin's open-files request is inherited from the podman service rather than granted per container ([containers/podman#25881](https://github.com/containers/podman/issues/25881)); signalk-container accounts for this.
818
- - Signal K server (≥ 2.31 to choose the default history provider in the admin UI)
949
+ The verify and `rm` commands run on podman's default connection — normally the
950
+ rootless one the plugin uses. Rootless and rootful connections keep separate
951
+ container stores, so on a setup with a non-default connection add the same
952
+ `--connection <name>` (list them with `podman system connection list`) to both
953
+ commands, so they hit the store where `sk-signalk-questdb` actually lives
954
+ (`podman ps -a` shows it).
819
955
 
820
956
  ## License
821
957
 
822
- signalk-questdb 2.0.0 and later is **source available, not open source**.
823
- See [LICENSE.md](LICENSE.md).
958
+ signalk-questdb 2.0.0 and later is **source available, not open source**. See
959
+ [LICENSE.md](LICENSE.md).
824
960
 
825
961
  **You may**, free of charge: run it on your own boat or fleet, private or
826
962
  commercial; use it for internal company operations; modify it for your own use;