signalk-questdb 2.3.1 → 2.4.0

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.
Files changed (52) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CLAUDE.md +2 -2
  3. package/README.md +92 -0
  4. package/dist/archive.d.ts +7 -1
  5. package/dist/archive.js +7 -1
  6. package/dist/archive.js.map +1 -1
  7. package/dist/container-env.js +14 -0
  8. package/dist/container-env.js.map +1 -1
  9. package/dist/drained.d.ts +2 -0
  10. package/dist/drained.js +22 -0
  11. package/dist/drained.js.map +1 -0
  12. package/dist/history-v2.js +227 -33
  13. package/dist/history-v2.js.map +1 -1
  14. package/dist/ilp-writer.d.ts +2 -0
  15. package/dist/ilp-writer.js +48 -13
  16. package/dist/ilp-writer.js.map +1 -1
  17. package/dist/image-refresh.d.ts +35 -0
  18. package/dist/image-refresh.js +118 -0
  19. package/dist/image-refresh.js.map +1 -0
  20. package/dist/index.js +464 -136
  21. package/dist/index.js.map +1 -1
  22. package/dist/query-client.d.ts +12 -1
  23. package/dist/query-client.js +37 -3
  24. package/dist/query-client.js.map +1 -1
  25. package/dist/questdb-ready.d.ts +21 -0
  26. package/dist/questdb-ready.js +35 -0
  27. package/dist/questdb-ready.js.map +1 -0
  28. package/dist/questdb-watchdog.d.ts +73 -0
  29. package/dist/questdb-watchdog.js +308 -0
  30. package/dist/questdb-watchdog.js.map +1 -0
  31. package/dist/watchdog-checks.d.ts +50 -0
  32. package/dist/watchdog-checks.js +279 -0
  33. package/dist/watchdog-checks.js.map +1 -0
  34. package/package.json +3 -3
  35. package/public/assets/AppPanel-C293yEHl.js +1 -0
  36. package/public/assets/{PluginConfigurationPanel-DR-y5e3d.js → PluginConfigurationPanel-7qfy8fIR.js} +1 -1
  37. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BH9mY6vj.js +1 -0
  38. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__97224418320974__loadShare__shared-H1lSL0nN.js +1 -0
  39. package/public/assets/{_virtual_mf___mfe_internal__signalk_questdb__mf_owner__196689844091135__prebuild__react__prebuild__-eQjVI-Oj.js → _virtual_mf___mfe_internal__signalk_questdb__mf_owner__97224418320974__prebuild__react__prebuild__-CTXDWPi7.js} +1 -1
  40. package/public/assets/{dist-C5M3fhBw.js → dist-BhVCbinB.js} +1 -1
  41. package/public/assets/helpers-CvnX61Ju.js +1 -0
  42. package/public/assets/hostInit-Ds8KD_fS.js +1 -0
  43. package/public/assets/{index-eAjx9ZuX.js → index-QJC86Uvi.js} +2 -2
  44. package/public/assets/{pendingShares-PvyVL1Z8.js → pendingShares-DemvETkM.js} +2 -2
  45. package/public/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  46. package/public/assets/{utils-v9GZlbdk.js → utils-D62kKhzq.js} +1 -1
  47. package/public/remoteEntry.js +2 -2
  48. package/public/assets/AppPanel-Fvhb8xTK.js +0 -1
  49. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BUtmcsA-.js +0 -1
  50. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__196689844091135__loadShare__shared-BRI06Lxu.js +0 -8
  51. package/public/assets/helpers-oIxDNnZJ.js +0 -1
  52. package/public/assets/hostInit-B3lIJd9C.js +0 -1
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "2.3.1"
2
+ ".": "2.4.0"
3
3
  }
package/CLAUDE.md CHANGED
@@ -33,9 +33,9 @@ Both build outputs are shipped in the npm package; `prepublishOnly` rebuilds the
33
33
  - Subscribes to the Signal K **streambundle** for all paths and writes filtered/throttled deltas to QuestDB via **ILP over raw TCP** (`src/ilp-writer.ts`) — no QuestDB client library.
34
34
  - Three tables (`signalk`, `signalk_str`, `signalk_position`) defined and queried in `src/query-client.ts`. All use WAL mode + daily partitioning + dedup. The archives `signalk_archive` and `signalk_position_archive` are defined there too, with their base tables' columns and dedup keys and monthly partitions.
35
35
  - Reads come from two registered providers:
36
- - **v2** (`src/history-v2.ts`) — `app.registerHistoryApiProvider()`, REST under `/signalk/v2/api/history/`. Aggregates map directly to QuestDB SQL except `sma`/`ema` (computed client-side).
36
+ - **v2** (`src/history-v2.ts`) — `app.registerHistoryApiProvider()`, REST under `/signalk/v2/api/history/`. Aggregates map directly to QuestDB SQL except `sma`/`ema` (computed client-side). Where the archive covers the range, each table is sampled on its own and the buckets merged in the plugin.
37
37
  - **v1** (`src/history-v1.ts`) — `app.registerHistoryProvider()`, WebSocket playback with chunked reads.
