signalk-questdb 2.5.1 → 2.5.2

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.
@@ -0,0 +1,179 @@
1
+ # Import and export
2
+
3
+ ## Migrating from InfluxDB
4
+
5
+ The **InfluxDB Migration** section of the config panel copies history out of an
6
+ existing InfluxDB into QuestDB. It supports InfluxDB 1.x (InfluxQL, what
7
+ signalk-to-influxdb wrote) and 2.x (Flux, what signalk-to-influxdb2 writes).
8
+
9
+ ### Running an import
10
+
11
+ 1. **Detect** finds an InfluxDB on `localhost:8086`, or enter its URL —
12
+ `localhost` or a loopback/private-network IP literal; anything else is
13
+ refused.
14
+ 2. Supply credentials — an API token and organisation for 2.x, or
15
+ username/password for 1.x if authentication is enabled — and list the buckets
16
+ (2.x) or databases (1.x). Credentials are used for the import only, are never
17
+ written to the plugin's settings, and travel in the request body rather than
18
+ the query string (Signal K logs full request URLs).
19
+ 3. Pick a bucket. The panel then lists the vessels it holds (**Listing the
20
+ vessels in the bucket…** — on a large bucket this takes a while) and shows
21
+ **Which vessel in the source is this one**: that vessel's history is imported
22
+ as `self`. The choice is made for you when the source says:
23
+ signalk-to-influxdb2 tags its own vessel's points with `self=true`, so that
24
+ vessel is preselected and marked "(recorded as the own vessel)" — as is the
25
+ one matching this server's identity, marked "(this server)", or the only one
26
+ there is. A signalk-to-influxdb 1.x source carries no such tag, so there the
27
+ choice is yours whenever it holds several vessels and none is this server's
28
+ identity — the usual case, since the recording server's identity is usually
29
+ not this one's (a new install has a new one). Tick **Also import the other
30
+ vessels (AIS targets), each under its own context** to bring those along; the
31
+ history API serves them as it does live-recorded targets.
32
+ 4. Pick a time range and **Start import**. Progress is polled while it runs;
33
+ **Cancel** stops it, keeping the saved position (see Resuming).
34
+
35
+ After the import, check the row counts on the status card or in the console
36
+ (`SELECT count() FROM signalk WHERE source = 'influxdb-import'`), make sure this
37
+ plugin is the default history provider (see [Default history provider](configuration.md#default-history-provider)), and disable the
38
+ InfluxDB plugin once nothing else needs it — a consumer such as a track viewer
39
+ reads whichever provider is the default, not both.
40
+
41
+ ### How the data maps
42
+
43
+ - A measurement with the conventional `value` field becomes the Signal K path of
44
+ the same name. So do the typed fields signalk-to-influxdb 1.x writes
45
+ (`stringValue`, `boolValue`, `jsonValue`). A measurement with any other named
46
+ fields becomes `measurement.field` paths, so two fields cannot overwrite each
47
+ other.
48
+ - Numbers go to `signalk`, strings and booleans to `signalk_str` (booleans
49
+ tagged `value_kind=boolean`). A `jsonValue` is decoded first: an object is
50
+ recorded as its scalar leaves, the same as live data.
51
+ - Positions go to `signalk_position`, whether stored as a `jsonValue`
52
+ (signalk-to-influxdb 1.x) or as a `lat`/`lon` or `latitude`/`longitude` field
53
+ pair (signalk-to-influxdb2).
54
+ - Both writers tag every point with the **context** of the vessel it belongs to;
55
+ the vessel you picked is imported as `self`, the others under their own
56
+ contexts or left out.
57
+ - Rows keep their **original timestamps**, to the microsecond (InfluxDB stores
58
+ nanoseconds; QuestDB's `TIMESTAMP` holds microseconds), so imported history
59
+ sorts and aggregates alongside live data.
60
+ - History is **streamed**: read in batches and written before more is asked for,
61
+ pausing while QuestDB catches up. Memory use stays flat however dense the
62
+ source is, so a large import is safe on a small board.
63
+ - Every imported row is tagged `source=influxdb-import`, which makes it
64
+ distinguishable from live recording — and because the tables deduplicate on
65
+ `(ts, path, context, source)`, **re-running the same range overwrites rather
66
+ than duplicating**.
67
+ - Anything that cannot be mapped (a gap, an unsupported value type, a
68
+ `jsonValue` that is not valid JSON, a latitude with no matching longitude) is
69
+ counted in the run's `skipped` total rather than being dropped silently.
70
+
71
+ ### Resuming an interrupted import
72
+
73
+ A large import runs for hours. If it stops part-way — cancelled, failed, or the
74
+ Signal K server restarted — the panel shows what was interrupted and offers
75
+ **Resume import**, which continues from the saved position instead of starting
76
+ again at the first measurement. It needs only the credentials, if InfluxDB asks
77
+ for any: the source and range are remembered, credentials never are. Starting
78
+ the identical import again the ordinary way continues it too. **Discard saved
79
+ position** starts over.
80
+
81
+ A position is saved only once QuestDB has been asked for the newest row written
82
+ before it and has it — ILP gives no acknowledgment, so the plugin reads the row
83
+ back — and once the position is about a minute old. The first keeps a stalled or
84
+ restarted QuestDB from letting the position past rows it never took, however
85
+ long the stall. The minute covers what a committed row still has to survive: a
86
+ crash of either process, and on a power cut the kernel's default write-back
87
+ timing for a QuestDB running on its default `nosync` — an assumption, not a
88
+ guarantee, which is why an external QuestDB should run with `sync` like the
89
+ managed container does (see [Durability on power loss](deployment.md#durability-on-power-loss)). The position file is
90
+ synced and renamed into place, so a power cut leaves the previous position or
91
+ the new one, never a damaged one; should none survive, the import starts again,
92
+ which is always safe. A position is a window boundary — the import reads each
93
+ measurement a window at a time, a day of source data by default — so a resumed
94
+ import repeats the window it was in plus up to a minute, which the deduplication
95
+ above makes harmless. An import shorter than a window saves nothing, and is
96
+ simply run again.
97
+
98
+ ### Imports made before 2.1.5
99
+
100
+ Imports from a signalk-to-influxdb 1.x database made before 2.1.5 filed every
101
+ string, boolean and position under a path suffixed with the InfluxDB field name
102
+ — `navigation.state.stringValue`, `steering.autopilot.engaged.boolValue`,
103
+ `navigation.position.jsonValue` — which nothing reads. Running the import again
104
+ writes them under their real paths but cannot remove the old rows: the path is
105
+ part of the deduplication key, and QuestDB has no row delete. When such rows
106
+ exist, the panel says how many and offers **Remove these rows**, which rebuilds
107
+ `signalk_str` without them. Recording continues while it runs; only the swap of
108
+ the rebuilt table for the old one holds the writer, for a few seconds, and rows
109
+ recorded during the rebuild are carried across. Should recording be fast enough
110
+ to fill the writer's buffer during those seconds, the samples it could not keep
111
+ are counted and the panel says how many. Run the import again afterwards.
112
+
113
+ ## Importing a GPX track
114
+
115
+ A chartplotter's exported track goes into the position archive (see [Retention
116
+ and the archive](recording.md#retention-and-the-archive)), where retention never drops it, under the source
117
+ `import.gpx`. An export can be gigabytes, a four-year TimeZero export is
118
+ 1.5 GB, so the file is sent in slices and parsed as it arrives. Memory stays at
119
+ one slice, whatever the file's size.
120
+
121
+ In the configuration panel, **GPX track import** reads the chosen file in the
122
+ browser first, which takes seconds even for a gigabyte, and lists its tracks by
123
+ name with their points and period. It proposes the own vessel's tracks: the
124
+ largest group whose name is not an AIS target's, and any other name that
125
+ recurs. It flags a group that lay more than a kilometre from a larger one at
126
+ the same time, since that was another vessel. Nothing is uploaded until
127
+ **Import** is pressed; the import then shows its progress and can be aborted.
128
+
129
+ - **Which tracks.** An export mixes the own track with AIS targets and with
130
+ passages imported into the plotter from elsewhere, and GPX does not say whose
131
+ a track is. The import takes the names of the tracks to import (`tracks`);
132
+ every other track is skipped and listed in the summary. In a TimeZero export
133
+ the own track is named `Own Ship`, or the plotter's translation of it, such
134
+ as `Eigenes Schiff`.
135
+ - **Thinning.** Points are thinned to the **Archive resolution**: each is
136
+ stamped on the start of its interval, and the archive keeps one row per
137
+ interval. 11.5 million points over four years become 3.2 million rows,
138
+ about 100 MB, at 10 s.
139
+ - **Overlap.** With `fillGapsOnly` (the default), a point is left out where the
140
+ vessel already has recorded positions in the same five minutes. Two
141
+ receivers' fixes interleaved in one series draw a sawtooth.
142
+ - **Doing it again.** Importing the same file again changes nothing, and a
143
+ stopped import can simply be restarted: the archive's dedup keys absorb the
144
+ rows already written.
145
+ - **Positions only.** The import takes a position and a time from each point;
146
+ depth, temperature or speed in a point's extensions are not imported. Depth
147
+ along the track beyond retention comes from live recording: list
148
+ `environment.depth.*` under **Archived own-vessel paths**, and retention keeps
149
+ it from then on.
150
+ - **Strict reading.** Coordinates must be decimals within range and times
151
+ `xsd:dateTime`; anything else is counted as invalid. A longitude written just
152
+ past ±180, as TimeZero does at the antimeridian, is wrapped. A file that is
153
+ not well-formed XML stops the import at the error, keeping the rows before
154
+ it.
155
+
156
+ ## Export and backup
157
+
158
+ **Data Export** in the panel, and
159
+ `GET /plugins/signalk-questdb/api/export?from=…&to=…&format=parquet|csv`,
160
+ download the `signalk` numeric table for a date range (both bounds required) as
161
+ Parquet — QuestDB's native export, compressed with the configured codec — or
162
+ CSV.
163
+
164
+ `GET /plugins/signalk-questdb/api/full-export/:table?from=…&to=…` streams one
165
+ whole table as Parquet, for snapshot and backup tooling that wants the full
166
+ content sliced into dedup-friendly shards (kopia, for instance). Tables:
167
+ `signalk`, `signalk_str`, `signalk_position`, `signalk_archive`, `signalk_position_archive`
168
+ (`/full-export/tables` lists them). The archive tables hold the own vessel's
169
+ history beyond retention and every imported track, so a backup that is meant to
170
+ outlive retention needs them too.
171
+
172
+ - `from` and `to` are optional but must be set together: omit both for the whole
173
+ table, or pass both as ISO 8601 timestamps for a half-open `[from, to)` window
174
+ — no row appears in two adjacent windows.
175
+ - Repeated query parameters (`?from=A&from=B`) and empty strings (`?from=`) are
176
+ rejected with HTTP 400; silently downgrading to a full-table export would hide
177
+ bugs in the caller.
178
+ - Format and compression follow the plugin's compression setting (LZ4_RAW or
179
+ ZSTD), the same as `/export`.
@@ -0,0 +1,187 @@
1
+ # Recording
2
+
3
+ ## Schema
4
+
5
+ Three tables, all with WAL mode, daily partitioning, and deduplication:
6
+
7
+ | Table | Purpose | Columns |
8
+ | ------------------ | -------------- | ---------------------------------------------------------------------------------------------------------- |
9
+ | `signalk` | Numeric values | `ts`, `path` (SYMBOL), `context` (SYMBOL), `source` (SYMBOL), `value` (DOUBLE) |
10
+ | `signalk_str` | String values | `ts`, `path` (SYMBOL), `context` (SYMBOL), `source` (SYMBOL), `value_str` (VARCHAR), `value_kind` (SYMBOL) |
11
+ | `signalk_position` | Positions | `ts`, `context` (SYMBOL), `source` (SYMBOL), `lat` (DOUBLE), `lon` (DOUBLE) |
12
+
13
+ What retention keeps of the own vessel lives in two more tables,
14
+ `signalk_archive` and `signalk_position_archive`, with the same columns and
15
+ monthly partitions (see Retention and the archive).
16
+
17
+ The own vessel is stored as context `self`, whatever its identity; other vessels
18
+ keep their `vessels.urn:…` context. The history API translates `self` back to
19
+ `vessels.self`.
20
+
21
+ `ts` is the **server receive time**, not the timestamp a source claims. Marine
22
+ sources carry independent clocks, and storing their timestamps makes commits
23
+ land out of order — QuestDB then rewrites partition tails on every merge
24
+ (observed as >3000x write amplification). Receive time keeps ingestion
25
+ append-only; the millisecond difference is far below the sampling resolution,
26
+ and a device with a broken clock gets more accurate history, not less.
27
+
28
+ `source` is the delta's sourceRef — which receiver produced the row. Two GPS
29
+ units feeding the same server interleave in storage, and without the column a
30
+ track drawn from history zigzags between them. Rows recorded before the column
31
+ existed have `source` null; they replay unattributed and cannot be filtered.
32
+
33
+ ## What is recorded
34
+
35
+ Every value on the stream is routed by its type: numbers go to `signalk`,
36
+ strings and booleans to `signalk_str` (booleans tagged `value_kind=boolean`),
37
+ and `navigation.position` — that path only — to `signalk_position`. A vessel's
38
+ name, which arrives as an empty-path `{name}` delta, is stored in `signalk_str`
39
+ under the path `name` with `value_kind=identity`, and replayed in its original
40
+ shape. An object value is recorded as its scalar leaves, one level deep:
41
+ `navigation.attitude {roll, pitch, yaw}` becomes `navigation.attitude.roll` and
42
+ so on, and another lat/lon object such as `navigation.anchor.position` becomes
43
+ `.latitude` and `.longitude` leaves rather than rows in the track table. Nested
44
+ objects — notifications, resource documents — are not descended into; a value no
45
+ table can hold is skipped and reported in the status endpoint and the panel
46
+ rather than dropped silently.
47
+
48
+ A number that means "no reading" is skipped and reported the same way: `NaN`
49
+ and `±Infinity`, and the NMEA 2000 "no data" marker on AC real, apparent and
50
+ reactive power (PGNs 65007–65029). Those fields cannot encode anything below
51
+ −2 000 000 000 W, but canboatjs decodes "no data" as −2 000 000 001 W, so real
52
+ and reactive power below that floor are dropped; real negative power, such as
53
+ export, is unaffected. Apparent power reaches the stream multiplied by the power
54
+ factor, which can move the marker anywhere below zero — and apparent power is
55
+ never negative, so any negative apparent power is dropped. Connections using the
56
+ canboat WASM decoder do not produce the marker at all.
57
+
58
+ **Sampling.** Each path is written at most once per **Default sampling rate**
59
+ (2000 ms), with per-path overrides by glob for the few paths that need finer
60
+ resolution and the many that need less. The first value after a window opens
61
+ is written and the rest of the window is dropped, not averaged, so a gust
62
+ between two samples is not in the history. A rate of 0, as default or per
63
+ path, removes the window: `environment.wind.* 0` records every change of the
64
+ wind at wire speed while everything else stays at the default. An unchanged
65
+ value is still written only once per **Unchanged-value heartbeat** (see
66
+ Change-only recording), except `navigation.position`, which is written even
67
+ when unchanged; set the heartbeat to 0 as well to record every update. Wire
68
+ speed costs rows: a wind sensor at 10 Hz writes about 1.7 million a day for
69
+ angle and speed, and every path of a busy NMEA 2000 boat at wire speed can be
70
+ a few hundred rows a second. **Record own vessel** and **Record AIS targets**
71
+ switch the two kinds of context independently, and **Path patterns** exclude
72
+ (or include only) paths by glob.
73
+
74
+ **Source priorities.** When Signal K's source priorities rank the sources of a
75
+ path, only the preferred source is recorded, the same value instruments show.
76
+ Paths without a priority are recorded from every source. **Record all sources**
77
+ records the sources a priority hides as well, for calibrating a sensor or
78
+ comparing two GPS receivers. Each source is then sampled on its own, so a path
79
+ with three sources writes up to three times the rows; with the option off, the
80
+ sources of a path share one sampling window.
81
+
82
+ **Change-only recording.** Most paths on a boat do not change from one sample
83
+ to the next: a switch that flips twice a day, an energy counter at rest, a tank
84
+ nobody is drawing from, a notification's state. Sampling alone would write each
85
+ of them at the sampling rate forever, and those repeats make up most of the
86
+ rows. So a value equal to the last one written for its path, vessel and source
87
+ is skipped until the **Unchanged-value heartbeat** is due, and then
88
+ written once more. Every change is written as it arrives, subject to the
89
+ sampling rate.
90
+
91
+ The heartbeat is what keeps the gaps readable. A path that is still being
92
+ updated has a row at least once per heartbeat, so a longer silence means the
93
+ data really stopped:
94
+
95
+ - **History API (v2).** A resolution bucket with no row takes the last sample
96
+ before it, for up to twice the heartbeat (see [History API](history-api.md)). A switch that has
97
+ been on for an hour reads as on for the whole hour, not as one point and
98
+ then nothing.
99
+ - **Stale data.** When Signal K marks a path stale it sends `null` for it.
100
+ Nothing is written for the `null`, so the history shows the last value for at
101
+ most twice the heartbeat and then a gap. The next reading after that is
102
+ always written, even if it equals the one before the gap.
103
+ - **Direct SQL and Grafana** see the rows as stored. Add `FILL(PREV)` to a
104
+ `SAMPLE BY` query to carry values across buckets.
105
+ - **Averages** weight rows, not time. A bucket in which a value changes often
106
+ and then holds steady averages mostly over the changes; `min`, `max`, `first`
107
+ and `last` are exact.
108
+ - **Startup restore** replays motion values recorded within **Restore max
109
+ age**. Keep the heartbeat below that, or a vessel lying still may come back
110
+ with its position but without its speed, course and heading.
111
+
112
+ `navigation.position` is written even when unchanged. A heartbeat of 0 does the
113
+ same for every path: each sample the sampling rate lets through is written. With
114
+ a sampling rate of 0 as well, that is every update.
115
+
116
+ **Batching.** Samples are buffered and committed every **Write batch interval**
117
+ (5 s), one WAL transaction per table per commit. The buffer holds 100 000 lines;
118
+ if QuestDB is unreachable for longer than that covers, the oldest lines are
119
+ dropped and counted. A clean Signal K shutdown flushes first; a hard crash loses
120
+ at most one interval.
121
+
122
+ **Compression** applies LZ4 or ZSTD to the WAL segments on disk (and to
123
+ Parquet exports).
124
+
125
+ ## Retention and the archive
126
+
127
+ Retention drops whole daily partitions once they lie entirely before the
128
+ configured number of days; 0 keeps everything. QuestDB cannot delete single
129
+ rows, so keeping part of a day means copying it out first. Before a day of
130
+ `signalk_position` or `signalk` is dropped, the own vessel's rows that the
131
+ settings ask to keep are copied into an archive table, thinned to one row per
132
+ **Archive resolution** interval, and the day is dropped only once QuestDB has
133
+ applied the copy. A copy that fails leaves the day in place for the next run.
134
+
135
+ | Table | Holds |
136
+ | -------------------------- | --------------------------------------------------------------- |
137
+ | `signalk_position_archive` | Own-vessel positions (**Keep own track**, on by default) |
138
+ | `signalk_archive` | Own-vessel numeric paths matching **Archived own-vessel paths** |
139
+
140
+ Both have the columns and dedup keys of their base table and are partitioned by
141
+ month. Retention never drops from them, other vessels are never archived, and
142
+ strings are not archived. The History API reads the archive together with the
143
+ base tables for any range it covers, so a track or a depth series continues
144
+ past the retention boundary without a seam. An archived row is the first sample
145
+ of its interval and is stamped on the interval's start.
146
+
147
+ Size, measured: a position row takes 32 bytes, so a vessel recorded around the
148
+ clock fills about 100 MB a year at a 10 s resolution, 34 MB at 30 s.
149
+
150
+ ## Startup restore
151
+
152
+ Signal K's data model lives in memory only. After a server restart every vessel
153
+ is gone until it transmits again — for AIS that means roughly 30 seconds for a
154
+ Class B target, up to 3 minutes for a Class A at anchor, and about 6 minutes
155
+ before names arrive. The chart fills in gradually instead of showing the traffic
156
+ that was there a moment ago.
157
+
158
+ With **Restore vessels on startup** enabled, the plugin replays each vessel's
159
+ last recorded position (plus course, speed, heading and identity — name,
160
+ dimensions and AIS ship type) from QuestDB as soon as it connects, so the chart
161
+ is populated immediately, with each target drawn in its ship-type colour rather
162
+ than the default.
163
+
164
+ **A restored position is where a vessel _was_, not where it is now.** Nothing is
165
+ dead-reckoned forward. The plugin presents these values as the history they are
166
+ rather than passing them off as live: each delta carries its original recorded
167
+ timestamp, so a chart plotter ages it out under the same staleness rules it
168
+ applies to any other target, and the deltas are tagged
169
+ `$source: signalk-questdb.restore`.
170
+
171
+ Values outside the **Restore max age** window are not replayed at all. The
172
+ 9-minute default matches Freeboard's AIS expiry, so a restored target is one
173
+ that would still have been on the chart had the server never stopped. Raising it
174
+ puts progressively staler positions in front of you — treat the window as a
175
+ collision-avoidance setting, not a convenience one.
176
+
177
+ Only navigation and identity paths are replayed, so stale tank levels and engine
178
+ temperatures are not resurrected as though they were current readings. A vessel
179
+ with no position in the window is skipped. Nothing the Signal K model already
180
+ holds is replaced: a vessel heard while QuestDB was still starting keeps its
181
+ newer position, and after a plugin restart every vessel keeps the one it has.
182
+ Restore honours the recording toggles: with **Record AIS targets** off, no AIS
183
+ target is restored.
184
+
185
+ The config panel reports the outcome once startup finishes: **Vessels Restored**
186
+ with the count, or **Restore failed** if the replay could not run. Neither
187
+ appears when the option is off.
@@ -0,0 +1,243 @@
1
+ # Troubleshooting
2
+
3
+ ## 'Module "signalk-questdb" is not available' right after install or update
4
+
5
+ Right after installing or updating the plugin, the admin UI can show
6
+ `Module "signalk-questdb" is not available. Make sure the webapp is installed.`
7
+ It is installed — the browser tab is still running the admin UI it loaded before
8
+ the update, which looks for the panel bundle it knew then. A hard refresh of the
9
+ tab (Shift-Reload) fixes it.
10
+
11
+ ## "Cannot apply a change: the QuestDB container cannot be stopped or removed"
12
+
13
+ The plugin needed to recreate the QuestDB container to apply a change — a new
14
+ image tag, a network or resource setting, a raised file-descriptor limit — but
15
+ the container runtime refused to stop or remove the running container. The
16
+ recreate is **deferred, not abandoned**: QuestDB keeps running and keeps
17
+ recording, on its **previous** configuration. The plugin reports the error
18
+ rather than a "Recording" status, because the change you asked for has not been
19
+ applied and reporting success would be a lie.
20
+
21
+ The usual cause on rootless Podman is an orphaned user namespace left behind
22
+ when the Podman service restarted underneath a running container. The error text
23
+ carries the specific remedy; the general one is to clear the stale namespace and
24
+ remove the container so the plugin can recreate it:
25
+
26
+ ```bash
27
+ podman system migrate # re-attaches orphaned user namespaces
28
+ podman ps -a | grep questdb # find the managed container's name
29
+ podman rm -f <name-from-above>
30
+ ```
31
+
32
+ Run all three as the **same OS user that owns the container**. Rootless and
33
+ rootful Podman keep separate container stores, so running them as root (or as
34
+ another user) will not find, and will not remove, a rootless container.
35
+
36
+ Look the name up rather than assuming it: the plugin's container is
37
+ `signalk-questdb` behind a prefix that defaults to `sk-` but is configurable
38
+ (`SIGNALK_CONTAINER_NAMESPACE`), so it is not always `sk-signalk-questdb`.
39
+
40
+ Then disable and re-enable the plugin, or restart Signal K. The plugin recreates
41
+ the container with the settings that were pending. Recorded data lives under
42
+ Signal K's data directory, not inside the container (see [Data storage](deployment.md#data-storage)), so
43
+ removing the container does not touch it.
44
+
45
+ **Do not** mask `podman.socket` to work around this. That socket is how the
46
+ plugin reaches Podman at all, so masking it turns a deferred recreate into a
47
+ container the plugin cannot manage in any way. (`podman.service` is the unit the
48
+ socket activates, and masking that has the same effect.) If the socket itself is
49
+ unhealthy, restart it rather than masking it:
50
+
51
+ ```bash
52
+ systemctl --user restart podman.socket
53
+ ```
54
+
55
+ This error is reported with signalk-container 1.29.0 or later. On older versions
56
+ the wedge still happens, but appears only as a repeating drift message in the
57
+ Signal K server log with no plugin error to go with it.
58
+
59
+ ## "QuestDB keeps dropping the write connection — the container may be unhealthy or out of memory"
60
+
61
+ This status appears when the plugin's ILP writer connects to QuestDB, gets
62
+ dropped within a few seconds, and retries — repeatedly. It is QuestDB itself
63
+ being unhealthy (typically OOM-killed and restart-looping), not a problem with
64
+ the plugin or with a single table. The most common cause on a Raspberry Pi or
65
+ other low-RAM host is that QuestDB is hitting a memory ceiling. There are two
66
+ variants, and the cgroup check below tells them apart.
67
+
68
+ **1. Is the cgroup `memory` controller delegated?**
69
+
70
+ ```bash
71
+ cat /sys/fs/cgroup/cgroup.controllers
72
+ # ...memory... present -> memory delegation is available, so the cap can be enforced (see B)
73
+ # memory ABSENT -> the cap is silently dropped (see A)
74
+ ```
75
+
76
+ When Signal K's containers run under rootless Podman, the kernel only enforces a
77
+ resource limit whose cgroup controller has been delegated to the user session.
78
+ Many distributions delegate `cpu`, `cpuset`, `io`, and `pids` by default but
79
+ **not** `memory`. signalk-container drops a limit whose controller is missing
80
+ rather than failing the container, so a configured cap can silently have no
81
+ effect.
82
+
83
+ **A. `memory` not delegated — the cap is silently dropped.** QuestDB grows
84
+ without bound and the host kernel's OOM killer eventually kills it under
85
+ whole-system memory pressure; it restarts and the cycle repeats. Enable memory
86
+ delegation on the host (one-time, needs sudo), then **recreate** the QuestDB
87
+ container (a restart is not enough — the limit is set at create time):
88
+
89
+ ```bash
90
+ sudo mkdir -p /etc/systemd/system/user@.service.d
91
+ sudo tee /etc/systemd/system/user@.service.d/delegate.conf >/dev/null <<'EOF'
92
+ [Service]
93
+ Delegate=cpu cpuset io memory pids
94
+ EOF
95
+ sudo systemctl daemon-reload
96
+ ```
97
+
98
+ `daemon-reload` reloads the drop-in file but does not re-apply `Delegate=` to
99
+ the already-running user manager, so the new delegation only takes effect after
100
+ the **user session restarts**. On a headless box a reboot is simplest (and is
101
+ required anyway if you edit `cmdline.txt` below); otherwise restart the user
102
+ manager with `sudo systemctl restart user@$(id -u).service` (this stops all of
103
+ that user's containers, so let them come back before recreating QuestDB).
104
+
105
+ ```bash
106
+ # On older Raspberry Pi kernels the memory controller is off at boot; add to
107
+ # /boot/cmdline.txt (one line) and reboot:
108
+ # cgroup_enable=memory cgroup_memory=1
109
+ ```
110
+
111
+ After the session restart, recreate the QuestDB container so the cap applies.
112
+
113
+ **B. `memory` is delegated — the 768 MB cap is enforced but too tight.** As the
114
+ database grows, QuestDB's peak memory (JVM heap plus off-heap memory-mapped
115
+ files, which spike during out-of-order merges) exceeds 768 MB and the cgroup
116
+ OOM-kills it. Raise **QuestDB memory limit** in the plugin config (e.g. `1g` or
117
+ `1.5g`, or empty to remove the cap on a roomier host), which recreates the
118
+ container.
119
+
120
+ **Confirm the diagnosis** (substitute the container's name if the prefix is not
121
+ `sk-`):
122
+
123
+ ```bash
124
+ podman inspect sk-signalk-questdb \
125
+ --format 'OOMKilled={{.State.OOMKilled}} RestartCount={{.RestartCount}} cap={{.HostConfig.Memory}}'
126
+ # cap=805306368 -> 768 MB cap is in place (variant B)
127
+ # cap=0 -> no cap applied (variant A, or cap intentionally removed)
128
+ podman events --since 2h --stream=false --filter container=sk-signalk-questdb \
129
+ --filter event=oom --filter event=died
130
+ ```
131
+
132
+ ## History queries slow or timing out / "out-of-memory" errors with free RAM
133
+
134
+ QuestDB memory-maps every partition column file and every pending WAL segment it
135
+ touches. The Linux kernel caps how many memory mappings one process may hold
136
+ (`vm.max_map_count`), and the stock value on Debian, Ubuntu ≤ 22.04, and RHEL
137
+ (65530) is far below what QuestDB recommends (1048576). A fresh database fits
138
+ easily; months of daily partitions across the three tables — plus a segment
139
+ backlog if a table's WAL is suspended — can exhaust the limit. When that happens
140
+ `mmap` fails with out-of-memory errors (errno 12) **even though plenty of RAM is
141
+ free**: queries error out or crawl, and the WAL apply job can suspend a table.
142
+
143
+ The QuestDB Web Console shows the same warning
144
+ (`vm.max_map_count limit is too low`), the plugin's config panel shows a banner,
145
+ and the server log gets a warning at plugin startup. Fix it in a shell **on the
146
+ host machine itself** — not inside the QuestDB container via `podman exec`: the
147
+ limit is kernel-global, a container cannot change it, and the QuestDB image has
148
+ neither `sudo` nor `/etc/sysctl.d`. Being kernel-global also means this works no
149
+ matter how Signal K itself is deployed:
150
+
151
+ ```bash
152
+ echo 'vm.max_map_count=1048576' | sudo tee /etc/sysctl.d/99-questdb.conf
153
+ sudo sysctl --system
154
+ ```
155
+
156
+ This takes effect immediately — no container or host restart needed. See
157
+ [QuestDB capacity planning](https://questdb.com/docs/getting-started/capacity-planning/#max-virtual-memory-areas-limit)
158
+ for background.
159
+
160
+ ## QuestDB container never starts on macOS (podman machine)
161
+
162
+ On macOS the QuestDB container is created but may never start. What you see
163
+ depends on the signalk-container version: with signalk-container 1.26 or later,
164
+ the manager names the problem outright (an open-files limit the host refuses)
165
+ and starts the container on the runtime's default limits instead, with this
166
+ plugin's config panel showing a **"request rejected by the host"** banner — the
167
+ remediation below applies unchanged. Older versions never start the container at
168
+ all and show either a misleading **"Permission denied"** (the runtime's error
169
+ text contains "operation not permitted") or only a generic **"Unexpected error.
170
+ See logs for details."** — and the container log view stays empty, because a
171
+ container that never started has no logs.
172
+
173
+ The cause is the plugin's open-files request. On macOS, Signal K runs on the Mac
174
+ while podman runs inside a Fedora CoreOS VM ("podman machine"). The plugin asks
175
+ for 1048576 open files, and signalk-container normally clamps that request to
176
+ what the host can grant — but from macOS it cannot read the VM's limits, so the
177
+ full request reaches the VM, exceeds its default hard limit (524288), and the
178
+ OCI runtime refuses to start the container. On current Fedora CoreOS the VM's
179
+ `vm.max_map_count` already meets QuestDB's 1048576, so usually only the
180
+ file-descriptor limit needs raising — but an older machine image may sit lower,
181
+ so check it first:
182
+
183
+ ```bash
184
+ podman machine ssh -- sysctl -n vm.max_map_count
185
+ ```
186
+
187
+ If that prints less than 1048576, raise it inside the VM before relying on the
188
+ file-descriptor fix alone (`podman machine ssh`, then
189
+ `sudo tee /etc/sysctl.d/99-signalk-questdb.conf <<< 'vm.max_map_count=1048576'`
190
+ and `sudo sysctl --system`).
191
+
192
+ Raise the file-descriptor limit inside the VM. From a macOS terminal:
193
+
194
+ ```bash
195
+ podman machine ssh
196
+ ```
197
+
198
+ Inside the VM, add a systemd drop-in for every user manager (the `user@.service`
199
+ template covers each user instance, including the machine's `core` user, whose
200
+ session runs rootless containers) and the same drop-in for `podman.service` to
201
+ cover the rootful connection:
202
+
203
+ ```bash
204
+ sudo mkdir -p /etc/systemd/system/user@.service.d /etc/systemd/system/podman.service.d
205
+ sudo tee /etc/systemd/system/user@.service.d/nofile.conf >/dev/null <<'EOF'
206
+ [Service]
207
+ LimitNOFILE=1048576
208
+ EOF
209
+ sudo cp /etc/systemd/system/user@.service.d/nofile.conf /etc/systemd/system/podman.service.d/nofile.conf
210
+ sudo systemctl daemon-reload
211
+ exit
212
+ ```
213
+
214
+ Back on macOS, restart the VM:
215
+
216
+ ```bash
217
+ podman machine stop && podman machine start
218
+ ```
219
+
220
+ and verify the new limit is grantable:
221
+
222
+ ```bash
223
+ podman run --rm --ulimit nofile=1048576:1048576 docker.io/library/alpine sh -c 'ulimit -n -H'
224
+ ```
225
+
226
+ This should print `1048576`. Finally, remove the half-created container so it is
227
+ recreated with the full limit — the limit is set at create time, so a restart is
228
+ not enough:
229
+
230
+ ```bash
231
+ podman rm -f sk-signalk-questdb
232
+ ```
233
+
234
+ then restart Signal K — on the next plugin start the container is recreated with
235
+ the full limit. (The container manager's **Start** button only starts an
236
+ existing container, so it cannot replace this step.)
237
+
238
+ The verify and `rm` commands run on podman's default connection — normally the
239
+ rootless one the plugin uses. Rootless and rootful connections keep separate
240
+ container stores, so on a setup with a non-default connection add the same
241
+ `--connection <name>` (list them with `podman system connection list`) to both
242
+ commands, so they hit the store where `sk-signalk-questdb` actually lives
243
+ (`podman ps -a` shows it).
@@ -39,6 +39,23 @@ console but QuestDB's REST API and data ingestion, with no authentication, so
39
39
  anyone who can reach it can read, **modify or delete** your history. The webapp
40
40
  route needs a Signal K admin session; prefer it.
41
41
 
42
+ ## Access and safety
43
+
44
+ **It is admin only.** The console is proxied at
45
+ `/plugins/signalk-questdb/console/`, and Signal K gives plugin routes an
46
+ admin-only default — the plugin deliberately does not relax that. A non-admin
47
+ who opens the webapp sees the page frame and an authorization error, never the
48
+ console.
49
+
50
+ **It can modify data.** Unlike the read-only `/query` endpoint, which rejects
51
+ anything that is not a `SELECT`, the console is a real SQL client: it can drop
52
+ tables and delete recorded history. That is the point of having it, and it is
53
+ what an administrator would use it for — but it is worth knowing before handing
54
+ someone an admin account.
55
+
56
+ If you would rather not serve it at all, switch the option off and the route is
57
+ not registered.
58
+
42
59
  ## A Quick Tour
43
60
 
44
61
  - The **left panel** lists the tables. Click a table to see its columns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-questdb",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
4
4
  "type": "module",
5
5
  "description": "QuestDB history provider for Signal K — replaces signalk-to-influxdb",
6
6
  "keywords": [