signalk-questdb 2.3.2 → 2.5.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 (62) hide show
  1. package/.coderabbit.yaml +17 -2
  2. package/.release-please-manifest.json +1 -1
  3. package/AGENTS.md +80 -0
  4. package/CLAUDE.md +1 -79
  5. package/README.md +121 -9
  6. package/dist/change-gate.d.ts +6 -6
  7. package/dist/change-gate.js +8 -6
  8. package/dist/change-gate.js.map +1 -1
  9. package/dist/config/schema.d.ts +1 -0
  10. package/dist/config/schema.js +6 -1
  11. package/dist/config/schema.js.map +1 -1
  12. package/dist/container-env.js +14 -0
  13. package/dist/container-env.js.map +1 -1
  14. package/dist/drained.d.ts +2 -0
  15. package/dist/drained.js +22 -0
  16. package/dist/drained.js.map +1 -0
  17. package/dist/ilp-writer.d.ts +2 -0
  18. package/dist/ilp-writer.js +48 -13
  19. package/dist/ilp-writer.js.map +1 -1
  20. package/dist/image-refresh.d.ts +35 -0
  21. package/dist/image-refresh.js +118 -0
  22. package/dist/image-refresh.js.map +1 -0
  23. package/dist/index.d.ts +4 -0
  24. package/dist/index.js +502 -149
  25. package/dist/index.js.map +1 -1
  26. package/dist/path-matcher.d.ts +5 -4
  27. package/dist/path-matcher.js +17 -10
  28. package/dist/path-matcher.js.map +1 -1
  29. package/dist/query-client.d.ts +12 -1
  30. package/dist/query-client.js +37 -3
  31. package/dist/query-client.js.map +1 -1
  32. package/dist/questdb-ready.d.ts +21 -0
  33. package/dist/questdb-ready.js +35 -0
  34. package/dist/questdb-ready.js.map +1 -0
  35. package/dist/questdb-watchdog.d.ts +73 -0
  36. package/dist/questdb-watchdog.js +308 -0
  37. package/dist/questdb-watchdog.js.map +1 -0
  38. package/dist/watchdog-checks.d.ts +50 -0
  39. package/dist/watchdog-checks.js +279 -0
  40. package/dist/watchdog-checks.js.map +1 -0
  41. package/package.json +5 -5
  42. package/public/assets/AppPanel-D_HLFa10.js +1 -0
  43. package/public/assets/PluginConfigurationPanel-CAj6IDmY.js +13 -0
  44. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-CTTydXPl.js +1 -0
  45. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__97224418320974__loadShare__shared-BgRwx7LZ.js +1 -0
  46. 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
  47. package/public/assets/{dist-C5M3fhBw.js → dist-B4My1YVh.js} +1 -1
  48. package/public/assets/helpers-PTLizuYm.js +1 -0
  49. package/public/assets/hostInit-Dvn0aCSd.js +1 -0
  50. package/public/assets/{index-D2iWq8nf.js → index-j5iNctbW.js} +2 -2
  51. package/public/assets/pendingShares-BHVf8sM9.js +2 -0
  52. package/public/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  53. package/public/assets/{utils-v9GZlbdk.js → utils-BesU7hIa.js} +1 -1
  54. package/public/assets/{vite-preload-helper-CRzLMJPS.js → vite-preload-helper-DclECHGd.js} +1 -1
  55. package/public/remoteEntry.js +2 -2
  56. package/public/assets/AppPanel-Fvhb8xTK.js +0 -1
  57. package/public/assets/PluginConfigurationPanel-DR-y5e3d.js +0 -11
  58. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BUtmcsA-.js +0 -1
  59. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__196689844091135__loadShare__shared-BRI06Lxu.js +0 -8
  60. package/public/assets/helpers-oIxDNnZJ.js +0 -1
  61. package/public/assets/hostInit-3qWTwrQQ.js +0 -1
  62. package/public/assets/pendingShares-PvyVL1Z8.js +0 -2
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
  }
