signalk-questdb 2.3.2 → 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.
- package/.release-please-manifest.json +1 -1
- package/README.md +92 -0
- package/dist/container-env.js +14 -0
- package/dist/container-env.js.map +1 -1
- package/dist/drained.d.ts +2 -0
- package/dist/drained.js +22 -0
- package/dist/drained.js.map +1 -0
- package/dist/ilp-writer.d.ts +2 -0
- package/dist/ilp-writer.js +48 -13
- package/dist/ilp-writer.js.map +1 -1
- package/dist/image-refresh.d.ts +35 -0
- package/dist/image-refresh.js +118 -0
- package/dist/image-refresh.js.map +1 -0
- package/dist/index.js +464 -136
- package/dist/index.js.map +1 -1
- package/dist/query-client.d.ts +12 -1
- package/dist/query-client.js +37 -3
- package/dist/query-client.js.map +1 -1
- package/dist/questdb-ready.d.ts +21 -0
- package/dist/questdb-ready.js +35 -0
- package/dist/questdb-ready.js.map +1 -0
- package/dist/questdb-watchdog.d.ts +73 -0
- package/dist/questdb-watchdog.js +308 -0
- package/dist/questdb-watchdog.js.map +1 -0
- package/dist/watchdog-checks.d.ts +50 -0
- package/dist/watchdog-checks.js +279 -0
- package/dist/watchdog-checks.js.map +1 -0
- package/package.json +3 -3
- package/public/assets/AppPanel-C293yEHl.js +1 -0
- package/public/assets/{PluginConfigurationPanel-DR-y5e3d.js → PluginConfigurationPanel-7qfy8fIR.js} +1 -1
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BH9mY6vj.js +1 -0
- package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__97224418320974__loadShare__shared-H1lSL0nN.js +1 -0
- 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
- package/public/assets/{dist-C5M3fhBw.js → dist-BhVCbinB.js} +1 -1
- package/public/assets/helpers-CvnX61Ju.js +1 -0
- package/public/assets/{hostInit-3qWTwrQQ.js → hostInit-Ds8KD_fS.js} +1 -1
- package/public/assets/{index-D2iWq8nf.js → index-QJC86Uvi.js} +2 -2
- package/public/assets/{pendingShares-PvyVL1Z8.js → pendingShares-DemvETkM.js} +2 -2
- package/public/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/public/assets/{utils-v9GZlbdk.js → utils-D62kKhzq.js} +1 -1
- package/public/remoteEntry.js +2 -2
- package/public/assets/AppPanel-Fvhb8xTK.js +0 -1
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BUtmcsA-.js +0 -1
- package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__196689844091135__loadShare__shared-BRI06Lxu.js +0 -8
- package/public/assets/helpers-oIxDNnZJ.js +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
3310
|
-
//
|
|
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
|
-
|
|
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
|
}
|