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.
- package/.coderabbit.yaml +17 -2
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +80 -0
- package/CLAUDE.md +1 -79
- package/README.md +121 -9
- package/dist/change-gate.d.ts +6 -6
- package/dist/change-gate.js +8 -6
- package/dist/change-gate.js.map +1 -1
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +6 -1
- package/dist/config/schema.js.map +1 -1
- 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.d.ts +4 -0
- package/dist/index.js +502 -149
- package/dist/index.js.map +1 -1
- package/dist/path-matcher.d.ts +5 -4
- package/dist/path-matcher.js +17 -10
- package/dist/path-matcher.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 +5 -5
- package/public/assets/AppPanel-D_HLFa10.js +1 -0
- package/public/assets/PluginConfigurationPanel-CAj6IDmY.js +13 -0
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-CTTydXPl.js +1 -0
- package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__97224418320974__loadShare__shared-BgRwx7LZ.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-B4My1YVh.js} +1 -1
- package/public/assets/helpers-PTLizuYm.js +1 -0
- package/public/assets/hostInit-Dvn0aCSd.js +1 -0
- package/public/assets/{index-D2iWq8nf.js → index-j5iNctbW.js} +2 -2
- package/public/assets/pendingShares-BHVf8sM9.js +2 -0
- package/public/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/public/assets/{utils-v9GZlbdk.js → utils-BesU7hIa.js} +1 -1
- package/public/assets/{vite-preload-helper-CRzLMJPS.js → vite-preload-helper-DclECHGd.js} +1 -1
- package/public/remoteEntry.js +2 -2
- package/public/assets/AppPanel-Fvhb8xTK.js +0 -1
- package/public/assets/PluginConfigurationPanel-DR-y5e3d.js +0 -11
- 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/public/assets/hostInit-3qWTwrQQ.js +0 -1
- package/public/assets/pendingShares-PvyVL1Z8.js +0 -2
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
|
}
|
|
@@ -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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
3310
|
-
//
|
|
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
|
-
|
|
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
|
}
|