38
- - Retention (`src/retention.ts`) drops old daily partitions on a timer. Before a partition of `signalk_position` or `signalk` goes, the own vessel's rows the config keeps are copied, thinned, into `signalk_position_archive` / `signalk_archive` (`src/archive.ts`), and the drop waits for that copy to be applied. Reads add an archive table only for a range it covers (`ArchiveSpans`), so a recent window stays on the base table alone; v1 snapshots read it as one more per-table `LATEST ON` branch, never by wrapping the union.
38
+ - Retention (`src/retention.ts`) drops old daily partitions on a timer. Before a partition of `signalk_position` or `signalk` goes, the own vessel's rows the config keeps are copied, thinned, into `signalk_position_archive` / `signalk_archive` (`src/archive.ts`), and the drop waits for that copy to be applied. Reads add an archive table only for a range it covers (`ArchiveSpans`), so a recent window stays on the base table alone. v1 snapshots read it as one more per-table `LATEST ON` branch, and v2's sampled and raw reads of the own vessel read each table on its own and merge the buckets or rows in the plugin. Their union (`readFrom` in `src/archive.ts`) sorts every row in range, so only reads that need one order across both tables, such as `middle_index`, or that a short window bounds still use it.
39
39
  - GPX import (`src/gpx-import.ts`, `src/gpx-import-session.ts`) streams a sliced upload through saxes into `signalk_position_archive` under source `import.gpx`, thinned by stamping points on interval starts and letting dedup keep one per interval. The caller names the tracks to import: an export mixes the own track with AIS targets and other boats' passages, and nothing in GPX tells them apart.
40
40
  - Startup restore (`src/restore.ts`) optionally replays each vessel's last recorded position back into the live model after a server restart.
41
41
  - Console proxy (`src/console-proxy.ts`) reverse-proxies QuestDB's own web console so the embeddable webapp (`public/index.html`) can host it in the admin UI.
package/README.md CHANGED
@@ -132,6 +132,51 @@ rootless Podman.
132
132
  | 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") |
133
133
  | Bind to 0.0.0.0 | `false` | Publish QuestDB's ports on all interfaces (see Connectivity) |
134
134
 
