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
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
- import { waitForContainerManager } from "signalk-container-helper";
1
+ import { readFileSync } from "fs";
2
+ import { errMsg, updateImageAfterPluginUpdate, waitForContainerManager, } from "signalk-container-helper";
3
+ import { waitForQuestdb } from "./questdb-ready.js";
2
4
  import { ILPWriter } from "./ilp-writer.js";
5
+ import { refreshImage } from "./image-refresh.js";
3
6
  import { QueryClient, isReadOnlySQL } from "./query-client.js";
4
7
  import { ConfigSchema, normalizeConfig } from "./config/schema.js";
5
8
  import { extractVesselName, flattenObjectValue, routeDeltaValue, UnstorableTracker, } from "./delta-routing.js";
@@ -19,11 +22,16 @@ import { FileCheckpointStore, migrationIdentity, sameIdentity, } from "./migrati
19
22
  import { countLegacyImportRows, removeLegacyImportRows, } from "./migration-cleanup.js";
20
23
  import { adoptDatabaseFromVolumeRoot, wipeDataInSignalk, resolveQuestdbMount, } from "./questdb-mount.js";
21
24
  import { WalMonitor, buildPendingSegmentsSQL, computeSkipPlan, extractApplyError, isPartitionOpenFailure, skipPlansEqual, } from "./wal-monitor.js";
25
+ import { QuestdbWatchdog, questdbErrorLines } from "./questdb-watchdog.js";
26
+ import { WatchdogChecks } from "./watchdog-checks.js";
27
+ import { drained } from "./drained.js";
22
28
  import { QUESTDB_INTERNAL_HTTP_PORT, QUESTDB_INTERNAL_ILP_PORT, QUESTDB_INTERNAL_PG_PORT, QUESTDB_ACCESSIBLE_PORTS, resolveManagedEndpoints, resolveLanExposureHost, lanExposureEndpoints, } from "./questdb-endpoint.js";
23
29
  import { nofileClampSatisfied, readMaxMapCount } from "./host-limits.js";
24
30
  import { buildContainerEnv } from "./container-env.js";
25
31
  import { PathMatcher, RateMatcher, Throttle } from "./path-matcher.js";
26
32
  import { ChangeGate } from "./change-gate.js";
33
+ /** This plugin's own version, which an update of it changes. */
34
+ const PLUGIN_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
27
35
  // signalk-container's API, imported rather than mirrored. The 156-line
28
36
  // hand-written copy that used to live here drifted by construction: it was
29
37
  // only ever updated when this plugin happened to need a new member, so
@@ -45,15 +53,10 @@ const FULL_EXPORT_TABLES = [
45
53
  ARCHIVE_TABLES.signalk_position,
46
54
  ];
47
55
  const FULL_EXPORT_TABLE_SET = new Set(FULL_EXPORT_TABLES);
