signalk-questdb 1.5.2 → 1.5.4
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/README.md +29 -0
- package/dist/history-v2.js +32 -2
- package/dist/history-v2.js.map +1 -1
- package/dist/host-limits.d.ts +9 -0
- package/dist/host-limits.js +58 -0
- package/dist/host-limits.js.map +1 -0
- package/dist/index.js +435 -49
- package/dist/index.js.map +1 -1
- package/dist/query-client.d.ts +1 -1
- package/dist/query-client.js +13 -2
- package/dist/query-client.js.map +1 -1
- package/dist/test/history-v2.test.js +93 -0
- package/dist/test/history-v2.test.js.map +1 -1
- package/dist/test/host-limits.test.d.ts +1 -0
- package/dist/test/host-limits.test.js +71 -0
- package/dist/test/host-limits.test.js.map +1 -0
- package/dist/test/query-client.test.js +72 -0
- package/dist/test/query-client.test.js.map +1 -1
- package/dist/test/wal-monitor.test.d.ts +1 -0
- package/dist/test/wal-monitor.test.js +333 -0
- package/dist/test/wal-monitor.test.js.map +1 -0
- package/dist/wal-monitor.d.ts +65 -0
- package/dist/wal-monitor.js +293 -0
- package/dist/wal-monitor.js.map +1 -0
- package/package.json +1 -1
- package/public/540.js +1 -1
- package/public/805.js +1 -1
- package/public/main.js +1 -1
- package/public/remoteEntry.js +1 -1
package/dist/index.js
CHANGED
|
@@ -42,7 +42,9 @@ const history_v2_1 = require("./history-v2");
|
|
|
42
42
|
const history_v1_1 = require("./history-v1");
|
|
43
43
|
const retention_1 = require("./retention");
|
|
44
44
|
const full_export_range_1 = require("./full-export-range");
|
|
45
|
+
const wal_monitor_1 = require("./wal-monitor");
|
|
45
46
|
const questdb_endpoint_1 = require("./questdb-endpoint");
|
|
47
|
+
const host_limits_1 = require("./host-limits");
|
|
46
48
|
// The managed QuestDB container's name. signalk-container prefixes it with
|
|
47
49
|
// `sk-` for the actual container and its DNS name (e.g. `sk-signalk-questdb`).
|
|
48
50
|
const QUESTDB_CONTAINER_NAME = "signalk-questdb";
|
|
@@ -88,6 +90,16 @@ const QUESTDB_ULIMITS = { nofile: 1048576 };
|
|
|
88
90
|
// schema (and rebuild any ILP auto-created with the wrong shape). 60s is far
|
|
89
91
|
// faster than a human would notice "recording broke" yet negligible load.
|
|
90
92
|
const SCHEMA_HEAL_INTERVAL_MS = 60_000;
|
|
93
|
+
// How long the guided-skip endpoint waits after a RESUME WAL before
|
|
94
|
+
// re-reading wal_tables(). An apply attempt against unreadable segment data
|
|
95
|
+
// fails within milliseconds (observed ~3ms in the field), so a few seconds
|
|
96
|
+
// cleanly separates "re-suspended at the same txn" from "healthily replaying".
|
|
97
|
+
const SKIP_RECHECK_DELAY_MS = 3_000;
|
|
98
|
+
// Deadline for the engine-log fetch inside /api/wal-diagnosis. The container
|
|
99
|
+
// API exposes no abort signal, so this bounds how long the diagnosis waits —
|
|
100
|
+
// a runtime that stalls streaming logs must degrade the response (applyError
|
|
101
|
+
// null), not hang it.
|
|
102
|
+
const ENGINE_LOG_TIMEOUT_MS = 10_000;
|
|
91
103
|
function buildResourceLimits(config) {
|
|
92
104
|
return {
|
|
93
105
|
memory: config.questdbMemoryLimit?.trim() || null,
|
|
@@ -116,6 +128,10 @@ module.exports = (app) => {
|
|
|
116
128
|
// Probed once via the richer status query and cached so an old build does
|
|
117
129
|
// not throw-and-fall-back on every /api/status call. Null = not yet probed.
|
|
118
130
|
let walTablesHasErrorColumns = null;
|
|
131
|
+
// Watches for WAL suspensions, auto-applies the lossless remedy once per
|
|
132
|
+
// stall point, and raises/clears the Signal K notification. Lives from a
|
|
133
|
+
// completed start to the next stop/purge.
|
|
134
|
+
let walMonitor = null;
|
|
119
135
|
// Serializes the lifecycle operations that create or destroy QuestDB
|
|
120
136
|
// resources — asyncStart, /api/update/apply, and /api/purge-data — so they
|
|
121
137
|
// can never interleave. Without this, a purge (or update) issued while a
|
|
@@ -131,6 +147,12 @@ module.exports = (app) => {
|
|
|
131
147
|
// the chain — or one parked in a long await — would run to completion after
|
|
132
148
|
// teardown and resurrect the resources that were just torn down.
|
|
133
149
|
let lifecycleGeneration = 0;
|
|
150
|
+
// Bumped by /api/update/apply when it recreates the QuestDB container.
|
|
151
|
+
// stop/purge bump lifecycleGeneration, but an update deliberately does
|
|
152
|
+
// not (its own generation check detects stop preemption) — so a request
|
|
153
|
+
// that must not span a container swap (the WAL skip's final guard) needs
|
|
154
|
+
// this separate epoch to notice one.
|
|
155
|
+
let containerEpoch = 0;
|
|
134
156
|
// True only between a FULLY completed start (providers, stream
|
|
135
157
|
// subscription, and timers all registered) and the next stop/purge.
|
|
136
158
|
// queryClient/writer are not usable as a running sentinel: they are
|
|
@@ -360,6 +382,139 @@ module.exports = (app) => {
|
|
|
360
382
|
}
|
|
361
383
|
return false;
|
|
362
384
|
}
|
|
385
|
+
const WAL_NOTIFICATION_PATH = "notifications.signalk-questdb.walSuspended";
|
|
386
|
+
// Dedupe key of the last emitted notification. The monitor reports every
|
|
387
|
+
// check cycle; the delta only goes out when the key changes, so clients
|
|
388
|
+
// aren't spammed with a fresh alert every minute. The key must be built
|
|
389
|
+
// from STABLE facts (state, table@stall-point, outcome) — the message
|
|
390
|
+
// itself contains txnLag, which grows every cycle while ingestion
|
|
391
|
+
// continues and would defeat the dedupe.
|
|
392
|
+
let lastWalNotification = null;
|
|
393
|
+
function emitWalNotification(state, message, key) {
|
|
394
|
+
if (key === lastWalNotification)
|
|
395
|
+
return;
|
|
396
|
+
lastWalNotification = key;
|
|
397
|
+
app.handleMessage("signalk-questdb", {
|
|
398
|
+
updates: [
|
|
399
|
+
{
|
|
400
|
+
values: [
|
|
401
|
+
{
|
|
402
|
+
path: WAL_NOTIFICATION_PATH,
|
|
403
|
+
value: {
|
|
404
|
+
state,
|
|
405
|
+
method: ["visual"],
|
|
406
|
+
message,
|
|
407
|
+
timestamp: new Date().toISOString(),
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
// A latched alert must not outlive the monitoring that backs it: after
|
|
416
|
+
// stop/purge nobody would ever clear it, and a permanent stale alarm
|
|
417
|
+
// teaches operators to ignore the path. Downgrade honestly (monitoring
|
|
418
|
+
// stopped ≠ recovered) and reset the dedupe so the next start re-alerts
|
|
419
|
+
// if the suspension is still there.
|
|
420
|
+
function clearWalAlertOnTeardown() {
|
|
421
|
+
if (lastWalNotification?.startsWith("alert:")) {
|
|
422
|
+
emitWalNotification("normal", "QuestDB WAL monitoring stopped (plugin stopped or data removed); " +
|
|
423
|
+
"suspension state is no longer tracked.", "normal:teardown");
|
|
424
|
+
}
|
|
425
|
+
lastWalNotification = null;
|
|
426
|
+
}
|
|
427
|
+
// wal_tables() probe shared by /api/status, the WAL monitor, and the
|
|
428
|
+
// resume endpoints. errorTag/errorMessage carry QuestDB's reason for a
|
|
429
|
+
// suspension when it has one; the columns are absent on an older pinned
|
|
430
|
+
// QuestDB, so probe for them once (the richer query throws if missing),
|
|
431
|
+
// cache the result in `walTablesHasErrorColumns`, and thereafter run the
|
|
432
|
+
// query the build actually supports. Detection never regresses; only the
|
|
433
|
+
// diagnostic columns are dropped on old builds. Throws on query failure —
|
|
434
|
+
// callers decide whether that means "treat as not suspended" (status) or
|
|
435
|
+
// "skip this cycle" (monitor).
|
|
436
|
+
const RICH_WAL_QUERY = "SELECT name, writerTxn, sequencerTxn, errorTag, errorMessage FROM wal_tables() WHERE suspended = true";
|
|
437
|
+
const BASIC_WAL_QUERY = "SELECT name, writerTxn, sequencerTxn FROM wal_tables() WHERE suspended = true";
|
|
438
|
+
async function listSuspendedTables(client) {
|
|
439
|
+
let walResult;
|
|
440
|
+
if (walTablesHasErrorColumns === null) {
|
|
441
|
+
try {
|
|
442
|
+
walResult = await client.exec(RICH_WAL_QUERY);
|
|
443
|
+
walTablesHasErrorColumns = true;
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
walResult = await client.exec(BASIC_WAL_QUERY);
|
|
447
|
+
// Only a definitive rejection (a 4xx — unknown column on an older
|
|
448
|
+
// QuestDB) proves the columns are missing. A transient failure
|
|
449
|
+
// (timeout, engine mid-restart) that happens to spare the cheaper
|
|
450
|
+
// basic query must not permanently disable the diagnostic columns —
|
|
451
|
+
// leave the flag unprobed so the next call retries the richer query.
|
|
452
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
453
|
+
if (/^QuestDB query failed \(4\d\d\)/.test(msg)) {
|
|
454
|
+
walTablesHasErrorColumns = false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
walResult = await client.exec(walTablesHasErrorColumns ? RICH_WAL_QUERY : BASIC_WAL_QUERY);
|
|
460
|
+
}
|
|
461
|
+
return client.toObjects(walResult).map((row) => {
|
|
462
|
+
const writerTxn = Number(row.writerTxn ?? 0);
|
|
463
|
+
const sequencerTxn = Number(row.sequencerTxn ?? 0);
|
|
464
|
+
return {
|
|
465
|
+
name: String(row.name),
|
|
466
|
+
writerTxn,
|
|
467
|
+
sequencerTxn,
|
|
468
|
+
txnLag: Math.max(0, sequencerTxn - writerTxn),
|
|
469
|
+
errorTag: String(row.errorTag ?? ""),
|
|
470
|
+
errorMessage: String(row.errorMessage ?? ""),
|
|
471
|
+
};
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
async function pendingSegments(client, table) {
|
|
475
|
+
const result = await client.exec((0, wal_monitor_1.buildPendingSegmentsSQL)(table.name, table.writerTxn), 15_000);
|
|
476
|
+
return client.toObjects(result).map((row) => ({
|
|
477
|
+
walId: Number(row.walId ?? 0),
|
|
478
|
+
segmentId: Number(row.segmentId ?? 0),
|
|
479
|
+
txns: Number(row.txns ?? 0),
|
|
480
|
+
minTxn: Number(row.minTxn ?? 0),
|
|
481
|
+
maxTxn: Number(row.maxTxn ?? 0),
|
|
482
|
+
minTimestamp: String(row.minTimestamp ?? ""),
|
|
483
|
+
maxTimestamp: String(row.maxTimestamp ?? ""),
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
// Engine log lines for the apply-failure scrape; extractApplyError then
|
|
487
|
+
// runs per suspended table over the one fetch. Best effort: external mode,
|
|
488
|
+
// an older signalk-container without getLogs, or a fetch failure all
|
|
489
|
+
// degrade to null (the diagnosis then shows only what wal_tables()
|
|
490
|
+
// reports).
|
|
491
|
+
async function fetchEngineLogLines() {
|
|
492
|
+
if (currentConfig?.managedContainer === false)
|
|
493
|
+
return null;
|
|
494
|
+
const containers = globalThis.__signalk_containerManager;
|
|
495
|
+
if (!containers?.getLogs)
|
|
496
|
+
return null;
|
|
497
|
+
let timeoutTimer;
|
|
498
|
+
try {
|
|
499
|
+
return await Promise.race([
|
|
500
|
+
// The trailing catch keeps a fetch that fails AFTER the timeout won
|
|
501
|
+
// the race from surfacing as an unhandled rejection.
|
|
502
|
+
containers
|
|
503
|
+
.getLogs(QUESTDB_CONTAINER_NAME, { tail: 3000 })
|
|
504
|
+
.catch(() => null),
|
|
505
|
+
new Promise((resolve) => {
|
|
506
|
+
timeoutTimer = setTimeout(() => resolve(null), ENGINE_LOG_TIMEOUT_MS);
|
|
507
|
+
}),
|
|
508
|
+
]);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
if (timeoutTimer)
|
|
515
|
+
clearTimeout(timeoutTimer);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
363
518
|
// Runs the actual startup. Always invoked through the lifecycle lock (see
|
|
364
519
|
// asyncStart) so a purge/update can't interleave with it.
|
|
365
520
|
//
|
|
@@ -623,6 +778,66 @@ module.exports = (app) => {
|
|
|
623
778
|
schemaHealTimer = setInterval(() => {
|
|
624
779
|
void healSchemaTables();
|
|
625
780
|
}, SCHEMA_HEAL_INTERVAL_MS);
|
|
781
|
+
// Watch for WAL suspensions. Detection alone is not enough — a suspended
|
|
782
|
+
// table keeps accepting ILP writes while committing nothing, and the only
|
|
783
|
+
// built-in recovery QuestDB itself attempts is at engine start. The
|
|
784
|
+
// monitor closes that gap: loud persistent logging, a Signal K alert
|
|
785
|
+
// (recording silently stalling for weeks is exactly what notifications
|
|
786
|
+
// exist for), and one automatic lossless resume per stall point.
|
|
787
|
+
const client = queryClient;
|
|
788
|
+
walMonitor = new wal_monitor_1.WalMonitor({
|
|
789
|
+
// Owned tables only: in external mode the plugin shares a QuestDB with
|
|
790
|
+
// whatever else the operator runs there, and auto-resuming (or
|
|
791
|
+
// alerting on) someone else's suspended table is not this plugin's
|
|
792
|
+
// call to make.
|
|
793
|
+
listSuspended: async () => (await listSuspendedTables(client)).filter((t) => FULL_EXPORT_TABLE_SET.has(t.name)),
|
|
794
|
+
resumeTable: (name) => client
|
|
795
|
+
.exec(`ALTER TABLE "${name.replace(/"/g, '""')}" RESUME WAL`, 10_000)
|
|
796
|
+
.then(() => undefined),
|
|
797
|
+
onSuspended: (tables, anyAutoResumeFailed) => {
|
|
798
|
+
const summary = tables
|
|
799
|
+
.map((t) => `${t.name} (${t.txnLag} txns behind)`)
|
|
800
|
+
.join(", ");
|
|
801
|
+
app.setPluginError(`QuestDB WAL suspended: ${summary}`);
|
|
802
|
+
const key = "alert:" +
|
|
803
|
+
tables
|
|
804
|
+
.map((t) => `${t.name}@${t.writerTxn}`)
|
|
805
|
+
.sort()
|
|
806
|
+
.join(",") +
|
|
807
|
+
`:${anyAutoResumeFailed}`;
|
|
808
|
+
emitWalNotification("alert", anyAutoResumeFailed
|
|
809
|
+
? `QuestDB history recording is stalled: ${summary}. Automatic ` +
|
|
810
|
+
`resume failed — the data at the stall point is likely ` +
|
|
811
|
+
`unreadable. Open the QuestDB History plugin panel to repair.`
|
|
812
|
+
: `QuestDB history recording is stalled: ${summary}. Automatic ` +
|
|
813
|
+
`recovery is being attempted.`, key);
|
|
814
|
+
},
|
|
815
|
+
onResolved: () => {
|
|
816
|
+
emitWalNotification("normal", "QuestDB history recording has recovered.", "normal:recovered");
|
|
817
|
+
app.setPluginStatus(`Recording to QuestDB at ${ilpHost}:${ilpPort}`);
|
|
818
|
+
},
|
|
819
|
+
debug: (msg) => app.debug(msg),
|
|
820
|
+
error: (msg) => app.error(msg),
|
|
821
|
+
});
|
|
822
|
+
walMonitor.start();
|
|
823
|
+
// vm.max_map_count is kernel-global (not namespaced), so in managed mode
|
|
824
|
+
// this local probe reflects the limit the QuestDB container actually runs
|
|
825
|
+
// under. QuestDB memory-maps every partition column file and WAL segment;
|
|
826
|
+
// on a grown database the stock 65530 exhausts and mmap fails with
|
|
827
|
+
// out-of-memory errors — failed queries and suspended WAL apply while
|
|
828
|
+
// plenty of RAM is free. Warn once here; /api/status re-reads it live so
|
|
829
|
+
// the panel banner clears as soon as the operator raises the sysctl.
|
|
830
|
+
if (config.managedContainer !== false) {
|
|
831
|
+
const mapCount = await (0, host_limits_1.readMaxMapCount)();
|
|
832
|
+
if (mapCount?.tooLow) {
|
|
833
|
+
app.error(`Host vm.max_map_count is ${mapCount.current}; QuestDB recommends ` +
|
|
834
|
+
`${mapCount.recommended}. A grown database can exhaust this and ` +
|
|
835
|
+
`fail queries or suspend recording. Fix in a host shell (not ` +
|
|
836
|
+
`inside the container — the limit is kernel-global): ` +
|
|
837
|
+
`echo 'vm.max_map_count=${mapCount.recommended}' | sudo tee ` +
|
|
838
|
+
`/etc/sysctl.d/99-questdb.conf && sudo sysctl --system`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
626
841
|
app.setPluginStatus(`Recording to QuestDB at ${ilpHost}:${ilpPort}`);
|
|
627
842
|
// Everything is registered — only now does the plugin count as running.
|
|
628
843
|
// Any earlier return/throw leaves the flag false, so a half-started
|
|
@@ -682,6 +897,11 @@ module.exports = (app) => {
|
|
|
682
897
|
clearInterval(schemaHealTimer);
|
|
683
898
|
schemaHealTimer = null;
|
|
684
899
|
}
|
|
900
|
+
if (walMonitor) {
|
|
901
|
+
walMonitor.stop();
|
|
902
|
+
walMonitor = null;
|
|
903
|
+
}
|
|
904
|
+
clearWalAlertOnTeardown();
|
|
685
905
|
schemaMismatch = false;
|
|
686
906
|
// Clear the clamp warning so it doesn't survive into the next start
|
|
687
907
|
// (e.g. a switch to external/unmanaged mode that never calls ensureRunning).
|
|
@@ -714,9 +934,21 @@ module.exports = (app) => {
|
|
|
714
934
|
res.status(503).json({ status: "not_running" });
|
|
715
935
|
return;
|
|
716
936
|
}
|
|
937
|
+
// Probe the host limit BEFORE the health gate: mmap exhaustion is
|
|
938
|
+
// one of the ways QuestDB becomes unhealthy in the first place, so
|
|
939
|
+
// the remediation hint must survive into the unhealthy response
|
|
940
|
+
// instead of disappearing exactly when it matters. Live /proc read
|
|
941
|
+
// (cheap) rather than a cached startup probe, so the panel warning
|
|
942
|
+
// clears on the next poll once the operator applies the sysctl —
|
|
943
|
+
// raising it takes effect immediately, no container restart needed.
|
|
944
|
+
// Managed mode only: an external QuestDB may run on another machine
|
|
945
|
+
// whose kernel this probe cannot see.
|
|
946
|
+
const hostMaxMapCount = currentConfig?.managedContainer !== false
|
|
947
|
+
? await (0, host_limits_1.readMaxMapCount)()
|
|
948
|
+
: null;
|
|
717
949
|
const healthy = await queryClient.isHealthy();
|
|
718
950
|
if (!healthy) {
|
|
719
|
-
res.status(503).json({ status: "unhealthy" });
|
|
951
|
+
res.status(503).json({ status: "unhealthy", hostMaxMapCount });
|
|
720
952
|
return;
|
|
721
953
|
}
|
|
722
954
|
let totalRows = 0;
|
|
@@ -741,49 +973,16 @@ module.exports = (app) => {
|
|
|
741
973
|
// still reads "running". Surface it explicitly so the panel can warn
|
|
742
974
|
// instead of looking healthy. `txnLag` = sequencer ahead of writer =
|
|
743
975
|
// the backlog that will never drain until the WAL is resumed.
|
|
976
|
+
// `autoResume` is the monitor's verdict for the current stall point:
|
|
977
|
+
// "pending" while the automatic lossless resume may still succeed,
|
|
978
|
+
// "failed" once replay re-hit the same failure — the panel then
|
|
979
|
+
// offers the guided skip instead of just the resume button.
|
|
744
980
|
let suspendedTables = [];
|
|
745
|
-
// errorTag/errorMessage carry QuestDB's reason for the suspension
|
|
746
|
-
// (e.g. an out-of-memory or disk error during WAL apply). They are
|
|
747
|
-
// empty on a healthy table; surfacing them lets the panel — and a
|
|
748
|
-
// bug report — show WHY ingestion stalled instead of just THAT it
|
|
749
|
-
// did, without needing to catch the live wal_tables() state by hand
|
|
750
|
-
// before the data is reset.
|
|
751
|
-
//
|
|
752
|
-
// The columns are absent on an older pinned QuestDB. Probe for them
|
|
753
|
-
// ONCE (the richer query throws if they're missing), cache the
|
|
754
|
-
// result in `walTablesHasErrorColumns`, and thereafter run the query
|
|
755
|
-
// the build actually supports — so an old build does not throw and
|
|
756
|
-
// fall back on every /api/status call. Detection never regresses;
|
|
757
|
-
// only the diagnostic columns are dropped on old builds.
|
|
758
|
-
const RICH_WAL_QUERY = "SELECT name, writerTxn, sequencerTxn, errorTag, errorMessage FROM wal_tables() WHERE suspended = true";
|
|
759
|
-
const BASIC_WAL_QUERY = "SELECT name, writerTxn, sequencerTxn FROM wal_tables() WHERE suspended = true";
|
|
760
981
|
try {
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
walTablesHasErrorColumns = true;
|
|
766
|
-
}
|
|
767
|
-
catch {
|
|
768
|
-
walResult = await queryClient.exec(BASIC_WAL_QUERY);
|
|
769
|
-
walTablesHasErrorColumns = false;
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
else {
|
|
773
|
-
walResult = await queryClient.exec(walTablesHasErrorColumns ? RICH_WAL_QUERY : BASIC_WAL_QUERY);
|
|
774
|
-
}
|
|
775
|
-
suspendedTables = queryClient.toObjects(walResult).map((row) => {
|
|
776
|
-
const writerTxn = Number(row.writerTxn ?? 0);
|
|
777
|
-
const sequencerTxn = Number(row.sequencerTxn ?? 0);
|
|
778
|
-
return {
|
|
779
|
-
name: String(row.name),
|
|
780
|
-
writerTxn,
|
|
781
|
-
sequencerTxn,
|
|
782
|
-
txnLag: Math.max(0, sequencerTxn - writerTxn),
|
|
783
|
-
errorTag: String(row.errorTag ?? ""),
|
|
784
|
-
errorMessage: String(row.errorMessage ?? ""),
|
|
785
|
-
};
|
|
786
|
-
});
|
|
982
|
+
suspendedTables = (await listSuspendedTables(queryClient)).map((t) => ({
|
|
983
|
+
...t,
|
|
984
|
+
autoResume: walMonitor?.outcomeFor(t.name) ?? null,
|
|
985
|
+
}));
|
|
787
986
|
}
|
|
788
987
|
catch {
|
|
789
988
|
// wal_tables() itself is unavailable on non-WAL/older QuestDB, or
|
|
@@ -798,6 +997,7 @@ module.exports = (app) => {
|
|
|
798
997
|
suspendedTables,
|
|
799
998
|
schemaMismatch,
|
|
800
999
|
ulimitClamp,
|
|
1000
|
+
hostMaxMapCount,
|
|
801
1001
|
endpoint: questdbEndpoints
|
|
802
1002
|
? `${questdbEndpoints.http.host}:${questdbEndpoints.http.port}`
|
|
803
1003
|
: null,
|
|
@@ -1025,6 +1225,7 @@ module.exports = (app) => {
|
|
|
1025
1225
|
// generation one more time.
|
|
1026
1226
|
assertNotStopped();
|
|
1027
1227
|
ulimitClamp = null;
|
|
1228
|
+
containerEpoch++;
|
|
1028
1229
|
await containers.ensureRunning(QUESTDB_CONTAINER_NAME, updateConfig, {
|
|
1029
1230
|
onUlimitClamped,
|
|
1030
1231
|
});
|
|
@@ -1125,17 +1326,14 @@ module.exports = (app) => {
|
|
|
1125
1326
|
.json({ error: "QuestDB connection not initialized" });
|
|
1126
1327
|
return;
|
|
1127
1328
|
}
|
|
1128
|
-
const
|
|
1129
|
-
const suspended = client
|
|
1130
|
-
.toObjects(walResult)
|
|
1131
|
-
.filter((row) => FULL_EXPORT_TABLE_SET.has(String(row.name)));
|
|
1329
|
+
const suspended = (await listSuspendedTables(client)).filter((row) => FULL_EXPORT_TABLE_SET.has(row.name));
|
|
1132
1330
|
const results = [];
|
|
1133
1331
|
for (const row of suspended) {
|
|
1134
|
-
const table =
|
|
1332
|
+
const table = row.name;
|
|
1135
1333
|
const base = {
|
|
1136
1334
|
table,
|
|
1137
|
-
writerTxn:
|
|
1138
|
-
sequencerTxn:
|
|
1335
|
+
writerTxn: row.writerTxn,
|
|
1336
|
+
sequencerTxn: row.sequencerTxn,
|
|
1139
1337
|
};
|
|
1140
1338
|
try {
|
|
1141
1339
|
// Plain RESUME WAL replays from the next unapplied txn — it
|
|
@@ -1170,6 +1368,189 @@ module.exports = (app) => {
|
|
|
1170
1368
|
});
|
|
1171
1369
|
}
|
|
1172
1370
|
});
|
|
1371
|
+
// Per-suspended-table repair diagnosis: the pending-segment map, the
|
|
1372
|
+
// minimal bounded skip that would get past the stuck segment (with the
|
|
1373
|
+
// exact loss it implies), and the apply job's real failure reason
|
|
1374
|
+
// scraped from the engine log. The panel calls this when a suspension's
|
|
1375
|
+
// automatic resume has failed, to present the skip decision with its
|
|
1376
|
+
// cost quantified instead of a blind "RESUME WAL FROM TXN" incantation.
|
|
1377
|
+
router.get("/api/wal-diagnosis", async (_req, res) => {
|
|
1378
|
+
try {
|
|
1379
|
+
const client = queryClient;
|
|
1380
|
+
if (!client) {
|
|
1381
|
+
res
|
|
1382
|
+
.status(503)
|
|
1383
|
+
.json({ error: "QuestDB connection not initialized" });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const suspended = (await listSuspendedTables(client)).filter((row) => FULL_EXPORT_TABLE_SET.has(row.name));
|
|
1387
|
+
const logLines = suspended.length > 0 ? await fetchEngineLogLines() : null;
|
|
1388
|
+
const tables = [];
|
|
1389
|
+
for (const table of suspended) {
|
|
1390
|
+
let segments = [];
|
|
1391
|
+
let segmentError = null;
|
|
1392
|
+
try {
|
|
1393
|
+
segments = await pendingSegments(client, table);
|
|
1394
|
+
}
|
|
1395
|
+
catch (err) {
|
|
1396
|
+
// wal_transactions() may be unavailable on an older QuestDB —
|
|
1397
|
+
// report the rest of the diagnosis without a skip plan.
|
|
1398
|
+
segmentError = err instanceof Error ? err.message : String(err);
|
|
1399
|
+
}
|
|
1400
|
+
tables.push({
|
|
1401
|
+
...table,
|
|
1402
|
+
autoResume: walMonitor?.outcomeFor(table.name) ?? null,
|
|
1403
|
+
// Commit-time of the first unapplied txn = when apply froze.
|
|
1404
|
+
suspendedSince: segments.length > 0 ? segments[0].minTimestamp : null,
|
|
1405
|
+
pendingSegments: segments.length,
|
|
1406
|
+
skipPlan: (0, wal_monitor_1.computeSkipPlan)(segments),
|
|
1407
|
+
segmentError,
|
|
1408
|
+
applyError: logLines
|
|
1409
|
+
? (0, wal_monitor_1.extractApplyError)(logLines, table.name)
|
|
1410
|
+
: null,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
res.json({ tables });
|
|
1414
|
+
}
|
|
1415
|
+
catch (err) {
|
|
1416
|
+
res.status(500).json({
|
|
1417
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
// Bounded lossy skip past an unreadable WAL segment. This is the ONLY
|
|
1422
|
+
// write path that can lose data, so it is deliberately narrow:
|
|
1423
|
+
// - the table must be one of ours and currently suspended;
|
|
1424
|
+
// - a lossless resume is attempted first and re-checked — if it
|
|
1425
|
+
// sticks, nothing is skipped;
|
|
1426
|
+
// - the skip target is computed server-side from the live segment
|
|
1427
|
+
// map (never taken from the client), so it can only ever be the
|
|
1428
|
+
// minimal next-segment boundary.
|
|
1429
|
+
// The client sends the plan it displayed only so a stale UI can be
|
|
1430
|
+
// rejected instead of skipping more than the operator agreed to.
|
|
1431
|
+
router.post("/api/resume-wal/skip", async (req, res) => {
|
|
1432
|
+
try {
|
|
1433
|
+
const client = queryClient;
|
|
1434
|
+
if (!client) {
|
|
1435
|
+
res
|
|
1436
|
+
.status(503)
|
|
1437
|
+
.json({ error: "QuestDB connection not initialized" });
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const table = String(req.body?.table ?? "");
|
|
1441
|
+
if (!FULL_EXPORT_TABLE_SET.has(table)) {
|
|
1442
|
+
res.status(400).json({ error: `Unknown table: ${table}` });
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const quoted = `"${table.replace(/"/g, '""')}"`;
|
|
1446
|
+
// Captured at entry: stop/purge bump the generation, an update
|
|
1447
|
+
// recreate bumps the container epoch — the destructive statement
|
|
1448
|
+
// below must never run against whatever lifecycle replaced the
|
|
1449
|
+
// state this request was validated on.
|
|
1450
|
+
const generation = lifecycleGeneration;
|
|
1451
|
+
const epoch = containerEpoch;
|
|
1452
|
+
const recheck = async () => (await listSuspendedTables(client)).find((t) => t.name === table);
|
|
1453
|
+
const before = await recheck();
|
|
1454
|
+
if (!before) {
|
|
1455
|
+
res.json({
|
|
1456
|
+
skipped: false,
|
|
1457
|
+
healed: true,
|
|
1458
|
+
message: `${table} is not suspended — nothing to skip.`,
|
|
1459
|
+
});
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
// Safety ladder step 1: the lossless path. If the suspension cause
|
|
1463
|
+
// was transient after all, this recovers everything and the lossy
|
|
1464
|
+
// step below never runs.
|
|
1465
|
+
await client.exec(`ALTER TABLE ${quoted} RESUME WAL`, 10_000);
|
|
1466
|
+
await new Promise((resolve) => setTimeout(resolve, SKIP_RECHECK_DELAY_MS));
|
|
1467
|
+
const afterResume = await recheck();
|
|
1468
|
+
if (!afterResume) {
|
|
1469
|
+
res.json({
|
|
1470
|
+
skipped: false,
|
|
1471
|
+
healed: true,
|
|
1472
|
+
message: `Lossless resume succeeded on ${table} — no data was skipped. The backlog is now replaying.`,
|
|
1473
|
+
});
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
if (afterResume.writerTxn !== before.writerTxn) {
|
|
1477
|
+
res.json({
|
|
1478
|
+
skipped: false,
|
|
1479
|
+
healed: false,
|
|
1480
|
+
progressed: true,
|
|
1481
|
+
message: `The writer advanced on ${table} before re-suspending — the situation changed. Re-run the diagnosis.`,
|
|
1482
|
+
});
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
// Step 2: replay re-froze at the same transaction. Everything the
|
|
1486
|
+
// destructive statement depends on is validated from the state
|
|
1487
|
+
// read CLOSEST to execution: re-read the suspension state, require
|
|
1488
|
+
// the writer still frozen at the same txn and the lifecycle
|
|
1489
|
+
// untouched by stop/update/purge, recompute the plan from that
|
|
1490
|
+
// state, and require it to match the operator's confirmation in
|
|
1491
|
+
// every field. Only that final validated plan is executed.
|
|
1492
|
+
const preExec = await recheck();
|
|
1493
|
+
if (generation !== lifecycleGeneration ||
|
|
1494
|
+
epoch !== containerEpoch ||
|
|
1495
|
+
!preExec ||
|
|
1496
|
+
preExec.writerTxn !== before.writerTxn) {
|
|
1497
|
+
res.status(409).json({
|
|
1498
|
+
error: "The table's state changed while confirming the skip — nothing was skipped. Re-run the diagnosis.",
|
|
1499
|
+
});
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const plan = (0, wal_monitor_1.computeSkipPlan)(await pendingSegments(client, preExec));
|
|
1503
|
+
if (!plan) {
|
|
1504
|
+
res.status(500).json({
|
|
1505
|
+
error: `Cannot compute a skip plan for ${table} — no pending transactions found.`,
|
|
1506
|
+
});
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
if (!(0, wal_monitor_1.skipPlansEqual)(plan, req.body?.confirmPlan)) {
|
|
1510
|
+
res.status(409).json({
|
|
1511
|
+
error: "The skip plan changed since the diagnosis was displayed. Re-run the diagnosis and confirm again.",
|
|
1512
|
+
skipPlan: plan,
|
|
1513
|
+
});
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
// Last state read before the destructive statement — only
|
|
1517
|
+
// synchronous code between this validation and the ALTER, so no
|
|
1518
|
+
// await window remains in which the writer could move or the
|
|
1519
|
+
// lifecycle could be swapped. (The two HTTP calls are still
|
|
1520
|
+
// distinct reads; that residue is irreducible client-side.)
|
|
1521
|
+
const finalState = await recheck();
|
|
1522
|
+
if (generation !== lifecycleGeneration ||
|
|
1523
|
+
epoch !== containerEpoch ||
|
|
1524
|
+
!finalState ||
|
|
1525
|
+
finalState.writerTxn !== before.writerTxn) {
|
|
1526
|
+
res.status(409).json({
|
|
1527
|
+
error: "The table's state changed while confirming the skip — nothing was skipped. Re-run the diagnosis.",
|
|
1528
|
+
skipPlan: plan,
|
|
1529
|
+
});
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
app.error(`Skipping ${plan.skippedTxns} unreadable WAL txn(s) on ${table} ` +
|
|
1533
|
+
`(walId=${plan.walId} segmentId=${plan.segmentId}, ` +
|
|
1534
|
+
`${plan.skipWindowStart} → ${plan.skipWindowEnd}) — ` +
|
|
1535
|
+
`RESUME WAL FROM TXN ${plan.skipToTxn}, requested via panel`);
|
|
1536
|
+
await client.exec(`ALTER TABLE ${quoted} RESUME WAL FROM TXN ${plan.skipToTxn}`, 10_000);
|
|
1537
|
+
await new Promise((resolve) => setTimeout(resolve, SKIP_RECHECK_DELAY_MS));
|
|
1538
|
+
const afterSkip = await recheck();
|
|
1539
|
+
res.json({
|
|
1540
|
+
skipped: true,
|
|
1541
|
+
skipPlan: plan,
|
|
1542
|
+
stillSuspended: Boolean(afterSkip),
|
|
1543
|
+
message: afterSkip
|
|
1544
|
+
? `Skipped ${plan.skippedTxns} txn(s) on ${table}, but the table re-suspended at txn ${afterSkip.writerTxn + 1} — another segment appears unreadable. Re-run the diagnosis to skip it too.`
|
|
1545
|
+
: `Skipped ${plan.skippedTxns} txn(s) on ${table} (${plan.skipWindowStart} → ${plan.skipWindowEnd}). The remaining backlog is replaying.`,
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
catch (err) {
|
|
1549
|
+
res.status(500).json({
|
|
1550
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1173
1554
|
router.post("/api/purge-data", async (_req, res) => {
|
|
1174
1555
|
try {
|
|
1175
1556
|
// External mode is a config fact independent of the runtime, so
|
|
@@ -1212,6 +1593,11 @@ module.exports = (app) => {
|
|
|
1212
1593
|
clearInterval(schemaHealTimer);
|
|
1213
1594
|
schemaHealTimer = null;
|
|
1214
1595
|
}
|
|
1596
|
+
if (walMonitor) {
|
|
1597
|
+
walMonitor.stop();
|
|
1598
|
+
walMonitor = null;
|
|
1599
|
+
}
|
|
1600
|
+
clearWalAlertOnTeardown();
|
|
1215
1601
|
if (retentionTimer) {
|
|
1216
1602
|
clearInterval(retentionTimer);
|
|
1217
1603
|
retentionTimer = null;
|