135
+ ### QuestDB updates
136
+
137
+ The panel's **update check** compares the running QuestDB with its latest stable
138
+ release. **Update** pulls that release, recreates the container on it and saves
139
+ it as the image version.
140
+
141
+ After the plugin itself was updated, and at its very first start, the plugin
142
+ also checks once on its own, in the background. It does so only in managed mode,
143
+ and only where the image version is a tag that moves, such as the default
144
+ `latest`. If QuestDB runs an older release than the latest stable one, the
145
+ plugin pulls the tag again and recreates the container on it. The image version
146
+ stays `latest`. Without this check, QuestDB on `latest` would stay on whatever
147
+ release the first install pulled, because signalk-container pulls an image only
148
+ when it is missing. An image version that names one release, such as `9.4.3`,
149
+ stays as it is, and so does one that **Update** saved while the check was
150
+ waiting. QuestDB is down while it starts again, as with **Update**. Progress
151
+ goes to the debug log, and failures go to the server log. A recreate that fails
152
+ half way and leaves QuestDB stopped or without a container is followed by a
153
+ start, as the plugin's own start would do. Only if that fails too does the
154
+ status line say so.
155
+
156
+ With [signalk-internet](https://github.com/meri-imperiumi/signalk-internet)
157
+ enabled, the check waits for a free link. On a metered, captive or offline link
158
+ it asks again from time to time, for as long as the plugin runs. Without
159
+ signalk-internet, the link counts as free. Telling the two apart needs
160
+ signalk-container 1.38.0 or later; with an older one, the check does not run. A
161
+ failure to read the running or the latest version is also tried again later.
162
+
163
+ The check is done once QuestDB runs the latest release, or once its container
164
+ runs the image the pull brought, even if QuestDB still reports an older release
165
+ afterwards. It then waits for the next plugin update. The next start checks
166
+ again wherever the container keeps its old image, or where the new one cannot
167
+ be told from the old:
168
+
169
+ - the plugin stopped first;
170
+ - the download failed, or something else failed before the recreate;
171
+ - an InfluxDB import, a GPX import or an export was running;
172
+ - the string table was being rebuilt;
173
+ - the container was stopped, which the check leaves as it is;
174
+ - the container was wedged;
175
+ - the tag on Docker Hub did not name the latest release yet.
176
+
177
+ A tag that never reaches the latest release, such as `9` while the latest is
178
+ `10.1.0`, therefore costs a pull and a look at GitHub at every start.
179
+
135
180
  ## Recording
136
181
 
137
182
  ### Schema
@@ -804,6 +849,53 @@ shown on the status card as **WAL suspended**. Two causes need two remedies:
804
849
  shows those numbers and offers **Skip**; if the stuck segment is the newest
805
850
  one, skipping drops the entire pending backlog, and the panel says so.
806
851
 
852
+ ### Out of memory or not answering
853
+
854
+ QuestDB caps the native memory it tracks below the container's memory limit
855
+ and fails any allocation past the cap with `global RSS memory limit exceeded`.
856
+ When something holds memory at the cap, queries fail, the WAL apply job then
857
+ suspends the tables, and in the end QuestDB stops accepting HTTP connections. The JVM stays up throughout, so the
858
+ container's restart policy never acts.
859
+
860
+ In managed mode the plugin checks for this every minute while it runs, and
861
+ restarts the QuestDB container when:
862
+
863
+ - QuestDB recently reported its global memory limit, and over consecutive
864
+ checks its native memory is still held at that limit, it fails even the
865
+ health check with it, or it has stopped answering. A single large query
866
+ frees what it held when it ends, so it does not count, nor does a client that
867
+ keeps asking for one.
868
+ - the container is running but QuestDB has answered nothing for several
869
+ minutes, over consecutive checks. A QuestDB busy with a long statement still
870
+ answers it in the end, and no check counts while a parquet export streams
871
+ from it.
872
+
873
+ Before the restart the plugin writes QuestDB's message and its last error lines
874
+ to the server log, shows the reason on the status card, and raises the Signal K
875
+ notification `notifications.signalk-questdb.watchdog`. After the restart it
876
+ logs the outcome. Once QuestDB answers again the notification returns to normal;
877
+ when it answered right after the restart, the notification keeps the time and
878
+ the reason. Restarts are rate limited, spaced apart and capped per few hours of
879
+ the plugin running; past that the plugin logs that the restart is held back and
880
+ keeps the alert up. A restart that fails, because stopping or starting the
881
+ container failed, counts against none of these limits: the plugin tries again a
882
+ few minutes later, since the container may be left stopped. It reports the
883
+ first failure; while the retries keep failing, they go to the debug log only.
884
+
885
+ The plugin leaves a container alone if someone else stopped it, and starts one
886
+ that its own restart left stopped. One exception: a container stopped by hand
887
+ while a stop of the plugin's is still pending, or right after a restart of the
888
+ plugin's before the plugin has seen it running again, may be started again too.
889
+ While a stop of the plugin's hangs, it sends no further stop. An external
890
+ QuestDB is never restarted. A
891
+ QuestDB that is already stuck when the plugin starts, as after a Signal K
892
+ restart while it was stuck, is not covered: restarting the plugin stops the
893
+ container and starts it afresh.
894
+
895
+ The managed container runs with QuestDB's query caches off: history reads put
896
+ their time range into the SQL, so the caches never got a hit, and on older
897
+ QuestDB releases they held memory.
898
+
807
899
  ### Performance (Pi and other low-power hosts)
808
900
 
809
901
  - **Default sampling rate** of 2000 ms limits each path to one write per two
package/dist/archive.d.ts CHANGED
@@ -18,7 +18,13 @@ export declare const NO_ARCHIVE: ArchiveReader;
18
18
  * The merge has to declare `ts` its designated timestamp again, or SAMPLE BY
19
19
  * and LATEST ON reject it, and that declaration is only true because of the
20
20
  * ORDER BY. A time filter on the outer query still reaches both tables as an
21
- * interval scan (verified with EXPLAIN on QuestDB 9.4 and 10.0).
21
+ * interval scan (verified with EXPLAIN on QuestDB 9.4 and 10.0), but the
22
+ * ORDER BY then sorts every row in range, so its memory grows with the
23
+ * range. And SAMPLE BY … FROM … TO … FILL(NULL) over it adds a bucket past
24
+ * TO that repeats the last one (QuestDB 9.4). The v2 provider's sampled and
25
+ * raw reads therefore read each table on its own and merge the rows
26
+ * themselves; this union is for reads that need one order across both
27
+ * tables, or that a short window bounds.
22
28
  */
23
29
  export declare function readFrom(table: ArchivedTable, withArchive: boolean): string;
24
30
  /**
package/dist/archive.js CHANGED
@@ -30,7 +30,13 @@ export const NO_ARCHIVE = { covers: () => false };
30
30
  * The merge has to declare `ts` its designated timestamp again, or SAMPLE BY
31
31
  * and LATEST ON reject it, and that declaration is only true because of the
32
32
  * ORDER BY. A time filter on the outer query still reaches both tables as an
33
- * interval scan (verified with EXPLAIN on QuestDB 9.4 and 10.0).
33
+ * interval scan (verified with EXPLAIN on QuestDB 9.4 and 10.0), but the
34
+ * ORDER BY then sorts every row in range, so its memory grows with the
35
+ * range. And SAMPLE BY … FROM … TO … FILL(NULL) over it adds a bucket past
36
+ * TO that repeats the last one (QuestDB 9.4). The v2 provider's sampled and
37
+ * raw reads therefore read each table on its own and merge the rows
38
+ * themselves; this union is for reads that need one order across both
39
+ * tables, or that a short window bounds.
34
40
  */
35
41
  export function readFrom(table, withArchive) {
36
42
  if (!withArchive)
@@ -1 +1 @@
1
- {"version":3,"file":"archive.js","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"archive.js","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"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"}
@@ -134,6 +134,20 @@ export function buildContainerEnv(config) {
134
134
  // apply memory-pressure backoff that precedes a suspension, so raise this
135
135
  // back to INFO when diagnosing a database that has stopped recording.
136
136
  QDB_LOG_W_STDOUT_LEVEL: "ERROR",
137
+ // The HTTP endpoint keeps up to 128 compiled queries (32 blocks of 4) for
138
+ // reuse by identical SQL text, and pg-wire keeps another such cache. The
139
+ // plugin's history reads put their time range into the SQL as literals,
140
+ // and so do Grafana's macros, so neither cache gets a hit — they only
141
+ // fill. On QuestDB before 10.0.0 every cached parallel GROUP BY / SAMPLE
142
+ // BY also keeps its native buffers at their peak size (fixed upstream in
143
+ // questdb#7267): about 7 MiB each over a month of data, so a full cache
144
+ // holds more than the 691 MiB the 768m cap leaves QuestDB, and it then
145
+ // fails every allocation with "global RSS memory limit exceeded". Seen
146
+ // twice on 9.4.3 within a day: 20 distinct reads added 150 MiB that only
147
+ // flush_query_cache() or a restart gave back. Without the caches, each
148
+ // read frees its buffers when it ends.
149
+ QDB_HTTP_QUERY_CACHE_ENABLED: "false",
150
+ QDB_PG_SELECT_CACHE_ENABLED: "false",
137
151
  QDB_CAIRO_O3_COLUMN_MEMORY_SIZE: "262144",
138
152
  ...(config.compression && config.compression !== "none"
139
153
  ? {
@@ -1 +1 @@
1
- {"version":3,"file":"container-env.js","sourceRoot":"","sources":["../src/container-env.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,6EAA6E;AAC7E,0EAA0E;AAC1E,6EAA6E;AAC7E,yBAAyB;AAIzB,kEAAkE;AAClE,6EAA6E;AAC7E,0EAA0E;AAC1E,0EAA0E;AAC1E,yEAAyE;AACzE,0EAA0E;AAC1E,6EAA6E;AAC7E,2EAA2E;AAC3E,0EAA0E;AAC1E,QAAQ;AACR,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,4EAA4E;AAC5E,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,CAAC;AAE1C,0EAA0E;AAC1E,2EAA2E;AAC3E,8EAA8E;AAC9E,qBAAqB;AACrB,MAAM,sBAAsB,GAAG,KAAK,CAAC;AAErC,4EAA4E;AAC5E,gBAAgB;AAChB,EAAE;AACF,4EAA4E;AAC5E,6EAA6E;AAC7E,0EAA0E;AAC1E,0EAA0E;AAC1E,2EAA2E;AAC3E,8EAA8E;AAC9E,uEAAuE;AACvE,EAAE;AACF,6EAA6E;AAC7E,sEAAsE;AACtE,8EAA8E;AAC9E,2EAA2E;AAC3E,uEAAuE;AACvE,uEAAuE;AACvE,wEAAwE;AACxE,MAAM,UAAU,eAAe,CAAC,MAAuB;IACrD,MAAM,KAAK,GAAG,CAAC,6BAA6B,CAAC,CAAC;IAC9C,IAAI,MAAM,CAAC,kBAAkB,EAAE,IAAI,EAAE,EAAE,CAAC;QACtC,KAAK,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IACxC,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,MAAuB;IAEvB,OAAO;QACL,qBAAqB,EAAE,OAAO;QAC9B,gBAAgB,EAAE,MAAM;QACxB,oBAAoB,EAAE,MAAM;QAC5B,qBAAqB,EAAE,mBAAmB;QAC1C,WAAW,EAAE,eAAe,CAAC,MAAM,CAAC;QACpC,yEAAyE;QACzE,sEAAsE;QACtE,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;QACxE,iEAAiE;QACjE,uEAAuE;QACvE,wEAAwE;QACxE,oEAAoE;QACpE,gDAAgD;QAChD,gBAAgB,EAAE,GAAG;QAErB,yEAAyE;QACzE,EAAE;QACF,wEAAwE;QACxE,wEAAwE;QACxE,uEAAuE;QACvE,0EAA0E;QAC1E,4CAA4C;QAC5C,EAAE;QACF,oEAAoE;QACpE,gEAAgE;QAChE,uEAAuE;QACvE,2DAA2D;QAC3D,EAAE;QACF,qEAAqE;QACrE,iCAAiC;QACjC,EAAE;QACF,mEAAmE;QACnE,kEAAkE;QAClE,uEAAuE;QACvE,uBAAuB;QACvB,EAAE;QACF,yEAAyE;QACzE,yEAAyE;QACzE,iBAAiB;QACjB,0BAA0B,EAAE,GAAG;QAC/B,uBAAuB,EAAE,GAAG;QAC5B,gCAAgC,EAAE,GAAG;QACrC,wEAAwE;QACxE,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,wCAAwC;QACxC,4BAA4B,EAAE,GAAG;QACjC,uEAAuE;QACvE,wEAAwE;QACxE,8BAA8B,EAAE,GAAG;QACnC,iCAAiC,EAAE,GAAG;QACtC,kCAAkC,EAAE,GAAG;QACvC,uBAAuB,EAAE,GAAG;QAE5B,0EAA0E;QAC1E,kEAAkE;QAClE,iCAAiC,EAAE,sBAAsB;QACzD,uEAAuE;QACvE,yEAAyE;QACzE,uEAAuE;QACvE,yEAAyE;QACzE,uEAAuE;QACvE,8CAA8C;QAC9C,+BAA+B,EAAE,sBAAsB;QACvD,6BAA6B,EAAE,sBAAsB;QACrD,oCAAoC,EAAE,sBAAsB;QAC5D,sCAAsC,EAAE,sBAAsB;QAC9D,0CAA0C,EAAE,sBAAsB;QAClE,wCAAwC,EAAE,sBAAsB;QAChE,2CAA2C,EAAE,sBAAsB;QACnE,4CAA4C,EAAE,sBAAsB;QACpE,iCAAiC,EAAE,sBAAsB;QAEzD,wEAAwE;QACxE,0EAA0E;QAC1E,mEAAmE;QACnE,mEAAmE;QACnE,uEAAuE;QACvE,0EAA0E;QAC1E,sEAAsE;QACtE,sBAAsB,EAAE,OAAO;QAE/B,+BAA+B,EAAE,QAAQ;QACzC,GAAG,CAAC,MAAM,CAAC,WAAW,IAAI,MAAM,CAAC,WAAW,KAAK,MAAM;YACrD,CAAC,CAAC;gBACE,uCAAuC,EACrC,MAAM,CAAC,WAAW,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK;gBAChD,GAAG,CAAC,MAAM,CAAC,WAAW,KAAK,MAAM,IAAI,MAAM,CAAC,gBAAgB;oBAC1D,CAAC,CAAC;wBACE,uCAAuC,EAAE,MAAM,CAC7C,MAAM,CAAC,gBAAgB,CACxB;qBACF;oBACH,CAAC,CAAC,EAAE,CAAC;aACR;YACH,CAAC,CAAC,EAAE,CAAC;KACR,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"container-env.js","sourceRoot":"","sources":["../src/container-env.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import type { ServerResponse } from "node:http";
2
+ export declare function drained(res: ServerResponse, signal?: AbortSignal): Promise<boolean>;
@@ -0,0 +1,22 @@
1
+ // After a write the response refused, waits until it takes more; false once
2
+ // the client is gone, or once `signal` (the export's deadline) aborts. A
3
+ // response whose client disconnected never drains, nor does one whose client
4
+ // stays connected but stops reading, and a stream loop waiting for it would
5
+ // hold its QuestDB export open for good, and with it the watchdog's export
6
+ // count (questdbExportsRunning in index.ts).
7
+ export function drained(res, signal) {
8
+ if (res.destroyed || signal?.aborted)
9
+ return Promise.resolve(false);
10
+ return new Promise((resolve) => {
11
+ const settle = () => {
12
+ res.off("drain", settle);
13
+ res.off("close", settle);
14
+ signal?.removeEventListener("abort", settle);
15
+ resolve(!res.destroyed && !signal?.aborted);
16
+ };
17
+ res.once("drain", settle);
18
+ res.once("close", settle);
19
+ signal?.addEventListener("abort", settle, { once: true });
20
+ });
21
+ }
22
+ //# sourceMappingURL=drained.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drained.js","sourceRoot":"","sources":["../src/drained.ts"],"names":[],"mappings":"AAEA,4EAA4E;AAC5E,yEAAyE;AACzE,6EAA6E;AAC7E,4EAA4E;AAC5E,2EAA2E;AAC3E,6CAA6C;AAC7C,MAAM,UAAU,OAAO,CACrB,GAAmB,EACnB,MAAoB;IAEpB,IAAI,GAAG,CAAC,SAAS,IAAI,MAAM,EAAE,OAAO;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IACpE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,GAAG,EAAE;YAClB,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YACzB,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YACzB,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC7C,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC9C,CAAC,CAAC;QACF,GAAG,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC1B,GAAG,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC1B,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5D,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -295,6 +295,8 @@ function normalizeContext(context, selfContext) {
295
295
  // well-behaved clients budget ~2000 points per request; a million covers any
296
296
  // sane chart while keeping the worst case bounded.
297
297
  const MAX_SAMPLE_BUCKETS = 1_000_000;
298
+ // The rows a read without a resolution returns at most.
299
+ const RAW_LIMIT = 10_000;
298
300
  // Upper bound on columns ONE path may expand into under sourcePolicy=all.
299
301
  // The bucket cap above only applies to requests that named a resolution, so
300
302
  // these are the only things bounding an unresolved expansion.
@@ -328,6 +330,128 @@ function sampleBy(range, resolution) {
328
330
  const to = validateTimestamp(range.to);
329
331
  return `SAMPLE BY ${effectiveResolution(resolution)}s FROM '${from}' TO '${to}' FILL(NULL)`;
330
332
  }
333
+ const numbers = (values) => values.filter((v) => typeof v === "number");
334
+ const total = (values) => numbers(values).reduce((a, b) => a + b, 0);
335
+ /** The least or greatest number, or null where there is none, as in SQL. */
336
+ function extreme(values, pick) {
337
+ const found = numbers(values);
338
+ return found.length === 0 ? null : Math[pick](...found);
339
+ }
340
+ /**
341
+ * The row whose time (part `at`) is earliest, or latest. QuestDB writes
342
+ * every timestamp in one fixed-width ISO form, so the strings compare in
343
+ * time order.
344
+ */
345
+ function byTime(rows, at, latest) {
346
+ return rows.reduce((best, row) => {
347
+ const [a, b] = [String(row[at]), String(best[at])];
348
+ return (latest ? a > b : a < b) ? row : best;
349
+ });
350
+ }
351
+ /** A numeric bucket's `method` value, as aggregateToSql() computes it. */
352
+ function valueColumns(method) {
353
+ const sql = `${aggregateToSql(method)} as agg_value`;
354
+ switch (method) {
355
+ case "min":
356
+ case "max":
357
+ return {
358
+ sql,
359
+ parts: [`${method}(value)`],
360
+ width: 1,
361
+ merge: (rows) => [
362
+ extreme(rows.map((r) => r[0]), method),
363
+ ],
364
+ };
365
+ case "first":
366
+ case "last":
367
+ return {
368
+ sql,
369
+ parts: [`${method}(value)`, method === "first" ? "min(ts)" : "max(ts)"],
370
+ width: 1,
371
+ merge: (rows) => [byTime(rows, 1, method === "last")[0]],
372
+ };
373
+ case "mid":
374
+ return {
375
+ sql,
376
+ parts: ["min(value)", "max(value)"],
377
+ width: 1,
378
+ merge: (rows) => {
379
+ const low = extreme(rows.map((r) => r[0]), "min");
380
+ const high = extreme(rows.map((r) => r[1]), "max");
381
+ return [low === null || high === null ? null : (low + high) / 2];
382
+ },
383
+ };
384
+ default: {
385
+ // A mean of the two means would weigh a bucket's few archived rows
386
+ // as much as its many recorded ones.
387
+ return {
388
+ sql,
389
+ parts: ["sum(value)", "count(value)"],
390
+ width: 1,
391
+ merge: (rows) => {
392
+ const count = total(rows.map((r) => r[1]));
393
+ return [count === 0 ? null : total(rows.map((r) => r[0])) / count];
394
+ },
395
+ };
396
+ }
397
+ }
398
+ }
399
+ /** ANGULAR_BUCKET_SQL's mean angle and floor, summed across the tables. */
400
+ const ANGULAR_COLUMNS = {
401
+ sql: ANGULAR_BUCKET_SQL,
402
+ parts: ["sum(sin(value))", "sum(cos(value))", "count(value)", "min(value)"],
403
+ width: 2,
404
+ merge: (rows) => {
405
+ const column = (i) => rows.map((r) => r[i]);
406
+ const count = total(column(2));
407
+ if (count === 0)
408
+ return [null, null];
409
+ const [sin, cos] = [total(column(0)) / count, total(column(1)) / count];
410
+ return [
411
+ Math.hypot(sin, cos) < NO_DIRECTION ? null : Math.atan2(sin, cos),
412
+ extreme(column(3), "min"),
413
+ ];
414
+ },
415
+ };
416
+ /** HELD_SQL's last sample and its time, from the table that took it last. */
417
+ const HELD_COLUMNS = {
418
+ sql: HELD_SQL,
419
+ parts: ["last(value)", "last(ts)"],
420
+ width: 2,
421
+ merge: (rows) => byTime(rows, 1, true),
422
+ };
423
+ /** A position bucket's `pick` fix, as one lat/lon pair from one table. */
424
+ function positionColumns(pick) {
425
+ return {
426
+ sql: `${pick}(lat) as lat, ${pick}(lon) as lon`,
427
+ parts: [
428
+ `${pick}(lat)`,
429
+ `${pick}(lon)`,
430
+ pick === "first" ? "min(ts)" : "max(ts)",
431
+ ],
432
+ width: 2,
433
+ merge: (rows) => byTime(rows, 2, pick === "last").slice(0, 2),
434
+ };
435
+ }
436
+ /**
437
+ * The rows of two reads ordered by time (column 0), paired where both have
438
+ * the same time and in time order throughout. Yielded rather than collected,
439
+ * so a merge holds the two reads and its result, not a third copy.
440
+ */
441
+ function* pairByTime(own, kept) {
442
+ let i = 0;
443
+ let j = 0;
444
+ while (i < own.length || j < kept.length) {
445
+ const a = i < own.length ? String(own[i][0]) : null;
446
+ const b = j < kept.length ? String(kept[j][0]) : null;
447
+ if (a !== null && (b === null || a < b))
448
+ yield [own[i++], null];
449
+ else if (a === null || b < a)
450
+ yield [null, kept[j++]];
451
+ else
452
+ yield [own[i++], kept[j++]];
453
+ }
454
+ }
331
455
  export function createHistoryProviderV2(queryClient, selfContext,
332
456
  // Opt-in, default off. Expansion multiplies one requested path into one
333
457
  // query and one column PER recording source, so a request that was cheap
@@ -357,11 +481,71 @@ archive = NO_ARCHIVE) {
357
481
  const since = new Date(Date.parse(from) - holdMs).toISOString();
358
482
  return `ts >= '${since}' AND ts < '${from}' AND context = '${safeContext}'`;
359
483
  }
360
- /** What the numeric seed is read from: the window `seedRangeWhere` names. */
361
- function seedSource(range) {
484
+ /**
485
+ * `base` sampled over the range in `groups`' columns, one row a bucket.
486
+ *
487
+ * With the archive, each table is sampled on its own and the two grids
488
+ * merged here. QuestDB samples their union only after sorting every row in
489
+ * range by time: the tracks plugin's history import asks once for
490
+ * everything since 1970, and that sort of 5.5 M own positions went past
491
+ * QuestDB's memory limit. Each table alone is sampled in the order it is
492
+ * stored, and the merge holds the two grids and the merged one.
493
+ */
494
+ async function readSampled(base, withArchive, where, range, resolution, groups) {
495
+ const grid = sampleBy(range, resolution);
496
+ if (!withArchive) {
497
+ const columns = groups.map((group) => group.sql).join(", ");
498
+ const result = await queryClient.exec(`SELECT ts, ${columns} FROM ${base} WHERE ${where} ${grid} ORDER BY ts`);
499
+ return result.dataset;
500
+ }
501
+ // Aliased, since two groups may select the same aggregate, and led by
502
+ // the bucket's row count, which FILL(NULL) leaves null where it is 0.
503
+ const parts = ["count()", ...groups.flatMap((group) => group.parts)]
504
+ .map((part, i) => `${part} p${i}`)
505
+ .join(", ");
506
+ const read = async (table) => (await queryClient.exec(`SELECT ts, ${parts} FROM ${table} WHERE ${where} ${grid} ORDER BY ts`)).dataset;
507
+ const own = await read(base);
508
+ const kept = await read(ARCHIVE_TABLES[base]);
509
+ return Array.from(pairByTime(own, kept), (pair) => {
510
+ const filled = pair.filter((row) => row !== null && Number(row[1]) > 0);
511
+ const row = [(pair[0] ?? pair[1])[0]];
512
+ let at = 2;
513
+ for (const group of groups) {
514
+ row.push(...(filled.length === 0
515
+ ? Array(group.width).fill(null)
516
+ : group.merge(filled.map((r) => r.slice(at, at + group.parts.length)))));
517
+ at += group.parts.length;
518
+ }
519
+ return row;
520
+ });
521
+ }
522
+ /**
523
+ * The first rows of `base` in the range, in time order, under the LIMIT
524
+ * every raw read has: with the archive, from each table on its own and
525
+ * merged here, for the reason readSampled() gives.
526
+ */
527
+ async function readRaw(base, withArchive, columns, where) {
528
+ const read = async (table) => (await queryClient.exec(`SELECT ts, ${columns} FROM ${table} WHERE ${where} ORDER BY ts LIMIT ${RAW_LIMIT}`)).dataset;
529
+ const own = await read(base);
530
+ if (!withArchive)
531
+ return own;
532
+ const kept = await read(ARCHIVE_TABLES[base]);
533
+ const rows = [];
534
+ for (const pair of pairByTime(own, kept)) {
535
+ rows.push(...pair.filter((row) => row !== null));
536
+ if (rows.length >= RAW_LIMIT)
537
+ break;
538
+ }
539
+ return rows.slice(0, RAW_LIMIT);
540
+ }
541
+ /**
542
+ * What the numeric seed is read from: the window `seedRangeWhere` names,
543
+ * with the archive only for the own vessel, the only one it holds.
544
+ */
545
+ function seedSource(range, own) {
362
546
  const from = validateTimestamp(range.from);
363
547
  const since = new Date(Date.parse(from) - holdMs).toISOString();
364
- return readFrom("signalk", archive.covers("signalk", since, from));
548
+ return readFrom("signalk", own && archive.covers("signalk", since, from));
365
549
  }
366
550
  /** The numeric table's last `[value, ts]` before the range, or null. */
367
551
  async function numericSeed(seedWhere, source) {
@@ -413,7 +597,7 @@ archive = NO_ARCHIVE) {
413
597
  * leaves the path as a single unexpanded column rather than failing the
414
598
  * whole request.
415
599
  */
416
- async function distinctSources(path, range, safeContext) {
600
+ async function distinctSources(path, range, safeContext, withArchive) {
417
601
  const safePath = validateIdentifier(path);
418
602
  const rangeWhere = buildRangeWhere(range, safeContext);
419
603
  const position = path === "navigation.position";
@@ -422,7 +606,7 @@ archive = NO_ARCHIVE) {
422
606
  const tables = [
423
607
  `SELECT DISTINCT source FROM ${table} WHERE ${rangeWhere}${pathWhere}`,
424
608
  ];
425
- if (archive.covers(table, range.from, range.to)) {
609
+ if (withArchive) {
426
610
  tables.push(`SELECT DISTINCT source FROM ${ARCHIVE_TABLES[table]} WHERE ${rangeWhere}${pathWhere}`);
427
611
  }
428
612
  if (!position) {
@@ -531,7 +715,7 @@ archive = NO_ARCHIVE) {
531
715
  // what an empty bucket after it holds (see holdThroughEmptyBuckets).
532
716
  return sampled
533
717
  ? `SELECT ts, ${pick}(value_str) as value_str, ${kindOf(pick)} as value_kind, last(value_str) as held_str, ${kindOf("last")} as held_kind, last(ts) as held_ts FROM signalk_str WHERE ${clause} ${sampleBy(range, resolution)} ORDER BY ts`
534
- : `SELECT ts, value_str, ${kind} as value_kind FROM signalk_str WHERE ${clause} ORDER BY ts LIMIT 10000`;
718
+ : `SELECT ts, value_str, ${kind} as value_kind FROM signalk_str WHERE ${clause} ORDER BY ts LIMIT ${RAW_LIMIT}`;
535
719
  };
536
720
  // A read racing ensureTables()'s migration — or an external QuestDB the
537
721
  // plugin does not own — must degrade, not fail the request. Two columns
@@ -607,6 +791,16 @@ archive = NO_ARCHIVE) {
607
791
  const requestedContext = query.context ?? "vessels.self";
608
792
  const storedContext = normalizeContext(requestedContext, selfContext);
609
793
  const safeContext = validateIdentifier(storedContext);
794
+ // Whether each table's reads include its archive. The archive holds only
795
+ // the own vessel (archive.ts), so another vessel's reads leave it out.
796
+ // Decided once, so the bucket budget counts the reads that then run even
797
+ // when a refresh of the archive's spans lands in between.
798
+ const archived = {
799
+ signalk: storedContext === "self" &&
800
+ archive.covers("signalk", range.from, range.to),
801
+ signalk_position: storedContext === "self" &&
802
+ archive.covers("signalk_position", range.from, range.to),
803
+ };
610
804
  // Expand each spec into the COLUMNS it will produce. Under
611
805
  // sourcePolicy=all a path with no explicit sourceRef becomes one column
612
806
  // per distinct source that actually recorded it in range; everything else
@@ -631,7 +825,7 @@ archive = NO_ARCHIVE) {
631
825
  `${MAX_EXPANDED_COLUMNS} columns — request fewer paths, or name ` +
632
826
  `the sources explicitly with paths=<path>|<sourceRef>`);
633
827
  }
634
- const sources = await distinctSources(spec.path, range, safeContext);
828
+ const sources = await distinctSources(spec.path, range, safeContext, archived[spec.path === "navigation.position" ? "signalk_position" : "signalk"]);
635
829
  if (sources.length === 0) {
636
830
  // Nothing recorded in range, or a database with no `source` column:
637
831
  // keep the unexpanded column so the caller still gets the series
@@ -691,10 +885,15 @@ archive = NO_ARCHIVE) {
691
885
  // navigation.position is served by its own table and never falls back;
692
886
  // a moving average has no text to fall back to.
693
887
  const fallbackCapableSpecs = columns.filter((spec) => spec.path !== "navigation.position" && !isMovingAverage(spec.aggregate)).length;
888
+ // A column the archive covers costs one more: readSampled() samples the
889
+ // base table and the archive each on the whole grid. middle_index reads
890
+ // their union in one query.
891
+ const archivedSpecs = columns.filter((spec) => spec.aggregate !== "middle_index" &&
892
+ archived[spec.path === "navigation.position" ? "signalk_position" : "signalk"]).length;
694
893
  if (sampledSpecs > 0 && query.resolution && query.resolution > 0) {
695
894
  const rangeSec = (Date.parse(range.to) - Date.parse(range.from)) / 1000;
696
895
  const bucketsPerSeries = Math.ceil(rangeSec / effectiveResolution(query.resolution));
697
- const worstCaseQueries = sampledSpecs + fallbackCapableSpecs;
896
+ const worstCaseQueries = sampledSpecs + fallbackCapableSpecs + archivedSpecs;
698
897
  const buckets = bucketsPerSeries * worstCaseQueries;
699
898
  if (buckets > MAX_SAMPLE_BUCKETS) {
700
899
  throw new Error(`resolution ${query.resolution}s over this range produces up to ` +
@@ -723,7 +922,11 @@ archive = NO_ARCHIVE) {
723
922
  const sourceWhere = buildSourceWhere(spec);
724
923
  const isPosition = spec.path === "navigation.position";
725
924
  const base = isPosition ? "signalk_position" : "signalk";
726
- const table = readFrom(base, archive.covers(base, range.from, range.to));
925
+ // A middle row needs both tables' rows in one order, so middle_index
926
+ // reads their union, as the numeric seed does over its short window.
927
+ // Over a long range the archive covers that still sorts every row in
928
+ // it. Every other read goes through readSampled() or readRaw().
929
+ const union = () => readFrom(base, archived[base]);
727
930
  if (isPosition) {
728
931
  const where = buildRangeWhere(range, safeContext) + sourceWhere;
729
932
  // Position is a co-recorded lat/lon pair. first, last and
@@ -736,21 +939,20 @@ archive = NO_ARCHIVE) {
736
939
  : "first";
737
940
  if (posAgg !== spec.aggregate)
738
941
  valuesList[specIndex].method = "first";
739
- let sql;
942
+ let dataset;
740
943
  if (posAgg === "middle_index") {
741
- sql = middleRowSql(table, "lat, lon", where, range, query.resolution);
944
+ dataset = (await queryClient.exec(middleRowSql(union(), "lat, lon", where, range, query.resolution))).dataset;
742
945
  }
743
946
  else if (query.resolution && query.resolution > 0) {
744
- sql = `SELECT ts, ${posAgg}(lat) as lat, ${posAgg}(lon) as lon FROM ${table} WHERE ${where} ${sampleBy(range, query.resolution)} ORDER BY ts`;
947
+ dataset = await readSampled(base, archived[base], where, range, query.resolution, [positionColumns(posAgg)]);
745
948
  }
746
949
  else {
747
- sql = `SELECT ts, lat, lon FROM ${table} WHERE ${where} ORDER BY ts LIMIT 10000`;
950
+ dataset = await readRaw(base, archived[base], "lat, lon", where);
748
951
  }
749
- const result = await queryClient.exec(sql);
750
952
  // A position goes out as a `[longitude, latitude]` pair — GeoJSON
751
953
  // order, as the History API's OpenAPI schema defines and the other
752
954
  // providers emit — not as the data model's `{latitude, longitude}`.
753
- const rows = result.dataset.map((row) => [
955
+ const rows = dataset.map((row) => [
754
956
  row[0],
755
957
  row[1] !== null && row[2] !== null ? [row[2], row[1]] : null,
756
958
  ]);
@@ -759,7 +961,7 @@ archive = NO_ARCHIVE) {
759
961
  }
760
962
  const where = `${buildRangeWhere(range, safeContext)} AND path = '${safePath}'${sourceWhere}`;
761
963
  const seedWhere = `${seedRangeWhere(range, safeContext)} AND path = '${safePath}'${sourceWhere}`;
762
- const seedFrom = seedSource(range);
964
+ const seedFrom = seedSource(range, storedContext === "self");
763
965
  if (isMovingAverage(spec.aggregate)) {
764
966
  // The window runs over the series at the requested resolution — one
765
967
  // averaged bucket per sample, the grid every other column is on — so
@@ -771,17 +973,13 @@ archive = NO_ARCHIVE) {
771
973
  // gap ages values out instead of stretching the window across it.
772
974
  const window = movingAverageWindow(spec);
773
975
  const angular = isAngular(unitsOf(spec.path, requestedContext));
774
- const bucket = angular ? ANGULAR_BUCKET_SQL : "avg(value) as agg_value";
976
+ const bucket = angular ? ANGULAR_COLUMNS : valueColumns("average");
775
977
  const sampled = !!query.resolution && query.resolution > 0;
776
- const sql = sampled
777
- ? `SELECT ts, ${bucket}, ${HELD_SQL} FROM ${table} WHERE ${where} ${sampleBy(range, query.resolution)} ORDER BY ts`
778
- : `SELECT ts, value FROM ${table} WHERE ${where} ORDER BY ts LIMIT 10000`;
779
- const result = await queryClient.exec(sql);
780
978
  // Held before averaging: an unchanged value is still a sample of
781
979
  // the window, not a hole in it.
782
980
  const dataset = sampled
783
- ? await holdNumericBuckets(result.dataset, seedWhere, seedFrom)
784
- : result.dataset;
981
+ ? await holdNumericBuckets(await readSampled(base, archived[base], where, range, query.resolution, [bucket, HELD_COLUMNS]), seedWhere, seedFrom)
982
+ : await readRaw(base, archived[base], "value", where);
785
983
  // An angular series keeps one convention throughout: [0, 2π) unless
786
984
  // a sample (or a bucket's lowest sample) was negative.
787
985
  const floors = dataset.map((r) => angular && sampled
@@ -823,7 +1021,7 @@ archive = NO_ARCHIVE) {
823
1021
  columnData.set(specIndex, strResult.rows);
824
1022
  };
825
1023
  if (spec.aggregate === "middle_index") {
826
- const result = await queryClient.exec(middleRowSql(table, "value", where, range, query.resolution));
1024
+ const result = await queryClient.exec(middleRowSql(union(), "value", where, range, query.resolution));
827
1025
  const rows = result.dataset.map((r) => [
828
1026
  r[0],
829
1027
  r[1],
@@ -840,17 +1038,13 @@ archive = NO_ARCHIVE) {
840
1038
  // the caller's choice, and first, last and middle_index are samples.
841
1039
  const angularMean = spec.aggregate === "average" &&
842
1040
  isAngular(unitsOf(spec.path, requestedContext));
843
- const aggExpr = angularMean
844
- ? ANGULAR_BUCKET_SQL
845
- : `${aggregateToSql(spec.aggregate)} as agg_value`;
1041
+ const value = angularMean
1042
+ ? ANGULAR_COLUMNS
1043
+ : valueColumns(spec.aggregate);
846
1044
  const sampled = !!query.resolution && query.resolution > 0;
847
- const sql = sampled
848
- ? `SELECT ts, ${aggExpr}, ${HELD_SQL} FROM ${table} WHERE ${where} ${sampleBy(range, query.resolution)} ORDER BY ts`
849
- : `SELECT ts, value FROM ${table} WHERE ${where} ORDER BY ts LIMIT 10000`;
850
- const result = await queryClient.exec(sql);
851
1045
  const dataset = sampled
852
- ? await holdNumericBuckets(result.dataset, seedWhere, seedFrom)
853
- : result.dataset;
1046
+ ? await holdNumericBuckets(await readSampled(base, archived[base], where, range, query.resolution, [value, HELD_COLUMNS]), seedWhere, seedFrom)
1047
+ : await readRaw(base, archived[base], "value", where);
854
1048
  const rows = dataset.map((row) => [
855
1049
  row[0],
856
1050
  angularMean && row.length > 2 && row[1] !== null