@@ -514,10 +556,10 @@ export default (app) => {
514
556
  const matches = matcher.matches(path);
515
557
  return mode === "exclude" ? !matches : matches;
516
558
  }
517
- function isThrottled(path, context, defaultRate) {
559
+ function isThrottled(path, context, defaultRate, source) {
518
560
  // Per-path override wins when one matches; otherwise the default rate.
519
561
  const overrideRate = rateMatcher?.rateFor(path) ?? null;
520
- return throttle.shouldDrop(path, context, overrideRate ?? defaultRate, Date.now());
562
+ return throttle.shouldDrop(path, context, overrideRate ?? defaultRate, Date.now(), source);
521
563
  }
522
564
  const WAL_NOTIFICATION_PATH = "notifications.signalk-questdb.walSuspended";
523
565
  // Dedupe key of the last emitted notification. The monitor reports every
@@ -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;
@@ -905,7 +1193,14 @@ export default (app) => {
905
1193
  // restore sees every vessel that transmitted during startup.
906
1194
  liveContexts.clear();
907
1195
  trackLiveContexts = config.restoreOnStart;
908
- const bus = app.streambundle.getBus();
1196
+ // getBus() carries only the source that Signal K's source priorities
1197
+ // prefer on each path, the value instruments show. recordAllSources
1198
+ // reads the unfiltered bus instead, so a source that loses the ranking
1199
+ // is recorded as well: the raw paddlewheel next to a corrected STW
1200
+ // (issue #235).
1201
+ const bus = config.recordAllSources
1202
+ ? app.streambundle.getUnfilteredBus()
1203
+ : app.streambundle.getBus();
909
1204
  const unsub = bus.onValue((delta) => {
910
1205
  if (!writer)
911
1206
  return;
@@ -924,9 +1219,26 @@ export default (app) => {
924
1219
  // syntax. Undefined when the delta carries none — the column stays
925
1220
  // null, same as rows written before it existed.
926
1221
  const source = typeof delta.$source === "string" ? delta.$source : undefined;
1222
+ // Recording every source keeps each one apart. It gets its own
1223
+ // sampling window: the shared one would keep whichever source arrives
1224
+ // first, so the fastest receiver would crowd out the ones recorded to
1225
+ // compare against it. And its nulls forget only its own last values:
1226
+ // a gateway sending "not available" several times a second would
1227
+ // otherwise make every other source's unchanged values due again at
1228
+ // each sampling window instead of each heartbeat.
1229
+ const perSource = config.recordAllSources ? source : undefined;
1230
+ // Meta updates ride the same bus as values, flagged isMeta. They
1231
+ // describe a path (units, description, displayName), they are not a
1232
+ // reading of it. Routed like a value object, their keys were stored
1233
+ // as rows such as `….units` and `….displayName`, again each time a
1234
+ // plugin republished its meta. Nor do they show that a vessel is
1235
+ // transmitting: a local plugin publishes its meta on its own start,
1236
+ // and counting that would cost the restore a vessel's saved position.
1237
+ if (delta.isMeta)
1238
+ return;
927
1239
  // Note the context BEFORE any early return or filter: identity-only
928
1240
  // deltas return below, and path filters and throttles decide what gets
929
- // STORED — but any delta at all proves the vessel is transmitting now,
1241
+ // STORED — but any value at all proves the vessel is transmitting now,
930
1242
  // which is what makes its stored position obsolete for the restore.
931
1243
  if (trackLiveContexts) {
932
1244
  liveContexts.add(context === app.selfContext ? "self" : context);
@@ -969,7 +1281,7 @@ export default (app) => {
969
1281
  // the change gate must forget the path, so the first reading after the
970
1282
  // gap is recorded even when it equals the last one before it.
971
1283
  if (path && value === null) {
972
- changeGate?.forget(path, context === app.selfContext ? "self" : context);
1284
+ changeGate?.forget(path, context === app.selfContext ? "self" : context, perSource);
973
1285
  }
974
1286
  if (!path || value === undefined || value === null)
975
1287
  return;
@@ -1016,13 +1328,14 @@ export default (app) => {
1016
1328
  return;
1017
1329
  // Throttled per path AND context: the sampling rate bounds each
1018
1330
  // vessel's stream, not the fleet's (issue #93). The stored context
1019
- // ("self" vs raw) is the key, matching what the rows carry. The key
1020
- // deliberately does NOT include source: the sampling rate bounds the
1021
- // per-path row volume, and keying per source would multiply it by the
1022
- // number of receivers. With two sources alternating, each window
1023
- // keeps whichever arrived first — a source-filtered read then sees
1024
- // roughly every other sample, which the rate already allows for.
1025
- if (isThrottled(path, isSelf ? "self" : context, config.defaultSamplingRate ?? 2000))
1331
+ // ("self" vs raw) is the key, matching what the rows carry. Unless
1332
+ // recordAllSources asks for every source, the key deliberately does
1333
+ // NOT include source: the sampling rate bounds the per-path row
1334
+ // volume, and keying per source would multiply it by the number of
1335
+ // receivers. With two sources alternating, each window keeps
1336
+ // whichever arrived first — a source-filtered read then sees roughly
1337
+ // every other sample, which the rate already allows for.
1338
+ if (isThrottled(path, isSelf ? "self" : context, config.defaultSamplingRate ?? 2000, perSource))
1026
1339
  return;
1027
1340
  }
1028
1341
  // Rows are stamped with the server receive time, deliberately NOT the
@@ -1077,7 +1390,7 @@ export default (app) => {
1077
1390
  continue;
1078
1391
  if (changeGate?.isRepeat(leaf.path, ctx, source, leaf.value, now))
1079
1392
  continue;
1080
- if (isThrottled(leaf.path, ctx, config.defaultSamplingRate ?? 2000))
1393
+ if (isThrottled(leaf.path, ctx, config.defaultSamplingRate ?? 2000, perSource))
1081
1394
  continue;
1082
1395
  changeGate?.wrote(leaf.path, ctx, source, leaf.value, now);
1083
1396
  if (typeof leaf.value === "number") {
@@ -1129,6 +1442,7 @@ export default (app) => {
1129
1442
  .exec(`ALTER TABLE "${name.replace(/"/g, '""')}" RESUME WAL`, 10_000)
1130
1443
  .then(() => undefined),
1131
1444
  onSuspended: (tables, anyAutoResumeFailed) => {
1445
+ watchdogChecks?.noteSuspended(tables);
1132
1446
  const summary = tables
1133
1447
  .map((t) => `${t.name} (${t.txnLag} txns behind)`)
1134
1448
  .join(", ");
@@ -1148,12 +1462,78 @@ export default (app) => {
1148
1462
  },
1149
1463
  onResolved: () => {
1150
1464
  emitWalNotification("normal", "QuestDB history recording has recovered.", "normal:recovered");
1465
+ if (watchdogStatusShown)
1466
+ return;
1151
1467
  app.setPluginStatus(recordingStatus(ilpHost, ilpPort));
1152
1468
  },
1153
1469
  debug: (msg) => app.debug(msg),
1154
1470
  error: (msg) => app.error(msg),
1155
1471
  });
1156
1472
  walMonitor.start();
1473
+ // Restart QuestDB when it runs out of memory or stops answering: neither
1474
+ // ends the JVM, so the container's restart policy never acts, and both
1475
+ // cost every sample from then on (questdb-watchdog.ts). Managed mode
1476
+ // only: an external QuestDB is not the plugin's to restart.
1477
+ const managedContainers = config.managedContainer !== false
1478
+ ? globalThis.__signalk_containerManager
1479
+ : undefined;
1480
+ if (managedContainers) {
1481
+ // Captured like an update's: stop() and purge bump it, and a restart
1482
+ // that spans one must not start the container the teardown stopped.
1483
+ const generation = lifecycleGeneration;
1484
+ const ended = () => generation !== lifecycleGeneration;
1485
+ const checks = new WatchdogChecks({
1486
+ containers: managedContainers,
1487
+ name: QUESTDB_CONTAINER_NAME,
1488
+ client: () => queryClient,
1489
+ busy: () => legacyCleanupRunning
1490
+ ? "the string table is being rebuilt"
1491
+ : questdbExportsRunning > 0
1492
+ ? "an export is running"
1493
+ : null,
1494
+ epoch: () => containerEpoch,
1495
+ bumpEpoch: () => {
1496
+ containerEpoch++;
1497
+ },
1498
+ ended,
1499
+ endpoint: () => {
1500
+ const http = questdbEndpoints?.http;
1501
+ return http ? `${http.host}:${http.port}` : null;
1502
+ },
1503
+ stops: watchdogStops,
1504
+ reconnectWriter: () => {
1505
+ // A connect that fails leaves the writer's own reconnect with
1506
+ // backoff to it.
1507
+ if (writer && !ended())
1508
+ writer.connect().catch(() => { });
1509
+ },
1510
+ });
1511
+ watchdogChecks = checks;
1512
+ watchdog = new QuestdbWatchdog({
1513
+ exclusive: (fn) => withLifecycleLock(fn),
1514
+ probe: () => checks.probe(),
1515
+ recentErrors: async () => questdbErrorLines((await fetchEngineLogLines()) ?? []),
1516
+ restart: () => checks.restart(),
1517
+ log: (msg) => app.error(msg),
1518
+ status: (msg) => {
1519
+ publishError(msg);
1520
+ watchdogStatusShown = true;
1521
+ },
1522
+ recovered: () => {
1523
+ // Another error published meanwhile is still current.
1524
+ if (pluginErrorActive && !watchdogStatusShown)
1525
+ return;
1526
+ pluginErrorActive = false;
1527
+ watchdogStatusShown = false;
1528
+ const ilp = questdbEndpoints?.ilp;
1529
+ if (ilp)
1530
+ app.setPluginStatus(recordingStatus(ilp.host, ilp.port));
1531
+ },
1532
+ notify: (state, message) => emitWatchdogNotification(state, message),
1533
+ debug: (msg) => app.debug(msg),
1534
+ });
1535
+ watchdog.start();
1536
+ }
1157
1537
  // vm.max_map_count is kernel-global (not namespaced), so in managed mode
1158
1538
  // this local probe reflects the limit the QuestDB container actually runs
1159
1539
  // under. QuestDB memory-maps every partition column file and WAL segment;
@@ -1184,6 +1564,10 @@ export default (app) => {
1184
1564
  // Any earlier return/throw leaves the flag false, so a half-started
1185
1565
  // plugin rejects lifecycle-dependent requests like /api/update/apply.
1186
1566
  pluginRunning = true;
1567
+ // Not awaited: it waits for a free link, and its recreate for the
1568
+ // lifecycle lock this start still holds.
1569
+ if (managedContainers)
1570
+ checkImageAfterPluginUpdate(managedContainers);
1187
1571
  // Repopulate the model from history — only now, because the guard below
1188
1572
  // reads pluginRunning and everything before this point can still throw
1189
1573
  // and abandon the start.
@@ -1295,6 +1679,7 @@ export default (app) => {
1295
1679
  // resurrecting a writer/client after this teardown ran.
1296
1680
  lifecycleGeneration++;
1297
1681
  startAbort?.abort();
1682
+ imageCheckAbort?.abort();
1298
1683
  pluginRunning = false;
1299
1684
  // A start aborted before its restore ran would otherwise leave tracking
1300
1685
  // armed, accumulating contexts for a restore that never happens.
@@ -1332,6 +1717,7 @@ export default (app) => {
1332
1717
  walMonitor = null;
1333
1718
  }
1334
1719
  clearWalAlertOnTeardown();
1720
+ stopWatchdog();
1335
1721
  schemaMismatch = false;
1336
1722
  // Clear the clamp warning so it doesn't survive into the next start
1337
1723
  // (e.g. a switch to external/unmanaged mode that never calls ensureRunning).
@@ -1616,13 +2002,7 @@ export default (app) => {
1616
2002
  res.status(503).json({ error: "QuestDB not connected" });
1617
2003
  return;
1618
2004
  }
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";
2005
+ const currentVersion = (await runningQuestdbVersion(queryClient)) ?? "unknown";
1626
2006
  // Get latest stable release from GitHub
1627
2007
  const ghRes = await fetch("https://api.github.com/repos/questdb/questdb/releases?per_page=5", {
1628
2008
  headers: { Accept: "application/vnd.github+json" },
@@ -1673,6 +2053,13 @@ export default (app) => {
1673
2053
  });
1674
2054
  return;
1675
2055
  }
2056
+ // A start holds the lifecycle lock until QuestDB answers, which it
2057
+ // waits for as long as the plugin runs: queued behind it, this
2058
+ // request would hang until then.
2059
+ if (startAbort) {
2060
+ res.status(409).json({ error: STILL_STARTING });
2061
+ return;
2062
+ }
1676
2063
  // Captured at route entry, before ANY await: a stop() that lands
1677
2064
  // during the release fetch below must already invalidate this
1678
2065
  // update, not just one that lands after the lock is acquired.
@@ -1698,6 +2085,11 @@ export default (app) => {
1698
2085
  return;
1699
2086
  }
1700
2087
  const newTag = stable.tag_name;
2088
+ // Asked again: a restart during the release fetch above starts anew.
2089
+ if (startAbort) {
2090
+ res.status(409).json({ error: STILL_STARTING });
2091
+ return;
2092
+ }
1701
2093
  // Run the mutating update under the lifecycle lock so a concurrent
1702
2094
  // purge/start can't interleave with the pull + recreate + reconnect.
1703
2095
  // The route-entry generation is re-checked at lock entry and after
@@ -1725,93 +2117,7 @@ export default (app) => {
1725
2117
  // recreate without throwing, and persisting newTag now would make
1726
2118
  // the plugin claim a version the still-running old container is not
1727
2119
  // 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
- }
2120
+ const { ilpHost, ilpPort } = await recreateQuestdb(newTag, updateGeneration, containers, { background: false, refresh: false });
1815
2121
  // Persist the new version only now that the recreate is confirmed
1816
2122
  // (a wedge deferred it — the old container is still running).
1817
2123
  if (!containerWedged && currentConfig) {
@@ -2187,6 +2493,7 @@ export default (app) => {
2187
2493
  walMonitor = null;
2188
2494
  }
2189
2495
  clearWalAlertOnTeardown();
2496
+ stopWatchdog();
2190
2497
  if (retention) {
2191
2498
  retention.stop();
2192
2499
  retention = null;
@@ -2241,6 +2548,11 @@ export default (app) => {
2241
2548
  // later wakes up returns early instead of recreating the container.
2242
2549
  lifecycleGeneration++;
2243
2550
  startAbort?.abort();
2551
+ // A check that outlived the purge would download the image again.
2552
+ imageCheckAbort?.abort();
2553
+ // Silences a restart in flight now; the teardown below clears the
2554
+ // alert once it has the lock.
2555
+ watchdog?.stop();
2244
2556
  let lockedTeardownSettled = false;
2245
2557
  let lockedTeardownError;
2246
2558
  const lockedTeardown = withLifecycleLock(teardown).then(() => {
@@ -3165,8 +3477,18 @@ export default (app) => {
3165
3477
  expUrl.searchParams.set("compression_level", String(currentConfig?.compressionLevel ?? 3));
3166
3478
  }
3167
3479
  }
3480
+ // Also bounds the streaming below: a client that stops reading
3481
+ // must not hold the export past it (see drained()). A client that
3482
+ // leaves ends it at once, as in /api/full-export: during a slow
3483
+ // query QuestDB may send nothing for minutes.
3484
+ const clientLeft = new AbortController();
3485
+ res.once("close", () => clientLeft.abort());
3486
+ const exportSignal = AbortSignal.any([
3487
+ AbortSignal.timeout(300000),
3488
+ clientLeft.signal,
3489
+ ]);
3168
3490
  const qdbRes = await fetch(expUrl.toString(), {
3169
- signal: AbortSignal.timeout(300000),
3491
+ signal: exportSignal,
3170
3492
  });
3171
3493
  if (!qdbRes.ok || !qdbRes.body) {
3172
3494
  const body = await qdbRes.text().catch(() => "");
@@ -3177,20 +3499,41 @@ export default (app) => {
3177
3499
  res.setHeader("Content-Disposition", `attachment; filename="signalk-export-${dateSlug}.parquet"`);
3178
3500
  // Stream the response through
3179
3501
  const reader = qdbRes.body.getReader();
3502
+ // Same as /api/full-export: QuestDB is busy until the stream ends.
3503
+ questdbExportsRunning++;
3504
+ let complete = false;
3180
3505
  try {
3181
3506
  while (true) {
3182
3507
  const { done, value } = await reader.read();
3183
- if (done)
3508
+ if (done) {
3509
+ complete = true;
3184
3510
  break;
3185
- if (!res.write(value)) {
3186
- await new Promise((resolve) => res.once("drain", resolve));
3187
3511
  }
3512
+ if (!res.write(value) && !(await drained(res, exportSignal)))
3513
+ break;
3188
3514
  }
3189
- res.end();
3190
3515
  }
3191
3516
  catch (streamErr) {
3192
3517
  app.debug("export stream error:", streamErr);
3193
- res.end();
3518
+ }
3519
+ finally {
3520
+ questdbExportsRunning--;
3521
+ // A stream cut short must not end like a complete one: the
3522
+ // response is chunked, so end() would hand the client a
3523
+ // truncated parquet file under a clean 200.
3524
+ if (complete)
3525
+ res.end();
3526
+ else
3527
+ res.destroy();
3528
+ // A client that left mid-stream ends the loop early: close
3529
+ // QuestDB's side too rather than leave it streaming to nobody.
3530
+ // cancel() throws if the stream already ended; that is fine.
3531
+ try {
3532
+ await reader.cancel();
3533
+ }
3534
+ catch {
3535
+ // already finished
3536
+ }
3194
3537
  }
3195
3538
  }
3196
3539
  else {
@@ -3306,25 +3649,31 @@ export default (app) => {
3306
3649
  }
3307
3650
  // Streaming phase: headers are committed at the first write,
3308
3651
  // 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.
3652
+ // on stream failure is break the connection, so the client sees
3653
+ // the truncation.
3311
3654
  res.setHeader("Content-Type", "application/vnd.apache.parquet");
3312
3655
  res.setHeader("Content-Disposition", `attachment; filename="${table}.parquet"`);
3313
3656
  const reader = qdbRes.body.getReader();
3657
+ // QuestDB answered and streams: its one worker is busy until the
3658
+ // stream ends (see questdbExportsRunning).
3659
+ questdbExportsRunning++;
3660
+ let complete = false;
3314
3661
  try {
3315
3662
  while (true) {
3316
3663
  const { done, value } = await reader.read();
3317
- if (done)
3664
+ if (done) {
3665
+ complete = true;
3318
3666
  break;
3319
- if (!res.write(value)) {
3320
- await new Promise((resolve) => res.once("drain", resolve));
3321
3667
  }
3668
+ if (!res.write(value) && !(await drained(res, controller.signal)))
3669
+ break;
3322
3670
  }
3323
3671
  }
3324
3672
  catch (streamErr) {
3325
3673
  app.debug("full-export stream error:", streamErr);
3326
3674
  }
3327
3675
  finally {
3676
+ questdbExportsRunning--;
3328
3677
  clearTimeout(timeoutId);
3329
3678
  res.removeListener("close", onClientClose);
3330
3679
  // Cancel the upstream reader so QuestDB's connection is
@@ -3337,7 +3686,11 @@ export default (app) => {
3337
3686
  // already finished
3338
3687
  }
3339
3688
  if (!res.writableEnded) {
3340
- res.end();
3689
+ // Same as /api/export: a truncated file must not end cleanly.
3690
+ if (complete)
3691
+ res.end();
3692
+ else
3693
+ res.destroy();
3341
3694
  }
3342
3695
  }
3343
3696
  }