48
- // Healthcheck for the QuestDB container. The `questdb/questdb` image ships no
49
- // HEALTHCHECK of its own, so under Podman the container would otherwise sit in
50
- // `starting` forever (Podman reports a probeless container as perpetually
51
- // starting, never healthy). We give it an explicit probe — `curl` is present
52
- // in the image — hitting QuestDB's purpose-built `/ping` liveness endpoint,
53
- // which returns an empty `204` immediately. (Probing `/` instead makes curl
54
- // hang on the web-console `301` redirect until it times out.) signalk-container
55
- // emits this as `--health-*` run flags (see its `healthcheck` ContainerConfig
56
- // field).
56
+ /** An update refused while a start holds the lifecycle lock. */
57
+ const STILL_STARTING = "The plugin is still starting QuestDB; try again once it is recording";
58
+ /** How long a retry probes for the LAN-exposure host; the wait itself polls. */
59
+ const RETRY_PROBE_DEADLINE_MS = 5_000;
57
60
  const QUESTDB_HEALTHCHECK = {
58
61
  test: [
59
62
  "CMD",
@@ -153,6 +156,8 @@ export default (app) => {
153
156
  // OLD config/version, so we must not report success or persist a new version
154
157
  // as if the change applied. Cleared at the top of each ensureRunning attempt.
155
158
  let containerWedged = null;
159
+ // The image check after a plugin update, while it waits for a free link.
160
+ let imageCheckAbort = null;
156
161
  // Whether this QuestDB's wal_tables() exposes the errorTag/errorMessage
157
162
  // columns (present on current builds, absent on an older pinned version).
158
163
  // Probed once via the richer status query and cached so an old build does
@@ -162,6 +167,35 @@ export default (app) => {
162
167
  // stall point, and raises/clears the Signal K notification. Lives from a
163
168
  // completed start to the next stop/purge.
164
169
  let walMonitor = null;
170
+ // Restarts a managed QuestDB that ran out of memory or stopped answering
171
+ // (questdb-watchdog.ts). Lives from a completed start to the next
172
+ // stop/purge, managed mode only.
173
+ let watchdog = null;
174
+ // The watchdog's probe and restart, and what they learn from the plugin's
175
+ // own queries (watchdog-checks.ts).
176
+ let watchdogChecks = null;
177
+ // A client reports to the watchdog of the start that created it: a request
178
+ // of an earlier start that ends late tells nothing about the container now.
179
+ const questdbClientOptions = (generation) => ({
180
+ onErrorResponse: (body) => {
181
+ if (generation === lifecycleGeneration)
182
+ watchdogChecks?.noteErrorResponse(body);
183
+ },
184
+ onResponse: () => {
185
+ if (generation === lifecycleGeneration)
186
+ watchdogChecks?.noteResponse();
187
+ },
188
+ });
189
+ // The watchdog's error is the one on the status line, so its recovery may
190
+ // replace it with the recording line. Any other error published since
191
+ // stays.
192
+ let watchdogStatusShown = false;
193
+ // Parquet exports streaming from QuestDB. One can keep QuestDB's single
194
+ // worker busy for minutes, so health checks time out while it streams: the
195
+ // watchdog does not judge QuestDB meanwhile. Counted only once QuestDB has
196
+ // answered, so an export waiting on a QuestDB that answers nothing does
197
+ // not hide it.
198
+ let questdbExportsRunning = 0;
165
199
  // Serializes the lifecycle operations that create or destroy QuestDB
166
200
  // resources — asyncStart, /api/update/apply, and /api/purge-data — so they
167
201
  // can never interleave. Without this, a purge (or update) issued while a
@@ -183,6 +217,9 @@ export default (app) => {
183
217
  // that must not span a container swap (the WAL skip's final guard) needs
184
218
  // this separate epoch to notice one.
185
219
  let containerEpoch = 0;
220
+ // The watchdog's container stops still in flight, across plugin restarts
221
+ // (WatchdogChecksDeps.stops).
222
+ const watchdogStops = { pending: 0 };
186
223
  // True only between a FULLY completed start (providers, stream
187
224
  // subscription, and timers all registered) and the next stop/purge.
188
225
  // queryClient/writer are not usable as a running sentinel: they are
@@ -209,6 +246,7 @@ export default (app) => {
209
246
  let pluginErrorActive = false;
210
247
  const publishError = (msg) => {
211
248
  pluginErrorActive = true;
249
+ watchdogStatusShown = false;
212
250
  app.setPluginError(msg);
213
251
  };
214
252
  // Republish the recording line, but never at the cost of an active error.
@@ -440,12 +478,16 @@ export default (app) => {
440
478
  // by probing, which only works once QuestDB is up. The resolver runs
441
479
  // after ensureRunning() on both call sites, so defer the probe into
442
480
  // the closure (and memoize: the endpoint can't change while the
443
- // container config that produced it is live).
481
+ // container config that produced it is live). Asked `again`, it probes
482
+ // anew: a QuestDB that was not answering yet left the gateway, the
483
+ // fallback, which need not be where it is.
444
484
  let endpoints = null;
445
- return async () => {
446
- if (!endpoints) {
485
+ return async (options) => {
486
+ if (!endpoints || options?.again) {
447
487
  const skHost = config.exposeToContainers
448
- ? await resolveLanExposureHost(containers, httpPort, (msg) => app.debug(msg))
488
+ ? await resolveLanExposureHost(containers, httpPort, (msg) => app.debug(msg), options?.again
489
+ ? { deadlineMs: RETRY_PROBE_DEADLINE_MS }
490
+ : undefined)
449
491
  : "127.0.0.1";
450
492
  endpoints = lanExposureEndpoints(skHost, httpPort, ilpPort);
451
493
  }
@@ -561,6 +603,41 @@ export default (app) => {
561
603
  }
562
604
  lastWalNotification = null;
563
605
  }
606
+ const WATCHDOG_NOTIFICATION_PATH = "notifications.signalk-questdb.watchdog";
607
+ // The watchdog sends each state change once (an alert when it restarts or
608
+ // holds a restart back, normal once QuestDB answers again), so no dedupe.
609
+ function emitWatchdogNotification(state, message) {
610
+ app.handleMessage("signalk-questdb", {
611
+ updates: [
612
+ {
613
+ values: [
614
+ {
615
+ path: WATCHDOG_NOTIFICATION_PATH,
616
+ value: {
617
+ state,
618
+ method: ["visual"],
619
+ message,
620
+ timestamp: new Date().toISOString(),
621
+ },
622
+ },
623
+ ],
624
+ },
625
+ ],
626
+ });
627
+ }
628
+ // Same reason as clearWalAlertOnTeardown: an alert nobody will clear must
629
+ // not outlive the watchdog.
630
+ function stopWatchdog() {
631
+ if (!watchdog)
632
+ return;
633
+ watchdog.stop();
634
+ if (watchdog.alertOutstanding) {
635
+ emitWatchdogNotification("normal", "QuestDB watchdog stopped (plugin stopped or data removed); QuestDB " +
636
+ "is no longer watched.");
637
+ }
638
+ watchdog = null;
639
+ watchdogChecks = null;
640
+ }
564
641
  // wal_tables() probe shared by /api/status, the WAL monitor, and the
565
642
  // resume endpoints. errorTag/errorMessage carry QuestDB's reason for a
566
643
  // suspension when it has one; the columns are absent on an older pinned
@@ -652,6 +729,193 @@ export default (app) => {
652
729
  clearTimeout(timeoutTimer);
653
730
  }
654
731
  }
732
+ // The update button's recreate, wait and reconnect, and the image check's
733
+ // after a plugin update. Runs under the lifecycle lock; `generation` is the
734
+ // one the caller started from, and a stop() or purge that ended it fails
735
+ // the rest. A wedged container defers the recreate: `containerWedged` then
736
+ // says so. `background`, as for the image check, sends progress to the
737
+ // debug log, as no one is waiting on it. With `refresh`, the container is
738
+ // recreated on the image a pull just brought for the tag it already runs,
739
+ // where that differs from the one it runs.
740
+ async function recreateQuestdb(tag, generation, containers, { background, refresh }) {
741
+ const assertNotStopped = () => {
742
+ if (generation !== lifecycleGeneration)
743
+ throw new Error("plugin stopped while the update was running");
744
+ };
745
+ const progress = (line) => {
746
+ if (background)
747
+ app.debug(line);
748
+ else
749
+ app.setPluginStatus(line);
750
+ };
751
+ const updateMount = await resolveQuestdbDataMount(containers);
752
+ progress(`Starting QuestDB ${tag}...`);
753
+ const updateConfig = {
754
+ image: "questdb/questdb",
755
+ tag,
756
+ volumes: updateMount.volumes,
757
+ env: {
758
+ ...buildContainerEnv(currentConfig ?? {}),
759
+ ...updateMount.env,
760
+ },
761
+ restart: "unless-stopped",
762
+ resources: currentConfig ? buildResourceLimits(currentConfig) : undefined,
763
+ ulimits: QUESTDB_ULIMITS,
764
+ healthcheck: QUESTDB_HEALTHCHECK,
765
+ // The tag names what it did before, so only this recreates on the image
766
+ // the pull brought. Through ensureRunning rather than recreate(), which
767
+ // drops the container's addresses and may pick another host port.
768
+ ...(refresh ? { autoUpdateOnFloatingTag: true } : {}),
769
+ };
770
+ const resolveUpdateEndpoints = await applyQuestdbNetworking(currentConfig ?? {}, containers, QUESTDB_CONTAINER_NAME, updateConfig);
771
+ // Clear any prior clamp before re-running so a no-longer-clamping
772
+ // update doesn't leave a stale warning. The recreate must not
773
+ // happen after a stop/purge teardown, so gate it on the
774
+ // generation one more time.
775
+ assertNotStopped();
776
+ ulimitClamp = null;
777
+ containerWedged = null;
778
+ containerEpoch++;
779
+ await containers.ensureRunning(QUESTDB_CONTAINER_NAME, updateConfig, {
780
+ onUlimitClamped,
781
+ onContainerWedged,
782
+ });
783
+ assertNotStopped();
784
+ // The QuestDB version just changed, so the cached wal_tables()
785
+ // column shape may no longer match (the errorTag/errorMessage
786
+ // columns could appear on an upgrade or disappear on a downgrade).
787
+ // Force the next /api/status to re-probe instead of trusting a
788
+ // stale capability flag.
789
+ walTablesHasErrorColumns = null;
790
+ // Re-resolve so the export endpoints and status line reflect the
791
+ // current endpoint. The version bump keeps the same container name
792
+ // and networking, so the endpoint is stable — the existing
793
+ // QueryClient/ILPWriter stay valid (and the registered history
794
+ // providers keep their reference). On the LAN path this await
795
+ // probes for the reachable host, so it's long enough for a stop()
796
+ // to land mid-flight — re-check before touching plugin state.
797
+ const updatedEndpoints = await resolveUpdateEndpoints();
798
+ assertNotStopped();
799
+ questdbEndpoints = updatedEndpoints;
800
+ const { host: ilpHost, port: ilpPort } = questdbEndpoints.ilp;
801
+ // Wait for the recreated container to answer, then reconnect ILP.
802
+ // Locals are captured because stop() nulls the module bindings:
803
+ // reading them again mid-loop would throw, and the generation
804
+ // checks after every await are what keep a concurrent teardown
805
+ // from being resurrected by the reconnect.
806
+ const client = queryClient;
807
+ if (client) {
808
+ const deadline = Date.now() + 30000;
809
+ while (Date.now() < deadline) {
810
+ assertNotStopped();
811
+ if (await client.isHealthy())
812
+ break;
813
+ await new Promise((resolve) => setTimeout(resolve, 500));
814
+ }
815
+ }
816
+ assertNotStopped();
817
+ const ilpWriter = writer;
818
+ if (ilpWriter) {
819
+ try {
820
+ await ilpWriter.disconnect();
821
+ }
822
+ catch {
823
+ /* ignore */
824
+ }
825
+ assertNotStopped();
826
+ await ilpWriter.connect();
827
+ // A stop() that landed during connect() has already nulled
828
+ // `writer`, so nothing would ever disconnect this fresh
829
+ // connection — close it ourselves before failing the update.
830
+ if (generation !== lifecycleGeneration) {
831
+ try {
832
+ await ilpWriter.disconnect();
833
+ }
834
+ catch {
835
+ /* ignore */
836
+ }
837
+ assertNotStopped();
838
+ }
839
+ }
840
+ return { ilpHost, ilpPort };
841
+ }
842
+ /** The running QuestDB's version, from `build()`; null where it does not say. */
843
+ async function runningQuestdbVersion(client) {
844
+ const result = await client.exec("SELECT build()");
845
+ const build = result.dataset.length > 0 ? String(result.dataset[0][0]) : "";
846
+ return build.match(/QuestDB\s+([\d.]+)/)?.[1] ?? null;
847
+ }
848
+ // After the plugin itself was updated, its first start moves QuestDB on a
849
+ // floating tag such as `latest` to the latest release, once, on a free
850
+ // link. signalk-container pulls an image only when it is missing, so such
851
+ // a tag otherwise stays on whatever release the first install found.
852
+ function checkImageAfterPluginUpdate(containers) {
853
+ // One check at a time; stop() aborts it too.
854
+ imageCheckAbort?.abort();
855
+ const abort = new AbortController();
856
+ imageCheckAbort = abort;
857
+ const generation = lifecycleGeneration;
858
+ const tag = currentConfig?.questdbVersion ?? "latest";
859
+ updateImageAfterPluginUpdate({
860
+ manager: containers,
861
+ pluginVersion: PLUGIN_VERSION,
862
+ // Beside the data directory, which is QuestDB's own.
863
+ stateFile: `${app.getDataDirPath()}.image-check.json`,
864
+ tag,
865
+ versionSource: { githubReleases: "questdb/questdb" },
866
+ currentVersion: async () => queryClient ? runningQuestdbVersion(queryClient) : null,
867
+ update: (signal) => refreshImage({
868
+ containers,
869
+ name: QUESTDB_CONTAINER_NAME,
870
+ image: "questdb/questdb",
871
+ withLock: withLifecycleLock,
872
+ current: () => generation === lifecycleGeneration && pluginRunning,
873
+ configuredTag: () => currentConfig?.questdbVersion ?? "latest",
874
+ // On a link that was metered, the check may come hours later, in
875
+ // the middle of work the recreate would break.
876
+ blocked: () => legacyCleanupRunning
877
+ ? "the string table is being rebuilt"
878
+ : containerWedged
879
+ ? "the container is wedged"
880
+ : activeMigration?.state === "running"
881
+ ? "an InfluxDB import is running"
882
+ : gpxImports.active
883
+ ? "a GPX import is running"
884
+ : questdbExportsRunning > 0
885
+ ? "an export is running"
886
+ : null,
887
+ recreate: async (newTag) => {
888
+ await recreateQuestdb(newTag, generation, containers, {
889
+ background: true,
890
+ refresh: true,
891
+ });
892
+ },
893
+ restore: async (newTag) => {
894
+ await recreateQuestdb(newTag, generation, containers, {
895
+ background: true,
896
+ refresh: false,
897
+ });
898
+ },
899
+ debug: (msg) => app.debug(msg),
900
+ error: (msg) => app.error(msg),
901
+ reportDown: (msg) => {
902
+ app.error(msg);
903
+ publishError(msg);
904
+ },
905
+ }, tag, signal),
906
+ signal: abort.signal,
907
+ })
908
+ .then((outcome) => app.debug(`QuestDB image check after the plugin update to ${PLUGIN_VERSION}: ${outcome}`))
909
+ .catch((err) => {
910
+ if (abort.signal.aborted)
911
+ return;
912
+ app.error(`QuestDB image check after the plugin update to ${PLUGIN_VERSION} failed: ${errMsg(err)}`);
913
+ })
914
+ .finally(() => {
915
+ if (imageCheckAbort === abort)
916
+ imageCheckAbort = null;
917
+ });
918
+ }
655
919
  // Runs the actual startup. Always invoked through the lifecycle lock (see
656
920
  // asyncStart) so a purge/update can't interleave with it.
657
921
  //
@@ -711,6 +975,8 @@ export default (app) => {
711
975
  port: config.questdbIlpPort ?? QUESTDB_INTERNAL_ILP_PORT,
712
976
  },
713
977
  };
978
+ // Managed mode: where the container is now, which a recreate can change.
979
+ let refreshEndpoints = null;
714
980
  if (config.managedContainer !== false) {
715
981
  // Two phases in one call: poll for the manager global, then wait for
716
982
  // runtime detection to settle. The previous code read the global ONCE,
@@ -794,6 +1060,7 @@ export default (app) => {
794
1060
  const resolveEndpoints = await applyQuestdbNetworking(config, containers, QUESTDB_CONTAINER_NAME, containerConfig);
795
1061
  if (signal.aborted)
796
1062
  return;
1063
+ refreshEndpoints = resolveEndpoints;
797
1064
  app.setPluginStatus("Starting QuestDB container...");
798
1065
  // Clear any prior clamp so a run that no longer clamps (e.g. the host
799
1066
  // limit was raised) doesn't leave a stale warning; onUlimitClamped
@@ -824,29 +1091,47 @@ export default (app) => {
824
1091
  return;
825
1092
  }
826
1093
  }
827
- const { host: httpHost, port: httpPort } = questdbEndpoints.http;
828
- const { host: ilpHost, port: ilpPort } = questdbEndpoints.ilp;
1094
+ let { host: httpHost, port: httpPort } = questdbEndpoints.http;
829
1095
  app.debug("connecting to QuestDB at %s:%d", httpHost, httpPort);
830
- queryClient = new QueryClient(httpHost, httpPort);
1096
+ queryClient = new QueryClient(httpHost, httpPort, questdbClientOptions(lifecycleGeneration));
831
1097
  app.setPluginStatus("Waiting for QuestDB to become ready...");
832
- const deadline = Date.now() + 30000;
833
- while (Date.now() < deadline) {
834
- if (signal.aborted) {
835
- queryClient = null;
836
- return;
837
- }
838
- if (await queryClient.isHealthy())
839
- break;
840
- await new Promise((resolve) => setTimeout(resolve, 500));
1098
+ let questdb = queryClient;
1099
+ let notResponding = false;
1100
+ const refresh = refreshEndpoints;
1101
+ try {
1102
+ await waitForQuestdb(() => questdb.isHealthy(), {
1103
+ signal,
1104
+ // A container recreated during the wait can come back on another port.
1105
+ beforeRetry: refresh
1106
+ ? async () => {
1107
+ const resolved = await refresh({ again: true });
1108
+ if (signal.aborted)
1109
+ return;
1110
+ questdbEndpoints = resolved;
1111
+ ({ host: httpHost, port: httpPort } = resolved.http);
1112
+ questdb = queryClient = new QueryClient(httpHost, httpPort, questdbClientOptions(lifecycleGeneration));
1113
+ }
1114
+ : undefined,
1115
+ onAttemptFailed: (err, nextDelayMs) => {
1116
+ app.debug(`QuestDB readiness attempt failed: ${errMsg(err)}`);
1117
+ notResponding = true;
1118
+ publishError(`QuestDB not responding at ${httpHost}:${httpPort} — retrying in ${Math.round(nextDelayMs / 1000)}s`);
1119
+ },
1120
+ });
1121
+ }
1122
+ catch (err) {
1123
+ if (!signal.aborted)
1124
+ throw err;
841
1125
  }
842
1126
  if (signal.aborted) {
843
1127
  queryClient = null;
844
1128
  return;
845
1129
  }
846
- if (!(await queryClient.isHealthy())) {
847
- publishError(`QuestDB not responding at ${httpHost}:${httpPort}`);
848
- return;
849
- }
1130
+ // It answers now: the error was this wait's own, and must not hold back
1131
+ // the recording line once the writer is up.
1132
+ if (notResponding)
1133
+ pluginErrorActive = false;
1134
+ const { host: ilpHost, port: ilpPort } = questdbEndpoints.ilp;
850
1135
  app.setPluginStatus("Creating tables...");
851
1136
  // A migration that degrades (dedup off on an external table) is worth a
852
1137
  // line in the server log, but must never abort startup — see ensureTables.
@@ -872,6 +1157,9 @@ export default (app) => {
872
1157
  // stuck on a cheerful "Recording" while every sample is dropped.
873
1158
  onUnhealthy: (msg) => publishError(msg),
874
1159
  onHealthy: () => {
1160
+ // The watchdog's error stays until the watchdog clears it.
1161
+ if (watchdogStatusShown)
1162
+ return;
875
1163
  // The writer recovered: the error is no longer current, so the
876
1164
  // recording line may take the status entry back.
877
1165
  pluginErrorActive = false;
@@ -1129,6 +1417,7 @@ export default (app) => {
1129
1417
  .exec(`ALTER TABLE "${name.replace(/"/g, '""')}" RESUME WAL`, 10_000)
1130
1418
  .then(() => undefined),
1131
1419
  onSuspended: (tables, anyAutoResumeFailed) => {
1420
+ watchdogChecks?.noteSuspended(tables);
1132
1421
  const summary = tables
1133
1422
  .map((t) => `${t.name} (${t.txnLag} txns behind)`)
1134
1423
  .join(", ");
@@ -1148,12 +1437,78 @@ export default (app) => {
1148
1437
  },
1149
1438
  onResolved: () => {
1150
1439
  emitWalNotification("normal", "QuestDB history recording has recovered.", "normal:recovered");
1440
+ if (watchdogStatusShown)
1441
+ return;
1151
1442
  app.setPluginStatus(recordingStatus(ilpHost, ilpPort));
1152
1443
  },
1153
1444
  debug: (msg) => app.debug(msg),
1154
1445
  error: (msg) => app.error(msg),
1155
1446
  });
1156
1447
  walMonitor.start();
1448
+ // Restart QuestDB when it runs out of memory or stops answering: neither
1449
+ // ends the JVM, so the container's restart policy never acts, and both
1450
+ // cost every sample from then on (questdb-watchdog.ts). Managed mode
1451
+ // only: an external QuestDB is not the plugin's to restart.
1452
+ const managedContainers = config.managedContainer !== false
1453
+ ? globalThis.__signalk_containerManager
1454
+ : undefined;
1455
+ if (managedContainers) {
1456
+ // Captured like an update's: stop() and purge bump it, and a restart
1457
+ // that spans one must not start the container the teardown stopped.
1458
+ const generation = lifecycleGeneration;
1459
+ const ended = () => generation !== lifecycleGeneration;
1460
+ const checks = new WatchdogChecks({
1461
+ containers: managedContainers,
1462
+ name: QUESTDB_CONTAINER_NAME,
1463
+ client: () => queryClient,
1464
+ busy: () => legacyCleanupRunning
1465
+ ? "the string table is being rebuilt"
1466
+ : questdbExportsRunning > 0
1467
+ ? "an export is running"
1468
+ : null,
1469
+ epoch: () => containerEpoch,
1470
+ bumpEpoch: () => {
1471
+ containerEpoch++;
1472
+ },
1473
+ ended,
1474
+ endpoint: () => {
1475
+ const http = questdbEndpoints?.http;
1476
+ return http ? `${http.host}:${http.port}` : null;
1477
+ },
1478
+ stops: watchdogStops,
1479
+ reconnectWriter: () => {
1480
+ // A connect that fails leaves the writer's own reconnect with
1481
+ // backoff to it.
1482
+ if (writer && !ended())
1483
+ writer.connect().catch(() => { });
1484
+ },
1485
+ });
1486
+ watchdogChecks = checks;
1487
+ watchdog = new QuestdbWatchdog({
1488
+ exclusive: (fn) => withLifecycleLock(fn),
1489
+ probe: () => checks.probe(),
1490
+ recentErrors: async () => questdbErrorLines((await fetchEngineLogLines()) ?? []),
1491
+ restart: () => checks.restart(),
1492
+ log: (msg) => app.error(msg),
1493
+ status: (msg) => {
1494
+ publishError(msg);
1495
+ watchdogStatusShown = true;
1496
+ },
1497
+ recovered: () => {
1498
+ // Another error published meanwhile is still current.
1499
+ if (pluginErrorActive && !watchdogStatusShown)
1500
+ return;
1501
+ pluginErrorActive = false;
1502
+ watchdogStatusShown = false;
1503
+ const ilp = questdbEndpoints?.ilp;
1504
+ if (ilp)
1505
+ app.setPluginStatus(recordingStatus(ilp.host, ilp.port));
1506
+ },
1507
+ notify: (state, message) => emitWatchdogNotification(state, message),
1508
+ debug: (msg) => app.debug(msg),
1509
+ });
1510
+ watchdog.start();
1511
+ }
1157
1512
  // vm.max_map_count is kernel-global (not namespaced), so in managed mode
1158
1513
  // this local probe reflects the limit the QuestDB container actually runs
1159
1514
  // under. QuestDB memory-maps every partition column file and WAL segment;
@@ -1184,6 +1539,10 @@ export default (app) => {
1184
1539
  // Any earlier return/throw leaves the flag false, so a half-started
1185
1540
  // plugin rejects lifecycle-dependent requests like /api/update/apply.
1186
1541
  pluginRunning = true;
1542
+ // Not awaited: it waits for a free link, and its recreate for the
1543
+ // lifecycle lock this start still holds.
1544
+ if (managedContainers)
1545
+ checkImageAfterPluginUpdate(managedContainers);
1187
1546
  // Repopulate the model from history — only now, because the guard below
1188
1547
  // reads pluginRunning and everything before this point can still throw
1189
1548
  // and abandon the start.
@@ -1295,6 +1654,7 @@ export default (app) => {
1295
1654
  // resurrecting a writer/client after this teardown ran.
1296
1655
  lifecycleGeneration++;
1297
1656
  startAbort?.abort();
1657
+ imageCheckAbort?.abort();
1298
1658
  pluginRunning = false;
1299
1659
  // A start aborted before its restore ran would otherwise leave tracking
1300
1660
  // armed, accumulating contexts for a restore that never happens.
@@ -1332,6 +1692,7 @@ export default (app) => {
1332
1692
  walMonitor = null;
1333
1693
  }
1334
1694
  clearWalAlertOnTeardown();
1695
+ stopWatchdog();
1335
1696
  schemaMismatch = false;
1336
1697
  // Clear the clamp warning so it doesn't survive into the next start
1337
1698
  // (e.g. a switch to external/unmanaged mode that never calls ensureRunning).
@@ -1616,13 +1977,7 @@ export default (app) => {
1616
1977
  res.status(503).json({ error: "QuestDB not connected" });
1617
1978
  return;
1618
1979
  }
1619
- // Get running QuestDB version via SQL
1620
- const buildResult = await queryClient.exec("SELECT build()");
1621
- const buildStr = buildResult.dataset.length > 0
1622
- ? buildResult.dataset[0][0]
1623
- : "";
1624
- const versionMatch = buildStr.match(/QuestDB\s+([\d.]+)/);
1625
- const currentVersion = versionMatch ? versionMatch[1] : "unknown";
1980
+ const currentVersion = (await runningQuestdbVersion(queryClient)) ?? "unknown";
1626
1981
  // Get latest stable release from GitHub
1627
1982
  const ghRes = await fetch("https://api.github.com/repos/questdb/questdb/releases?per_page=5", {
1628
1983
  headers: { Accept: "application/vnd.github+json" },
@@ -1673,6 +2028,13 @@ export default (app) => {
1673
2028
  });
1674
2029
  return;
1675
2030
  }
2031
+ // A start holds the lifecycle lock until QuestDB answers, which it
2032
+ // waits for as long as the plugin runs: queued behind it, this
2033
+ // request would hang until then.
2034
+ if (startAbort) {
2035
+ res.status(409).json({ error: STILL_STARTING });
2036
+ return;
2037
+ }
1676
2038
  // Captured at route entry, before ANY await: a stop() that lands
1677
2039
  // during the release fetch below must already invalidate this
1678
2040
  // update, not just one that lands after the lock is acquired.
@@ -1698,6 +2060,11 @@ export default (app) => {
1698
2060
  return;
1699
2061
  }
1700
2062
  const newTag = stable.tag_name;
2063
+ // Asked again: a restart during the release fetch above starts anew.
2064
+ if (startAbort) {
2065
+ res.status(409).json({ error: STILL_STARTING });
2066
+ return;
2067
+ }
1701
2068
  // Run the mutating update under the lifecycle lock so a concurrent
1702
2069
  // purge/start can't interleave with the pull + recreate + reconnect.
1703
2070
  // The route-entry generation is re-checked at lock entry and after
@@ -1725,93 +2092,7 @@ export default (app) => {
1725
2092
  // recreate without throwing, and persisting newTag now would make
1726
2093
  // the plugin claim a version the still-running old container is not
1727
2094
  // actually on, surviving restarts.
1728
- const updateMount = await resolveQuestdbDataMount(containers);
1729
- app.setPluginStatus(`Starting QuestDB ${newTag}...`);
1730
- const updateConfig = {
1731
- image: "questdb/questdb",
1732
- tag: newTag,
1733
- volumes: updateMount.volumes,
1734
- env: {
1735
- ...buildContainerEnv(currentConfig ?? {}),
1736
- ...updateMount.env,
1737
- },
1738
- restart: "unless-stopped",
1739
- resources: currentConfig
1740
- ? buildResourceLimits(currentConfig)
1741
- : undefined,
1742
- ulimits: QUESTDB_ULIMITS,
1743
- healthcheck: QUESTDB_HEALTHCHECK,
1744
- };
1745
- const resolveUpdateEndpoints = await applyQuestdbNetworking(currentConfig ?? {}, containers, QUESTDB_CONTAINER_NAME, updateConfig);
1746
- // Clear any prior clamp before re-running so a no-longer-clamping
1747
- // update doesn't leave a stale warning. The recreate must not
1748
- // happen after a stop/purge teardown, so gate it on the
1749
- // generation one more time.
1750
- assertNotStopped();
1751
- ulimitClamp = null;
1752
- containerWedged = null;
1753
- containerEpoch++;
1754
- await containers.ensureRunning(QUESTDB_CONTAINER_NAME, updateConfig, {
1755
- onUlimitClamped,
1756
- onContainerWedged,
1757
- });
1758
- assertNotStopped();
1759
- // The QuestDB version just changed, so the cached wal_tables()
1760
- // column shape may no longer match (the errorTag/errorMessage
1761
- // columns could appear on an upgrade or disappear on a downgrade).
1762
- // Force the next /api/status to re-probe instead of trusting a
1763
- // stale capability flag.
1764
- walTablesHasErrorColumns = null;
1765
- // Re-resolve so the export endpoints and status line reflect the
1766
- // current endpoint. The version bump keeps the same container name
1767
- // and networking, so the endpoint is stable — the existing
1768
- // QueryClient/ILPWriter stay valid (and the registered history
1769
- // providers keep their reference). On the LAN path this await
1770
- // probes for the reachable host, so it's long enough for a stop()
1771
- // to land mid-flight — re-check before touching plugin state.
1772
- const updatedEndpoints = await resolveUpdateEndpoints();
1773
- assertNotStopped();
1774
- questdbEndpoints = updatedEndpoints;
1775
- const { host: ilpHost, port: ilpPort } = questdbEndpoints.ilp;
1776
- // Wait for the recreated container to answer, then reconnect ILP.
1777
- // Locals are captured because stop() nulls the module bindings:
1778
- // reading them again mid-loop would throw, and the generation
1779
- // checks after every await are what keep a concurrent teardown
1780
- // from being resurrected by the reconnect.
1781
- const client = queryClient;
1782
- if (client) {
1783
- const deadline = Date.now() + 30000;
1784
- while (Date.now() < deadline) {
1785
- assertNotStopped();
1786
- if (await client.isHealthy())
1787
- break;
1788
- await new Promise((resolve) => setTimeout(resolve, 500));
1789
- }
1790
- }
1791
- assertNotStopped();
1792
- const ilpWriter = writer;
1793
- if (ilpWriter) {
1794
- try {
1795
- await ilpWriter.disconnect();
1796
- }
1797
- catch {
1798
- /* ignore */
1799
- }
1800
- assertNotStopped();
1801
- await ilpWriter.connect();
1802
- // A stop() that landed during connect() has already nulled
1803
- // `writer`, so nothing would ever disconnect this fresh
1804
- // connection — close it ourselves before failing the update.
1805
- if (updateGeneration !== lifecycleGeneration) {
1806
- try {
1807
- await ilpWriter.disconnect();
1808
- }
1809
- catch {
1810
- /* ignore */
1811
- }
1812
- assertNotStopped();
1813
- }
1814
- }
2095
+ const { ilpHost, ilpPort } = await recreateQuestdb(newTag, updateGeneration, containers, { background: false, refresh: false });
1815
2096
  // Persist the new version only now that the recreate is confirmed
1816
2097
  // (a wedge deferred it — the old container is still running).
1817
2098
  if (!containerWedged && currentConfig) {
@@ -2187,6 +2468,7 @@ export default (app) => {
2187
2468
  walMonitor = null;
2188
2469
  }
2189
2470
  clearWalAlertOnTeardown();
2471
+ stopWatchdog();
2190
2472
  if (retention) {
2191
2473
  retention.stop();
2192
2474
  retention = null;
@@ -2241,6 +2523,11 @@ export default (app) => {
2241
2523
  // later wakes up returns early instead of recreating the container.
2242
2524
  lifecycleGeneration++;
2243
2525
  startAbort?.abort();
2526
+ // A check that outlived the purge would download the image again.
2527
+ imageCheckAbort?.abort();
2528
+ // Silences a restart in flight now; the teardown below clears the
2529
+ // alert once it has the lock.
2530
+ watchdog?.stop();
2244
2531
  let lockedTeardownSettled = false;
2245
2532
  let lockedTeardownError;
2246
2533
  const lockedTeardown = withLifecycleLock(teardown).then(() => {
@@ -3165,8 +3452,18 @@ export default (app) => {
3165
3452
  expUrl.searchParams.set("compression_level", String(currentConfig?.compressionLevel ?? 3));
3166
3453
  }
3167
3454
  }
3455
+ // Also bounds the streaming below: a client that stops reading
3456
+ // must not hold the export past it (see drained()). A client that
3457
+ // leaves ends it at once, as in /api/full-export: during a slow
3458
+ // query QuestDB may send nothing for minutes.
3459
+ const clientLeft = new AbortController();
3460
+ res.once("close", () => clientLeft.abort());
3461
+ const exportSignal = AbortSignal.any([
3462
+ AbortSignal.timeout(300000),
3463
+ clientLeft.signal,
3464
+ ]);
3168
3465
  const qdbRes = await fetch(expUrl.toString(), {
3169
- signal: AbortSignal.timeout(300000),
3466
+ signal: exportSignal,
3170
3467
  });
3171
3468
  if (!qdbRes.ok || !qdbRes.body) {
3172
3469
  const body = await qdbRes.text().catch(() => "");
@@ -3177,20 +3474,41 @@ export default (app) => {
3177
3474
  res.setHeader("Content-Disposition", `attachment; filename="signalk-export-${dateSlug}.parquet"`);
3178
3475
  // Stream the response through
3179
3476
  const reader = qdbRes.body.getReader();
3477
+ // Same as /api/full-export: QuestDB is busy until the stream ends.
3478
+ questdbExportsRunning++;
3479
+ let complete = false;
3180
3480
  try {
3181
3481
  while (true) {
3182
3482
  const { done, value } = await reader.read();
3183
- if (done)
3483
+ if (done) {
3484
+ complete = true;
3184
3485
  break;
3185
- if (!res.write(value)) {
3186
- await new Promise((resolve) => res.once("drain", resolve));
3187
3486
  }
3487
+ if (!res.write(value) && !(await drained(res, exportSignal)))
3488
+ break;
3188
3489
  }
3189
- res.end();
3190
3490
  }
3191
3491
  catch (streamErr) {
3192
3492
  app.debug("export stream error:", streamErr);
3193
- res.end();
3493
+ }
3494
+ finally {
3495
+ questdbExportsRunning--;
3496
+ // A stream cut short must not end like a complete one: the
3497
+ // response is chunked, so end() would hand the client a
3498
+ // truncated parquet file under a clean 200.
3499
+ if (complete)
3500
+ res.end();
3501
+ else
3502
+ res.destroy();
3503
+ // A client that left mid-stream ends the loop early: close
3504
+ // QuestDB's side too rather than leave it streaming to nobody.
3505
+ // cancel() throws if the stream already ended; that is fine.
3506
+ try {
3507
+ await reader.cancel();
3508
+ }
3509
+ catch {
3510
+ // already finished
3511
+ }
3194
3512
  }
3195
3513
  }
3196
3514
  else {
@@ -3306,25 +3624,31 @@ export default (app) => {
3306
3624
  }
3307
3625
  // Streaming phase: headers are committed at the first write,
3308
3626
  // so we can no longer switch to a JSON error. Best we can do
3309
- // on stream failure is end the response and let the client
3310
- // notice the truncation.
3627
+ // on stream failure is break the connection, so the client sees
3628
+ // the truncation.
3311
3629
  res.setHeader("Content-Type", "application/vnd.apache.parquet");
3312
3630
  res.setHeader("Content-Disposition", `attachment; filename="${table}.parquet"`);
3313
3631
  const reader = qdbRes.body.getReader();
3632
+ // QuestDB answered and streams: its one worker is busy until the
3633
+ // stream ends (see questdbExportsRunning).
3634
+ questdbExportsRunning++;
3635
+ let complete = false;
3314
3636
  try {
3315
3637
  while (true) {
3316
3638
  const { done, value } = await reader.read();
3317
- if (done)
3639
+ if (done) {
3640
+ complete = true;
3318
3641
  break;
3319
- if (!res.write(value)) {
3320
- await new Promise((resolve) => res.once("drain", resolve));
3321
3642
  }
3643
+ if (!res.write(value) && !(await drained(res, controller.signal)))
3644
+ break;
3322
3645
  }
3323
3646
  }
3324
3647
  catch (streamErr) {
3325
3648
  app.debug("full-export stream error:", streamErr);
3326
3649
  }
3327
3650
  finally {
3651
+ questdbExportsRunning--;
3328
3652
  clearTimeout(timeoutId);
3329
3653
  res.removeListener("close", onClientClose);
3330
3654
  // Cancel the upstream reader so QuestDB's connection is
@@ -3337,7 +3661,11 @@ export default (app) => {
3337
3661
  // already finished
3338
3662
  }
3339
3663
  if (!res.writableEnded) {
3340
- res.end();
3664
+ // Same as /api/export: a truncated file must not end cleanly.
3665
+ if (complete)
3666
+ res.end();
3667
+ else
3668
+ res.destroy();
3341
3669
  }
3342
3670
  }
3343
3671
  }