signalk-questdb 1.5.2 → 1.5.3
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/dist/index.js +402 -48
- package/dist/index.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/805.js +1 -1
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ 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");
|
|
46
47
|
// The managed QuestDB container's name. signalk-container prefixes it with
|
|
47
48
|
// `sk-` for the actual container and its DNS name (e.g. `sk-signalk-questdb`).
|
|
@@ -88,6 +89,16 @@ const QUESTDB_ULIMITS = { nofile: 1048576 };
|
|
|
88
89
|
// schema (and rebuild any ILP auto-created with the wrong shape). 60s is far
|
|
89
90
|
// faster than a human would notice "recording broke" yet negligible load.
|
|
90
91
|
const SCHEMA_HEAL_INTERVAL_MS = 60_000;
|
|
92
|
+
// How long the guided-skip endpoint waits after a RESUME WAL before
|
|
93
|
+
// re-reading wal_tables(). An apply attempt against unreadable segment data
|
|
94
|
+
// fails within milliseconds (observed ~3ms in the field), so a few seconds
|
|
95
|
+
// cleanly separates "re-suspended at the same txn" from "healthily replaying".
|
|
96
|
+
const SKIP_RECHECK_DELAY_MS = 3_000;
|
|
97
|
+
// Deadline for the engine-log fetch inside /api/wal-diagnosis. The container
|
|
98
|
+
// API exposes no abort signal, so this bounds how long the diagnosis waits —
|
|
99
|
+
// a runtime that stalls streaming logs must degrade the response (applyError
|
|
100
|
+
// null), not hang it.
|
|
101
|
+
const ENGINE_LOG_TIMEOUT_MS = 10_000;
|
|
91
102
|
function buildResourceLimits(config) {
|
|
92
103
|
return {
|
|
93
104
|
memory: config.questdbMemoryLimit?.trim() || null,
|
|
@@ -116,6 +127,10 @@ module.exports = (app) => {
|
|
|
116
127
|
// Probed once via the richer status query and cached so an old build does
|
|
117
128
|
// not throw-and-fall-back on every /api/status call. Null = not yet probed.
|
|
118
129
|
let walTablesHasErrorColumns = null;
|
|
130
|
+
// Watches for WAL suspensions, auto-applies the lossless remedy once per
|
|
131
|
+
// stall point, and raises/clears the Signal K notification. Lives from a
|
|
132
|
+
// completed start to the next stop/purge.
|
|
133
|
+
let walMonitor = null;
|
|
119
134
|
// Serializes the lifecycle operations that create or destroy QuestDB
|
|
120
135
|
// resources — asyncStart, /api/update/apply, and /api/purge-data — so they
|
|
121
136
|
// can never interleave. Without this, a purge (or update) issued while a
|
|
@@ -131,6 +146,12 @@ module.exports = (app) => {
|
|
|
131
146
|
// the chain — or one parked in a long await — would run to completion after
|
|
132
147
|
// teardown and resurrect the resources that were just torn down.
|
|
133
148
|
let lifecycleGeneration = 0;
|
|
149
|
+
// Bumped by /api/update/apply when it recreates the QuestDB container.
|
|
150
|
+
// stop/purge bump lifecycleGeneration, but an update deliberately does
|
|
151
|
+
// not (its own generation check detects stop preemption) — so a request
|
|
152
|
+
// that must not span a container swap (the WAL skip's final guard) needs
|
|
153
|
+
// this separate epoch to notice one.
|
|
154
|
+
let containerEpoch = 0;
|
|
134
155
|
// True only between a FULLY completed start (providers, stream
|
|
135
156
|
// subscription, and timers all registered) and the next stop/purge.
|
|
136
157
|
// queryClient/writer are not usable as a running sentinel: they are
|
|
@@ -360,6 +381,139 @@ module.exports = (app) => {
|
|
|
360
381
|
}
|
|
361
382
|
return false;
|
|
362
383
|
}
|
|
384
|
+
const WAL_NOTIFICATION_PATH = "notifications.signalk-questdb.walSuspended";
|
|
385
|
+
// Dedupe key of the last emitted notification. The monitor reports every
|
|
386
|
+
// check cycle; the delta only goes out when the key changes, so clients
|
|
387
|
+
// aren't spammed with a fresh alert every minute. The key must be built
|
|
388
|
+
// from STABLE facts (state, table@stall-point, outcome) — the message
|
|
389
|
+
// itself contains txnLag, which grows every cycle while ingestion
|
|
390
|
+
// continues and would defeat the dedupe.
|
|
391
|
+
let lastWalNotification = null;
|
|
392
|
+
function emitWalNotification(state, message, key) {
|
|
393
|
+
if (key === lastWalNotification)
|
|
394
|
+
return;
|
|
395
|
+
lastWalNotification = key;
|
|
396
|
+
app.handleMessage("signalk-questdb", {
|
|
397
|
+
updates: [
|
|
398
|
+
{
|
|
399
|
+
values: [
|
|
400
|
+
{
|
|
401
|
+
path: WAL_NOTIFICATION_PATH,
|
|
402
|
+
value: {
|
|
403
|
+
state,
|
|
404
|
+
method: ["visual"],
|
|
405
|
+
message,
|
|
406
|
+
timestamp: new Date().toISOString(),
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
// A latched alert must not outlive the monitoring that backs it: after
|
|
415
|
+
// stop/purge nobody would ever clear it, and a permanent stale alarm
|
|
416
|
+
// teaches operators to ignore the path. Downgrade honestly (monitoring
|
|
417
|
+
// stopped ≠ recovered) and reset the dedupe so the next start re-alerts
|
|
418
|
+
// if the suspension is still there.
|
|
419
|
+
function clearWalAlertOnTeardown() {
|
|
420
|
+
if (lastWalNotification?.startsWith("alert:")) {
|
|
421
|
+
emitWalNotification("normal", "QuestDB WAL monitoring stopped (plugin stopped or data removed); " +
|
|
422
|
+
"suspension state is no longer tracked.", "normal:teardown");
|
|
423
|
+
}
|
|
424
|
+
lastWalNotification = null;
|
|
425
|
+
}
|
|
426
|
+
// wal_tables() probe shared by /api/status, the WAL monitor, and the
|
|
427
|
+
// resume endpoints. errorTag/errorMessage carry QuestDB's reason for a
|
|
428
|
+
// suspension when it has one; the columns are absent on an older pinned
|
|
429
|
+
// QuestDB, so probe for them once (the richer query throws if missing),
|
|
430
|
+
// cache the result in `walTablesHasErrorColumns`, and thereafter run the
|
|
431
|
+
// query the build actually supports. Detection never regresses; only the
|
|
432
|
+
// diagnostic columns are dropped on old builds. Throws on query failure —
|
|
433
|
+
// callers decide whether that means "treat as not suspended" (status) or
|
|
434
|
+
// "skip this cycle" (monitor).
|
|
435
|
+
const RICH_WAL_QUERY = "SELECT name, writerTxn, sequencerTxn, errorTag, errorMessage FROM wal_tables() WHERE suspended = true";
|
|
436
|
+
const BASIC_WAL_QUERY = "SELECT name, writerTxn, sequencerTxn FROM wal_tables() WHERE suspended = true";
|
|
437
|
+
async function listSuspendedTables(client) {
|
|
438
|
+
let walResult;
|
|
439
|
+
if (walTablesHasErrorColumns === null) {
|
|
440
|
+
try {
|
|
441
|
+
walResult = await client.exec(RICH_WAL_QUERY);
|
|
442
|
+
walTablesHasErrorColumns = true;
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
walResult = await client.exec(BASIC_WAL_QUERY);
|
|
446
|
+
// Only a definitive rejection (a 4xx — unknown column on an older
|
|
447
|
+
// QuestDB) proves the columns are missing. A transient failure
|
|
448
|
+
// (timeout, engine mid-restart) that happens to spare the cheaper
|
|
449
|
+
// basic query must not permanently disable the diagnostic columns —
|
|
450
|
+
// leave the flag unprobed so the next call retries the richer query.
|
|
451
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
452
|
+
if (/^QuestDB query failed \(4\d\d\)/.test(msg)) {
|
|
453
|
+
walTablesHasErrorColumns = false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
walResult = await client.exec(walTablesHasErrorColumns ? RICH_WAL_QUERY : BASIC_WAL_QUERY);
|
|
459
|
+
}
|
|
460
|
+
return client.toObjects(walResult).map((row) => {
|
|
461
|
+
const writerTxn = Number(row.writerTxn ?? 0);
|
|
462
|
+
const sequencerTxn = Number(row.sequencerTxn ?? 0);
|
|
463
|
+
return {
|
|
464
|
+
name: String(row.name),
|
|
465
|
+
writerTxn,
|
|
466
|
+
sequencerTxn,
|
|
467
|
+
txnLag: Math.max(0, sequencerTxn - writerTxn),
|
|
468
|
+
errorTag: String(row.errorTag ?? ""),
|
|
469
|
+
errorMessage: String(row.errorMessage ?? ""),
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
async function pendingSegments(client, table) {
|
|
474
|
+
const result = await client.exec((0, wal_monitor_1.buildPendingSegmentsSQL)(table.name, table.writerTxn), 15_000);
|
|
475
|
+
return client.toObjects(result).map((row) => ({
|
|
476
|
+
walId: Number(row.walId ?? 0),
|
|
477
|
+
segmentId: Number(row.segmentId ?? 0),
|
|
478
|
+
txns: Number(row.txns ?? 0),
|
|
479
|
+
minTxn: Number(row.minTxn ?? 0),
|
|
480
|
+
maxTxn: Number(row.maxTxn ?? 0),
|
|
481
|
+
minTimestamp: String(row.minTimestamp ?? ""),
|
|
482
|
+
maxTimestamp: String(row.maxTimestamp ?? ""),
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
// Engine log lines for the apply-failure scrape; extractApplyError then
|
|
486
|
+
// runs per suspended table over the one fetch. Best effort: external mode,
|
|
487
|
+
// an older signalk-container without getLogs, or a fetch failure all
|
|
488
|
+
// degrade to null (the diagnosis then shows only what wal_tables()
|
|
489
|
+
// reports).
|
|
490
|
+
async function fetchEngineLogLines() {
|
|
491
|
+
if (currentConfig?.managedContainer === false)
|
|
492
|
+
return null;
|
|
493
|
+
const containers = globalThis.__signalk_containerManager;
|
|
494
|
+
if (!containers?.getLogs)
|
|
495
|
+
return null;
|
|
496
|
+
let timeoutTimer;
|
|
497
|
+
try {
|
|
498
|
+
return await Promise.race([
|
|
499
|
+
// The trailing catch keeps a fetch that fails AFTER the timeout won
|
|
500
|
+
// the race from surfacing as an unhandled rejection.
|
|
501
|
+
containers
|
|
502
|
+
.getLogs(QUESTDB_CONTAINER_NAME, { tail: 3000 })
|
|
503
|
+
.catch(() => null),
|
|
504
|
+
new Promise((resolve) => {
|
|
505
|
+
timeoutTimer = setTimeout(() => resolve(null), ENGINE_LOG_TIMEOUT_MS);
|
|
506
|
+
}),
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
if (timeoutTimer)
|
|
514
|
+
clearTimeout(timeoutTimer);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
363
517
|
// Runs the actual startup. Always invoked through the lifecycle lock (see
|
|
364
518
|
// asyncStart) so a purge/update can't interleave with it.
|
|
365
519
|
//
|
|
@@ -623,6 +777,48 @@ module.exports = (app) => {
|
|
|
623
777
|
schemaHealTimer = setInterval(() => {
|
|
624
778
|
void healSchemaTables();
|
|
625
779
|
}, SCHEMA_HEAL_INTERVAL_MS);
|
|
780
|
+
// Watch for WAL suspensions. Detection alone is not enough — a suspended
|
|
781
|
+
// table keeps accepting ILP writes while committing nothing, and the only
|
|
782
|
+
// built-in recovery QuestDB itself attempts is at engine start. The
|
|
783
|
+
// monitor closes that gap: loud persistent logging, a Signal K alert
|
|
784
|
+
// (recording silently stalling for weeks is exactly what notifications
|
|
785
|
+
// exist for), and one automatic lossless resume per stall point.
|
|
786
|
+
const client = queryClient;
|
|
787
|
+
walMonitor = new wal_monitor_1.WalMonitor({
|
|
788
|
+
// Owned tables only: in external mode the plugin shares a QuestDB with
|
|
789
|
+
// whatever else the operator runs there, and auto-resuming (or
|
|
790
|
+
// alerting on) someone else's suspended table is not this plugin's
|
|
791
|
+
// call to make.
|
|
792
|
+
listSuspended: async () => (await listSuspendedTables(client)).filter((t) => FULL_EXPORT_TABLE_SET.has(t.name)),
|
|
793
|
+
resumeTable: (name) => client
|
|
794
|
+
.exec(`ALTER TABLE "${name.replace(/"/g, '""')}" RESUME WAL`, 10_000)
|
|
795
|
+
.then(() => undefined),
|
|
796
|
+
onSuspended: (tables, anyAutoResumeFailed) => {
|
|
797
|
+
const summary = tables
|
|
798
|
+
.map((t) => `${t.name} (${t.txnLag} txns behind)`)
|
|
799
|
+
.join(", ");
|
|
800
|
+
app.setPluginError(`QuestDB WAL suspended: ${summary}`);
|
|
801
|
+
const key = "alert:" +
|
|
802
|
+
tables
|
|
803
|
+
.map((t) => `${t.name}@${t.writerTxn}`)
|
|
804
|
+
.sort()
|
|
805
|
+
.join(",") +
|
|
806
|
+
`:${anyAutoResumeFailed}`;
|
|
807
|
+
emitWalNotification("alert", anyAutoResumeFailed
|
|
808
|
+
? `QuestDB history recording is stalled: ${summary}. Automatic ` +
|
|
809
|
+
`resume failed — the data at the stall point is likely ` +
|
|
810
|
+
`unreadable. Open the QuestDB History plugin panel to repair.`
|
|
811
|
+
: `QuestDB history recording is stalled: ${summary}. Automatic ` +
|
|
812
|
+
`recovery is being attempted.`, key);
|
|
813
|
+
},
|
|
814
|
+
onResolved: () => {
|
|
815
|
+
emitWalNotification("normal", "QuestDB history recording has recovered.", "normal:recovered");
|
|
816
|
+
app.setPluginStatus(`Recording to QuestDB at ${ilpHost}:${ilpPort}`);
|
|
817
|
+
},
|
|
818
|
+
debug: (msg) => app.debug(msg),
|
|
819
|
+
error: (msg) => app.error(msg),
|
|
820
|
+
});
|
|
821
|
+
walMonitor.start();
|
|
626
822
|
app.setPluginStatus(`Recording to QuestDB at ${ilpHost}:${ilpPort}`);
|
|
627
823
|
// Everything is registered — only now does the plugin count as running.
|
|
628
824
|
// Any earlier return/throw leaves the flag false, so a half-started
|
|
@@ -682,6 +878,11 @@ module.exports = (app) => {
|
|
|
682
878
|
clearInterval(schemaHealTimer);
|
|
683
879
|
schemaHealTimer = null;
|
|
684
880
|
}
|
|
881
|
+
if (walMonitor) {
|
|
882
|
+
walMonitor.stop();
|
|
883
|
+
walMonitor = null;
|
|
884
|
+
}
|
|
885
|
+
clearWalAlertOnTeardown();
|
|
685
886
|
schemaMismatch = false;
|
|
686
887
|
// Clear the clamp warning so it doesn't survive into the next start
|
|
687
888
|
// (e.g. a switch to external/unmanaged mode that never calls ensureRunning).
|
|
@@ -741,49 +942,16 @@ module.exports = (app) => {
|
|
|
741
942
|
// still reads "running". Surface it explicitly so the panel can warn
|
|
742
943
|
// instead of looking healthy. `txnLag` = sequencer ahead of writer =
|
|
743
944
|
// the backlog that will never drain until the WAL is resumed.
|
|
945
|
+
// `autoResume` is the monitor's verdict for the current stall point:
|
|
946
|
+
// "pending" while the automatic lossless resume may still succeed,
|
|
947
|
+
// "failed" once replay re-hit the same failure — the panel then
|
|
948
|
+
// offers the guided skip instead of just the resume button.
|
|
744
949
|
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
950
|
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
|
-
});
|
|
951
|
+
suspendedTables = (await listSuspendedTables(queryClient)).map((t) => ({
|
|
952
|
+
...t,
|
|
953
|
+
autoResume: walMonitor?.outcomeFor(t.name) ?? null,
|
|
954
|
+
}));
|
|
787
955
|
}
|
|
788
956
|
catch {
|
|
789
957
|
// wal_tables() itself is unavailable on non-WAL/older QuestDB, or
|
|
@@ -1025,6 +1193,7 @@ module.exports = (app) => {
|
|
|
1025
1193
|
// generation one more time.
|
|
1026
1194
|
assertNotStopped();
|
|
1027
1195
|
ulimitClamp = null;
|
|
1196
|
+
containerEpoch++;
|
|
1028
1197
|
await containers.ensureRunning(QUESTDB_CONTAINER_NAME, updateConfig, {
|
|
1029
1198
|
onUlimitClamped,
|
|
1030
1199
|
});
|
|
@@ -1125,17 +1294,14 @@ module.exports = (app) => {
|
|
|
1125
1294
|
.json({ error: "QuestDB connection not initialized" });
|
|
1126
1295
|
return;
|
|
1127
1296
|
}
|
|
1128
|
-
const
|
|
1129
|
-
const suspended = client
|
|
1130
|
-
.toObjects(walResult)
|
|
1131
|
-
.filter((row) => FULL_EXPORT_TABLE_SET.has(String(row.name)));
|
|
1297
|
+
const suspended = (await listSuspendedTables(client)).filter((row) => FULL_EXPORT_TABLE_SET.has(row.name));
|
|
1132
1298
|
const results = [];
|
|
1133
1299
|
for (const row of suspended) {
|
|
1134
|
-
const table =
|
|
1300
|
+
const table = row.name;
|
|
1135
1301
|
const base = {
|
|
1136
1302
|
table,
|
|
1137
|
-
writerTxn:
|
|
1138
|
-
sequencerTxn:
|
|
1303
|
+
writerTxn: row.writerTxn,
|
|
1304
|
+
sequencerTxn: row.sequencerTxn,
|
|
1139
1305
|
};
|
|
1140
1306
|
try {
|
|
1141
1307
|
// Plain RESUME WAL replays from the next unapplied txn — it
|
|
@@ -1170,6 +1336,189 @@ module.exports = (app) => {
|
|
|
1170
1336
|
});
|
|
1171
1337
|
}
|
|
1172
1338
|
});
|
|
1339
|
+
// Per-suspended-table repair diagnosis: the pending-segment map, the
|
|
1340
|
+
// minimal bounded skip that would get past the stuck segment (with the
|
|
1341
|
+
// exact loss it implies), and the apply job's real failure reason
|
|
1342
|
+
// scraped from the engine log. The panel calls this when a suspension's
|
|
1343
|
+
// automatic resume has failed, to present the skip decision with its
|
|
1344
|
+
// cost quantified instead of a blind "RESUME WAL FROM TXN" incantation.
|
|
1345
|
+
router.get("/api/wal-diagnosis", async (_req, res) => {
|
|
1346
|
+
try {
|
|
1347
|
+
const client = queryClient;
|
|
1348
|
+
if (!client) {
|
|
1349
|
+
res
|
|
1350
|
+
.status(503)
|
|
1351
|
+
.json({ error: "QuestDB connection not initialized" });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
const suspended = (await listSuspendedTables(client)).filter((row) => FULL_EXPORT_TABLE_SET.has(row.name));
|
|
1355
|
+
const logLines = suspended.length > 0 ? await fetchEngineLogLines() : null;
|
|
1356
|
+
const tables = [];
|
|
1357
|
+
for (const table of suspended) {
|
|
1358
|
+
let segments = [];
|
|
1359
|
+
let segmentError = null;
|
|
1360
|
+
try {
|
|
1361
|
+
segments = await pendingSegments(client, table);
|
|
1362
|
+
}
|
|
1363
|
+
catch (err) {
|
|
1364
|
+
// wal_transactions() may be unavailable on an older QuestDB —
|
|
1365
|
+
// report the rest of the diagnosis without a skip plan.
|
|
1366
|
+
segmentError = err instanceof Error ? err.message : String(err);
|
|
1367
|
+
}
|
|
1368
|
+
tables.push({
|
|
1369
|
+
...table,
|
|
1370
|
+
autoResume: walMonitor?.outcomeFor(table.name) ?? null,
|
|
1371
|
+
// Commit-time of the first unapplied txn = when apply froze.
|
|
1372
|
+
suspendedSince: segments.length > 0 ? segments[0].minTimestamp : null,
|
|
1373
|
+
pendingSegments: segments.length,
|
|
1374
|
+
skipPlan: (0, wal_monitor_1.computeSkipPlan)(segments),
|
|
1375
|
+
segmentError,
|
|
1376
|
+
applyError: logLines
|
|
1377
|
+
? (0, wal_monitor_1.extractApplyError)(logLines, table.name)
|
|
1378
|
+
: null,
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
res.json({ tables });
|
|
1382
|
+
}
|
|
1383
|
+
catch (err) {
|
|
1384
|
+
res.status(500).json({
|
|
1385
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
// Bounded lossy skip past an unreadable WAL segment. This is the ONLY
|
|
1390
|
+
// write path that can lose data, so it is deliberately narrow:
|
|
1391
|
+
// - the table must be one of ours and currently suspended;
|
|
1392
|
+
// - a lossless resume is attempted first and re-checked — if it
|
|
1393
|
+
// sticks, nothing is skipped;
|
|
1394
|
+
// - the skip target is computed server-side from the live segment
|
|
1395
|
+
// map (never taken from the client), so it can only ever be the
|
|
1396
|
+
// minimal next-segment boundary.
|
|
1397
|
+
// The client sends the plan it displayed only so a stale UI can be
|
|
1398
|
+
// rejected instead of skipping more than the operator agreed to.
|
|
1399
|
+
router.post("/api/resume-wal/skip", async (req, res) => {
|
|
1400
|
+
try {
|
|
1401
|
+
const client = queryClient;
|
|
1402
|
+
if (!client) {
|
|
1403
|
+
res
|
|
1404
|
+
.status(503)
|
|
1405
|
+
.json({ error: "QuestDB connection not initialized" });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
const table = String(req.body?.table ?? "");
|
|
1409
|
+
if (!FULL_EXPORT_TABLE_SET.has(table)) {
|
|
1410
|
+
res.status(400).json({ error: `Unknown table: ${table}` });
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const quoted = `"${table.replace(/"/g, '""')}"`;
|
|
1414
|
+
// Captured at entry: stop/purge bump the generation, an update
|
|
1415
|
+
// recreate bumps the container epoch — the destructive statement
|
|
1416
|
+
// below must never run against whatever lifecycle replaced the
|
|
1417
|
+
// state this request was validated on.
|
|
1418
|
+
const generation = lifecycleGeneration;
|
|
1419
|
+
const epoch = containerEpoch;
|
|
1420
|
+
const recheck = async () => (await listSuspendedTables(client)).find((t) => t.name === table);
|
|
1421
|
+
const before = await recheck();
|
|
1422
|
+
if (!before) {
|
|
1423
|
+
res.json({
|
|
1424
|
+
skipped: false,
|
|
1425
|
+
healed: true,
|
|
1426
|
+
message: `${table} is not suspended — nothing to skip.`,
|
|
1427
|
+
});
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// Safety ladder step 1: the lossless path. If the suspension cause
|
|
1431
|
+
// was transient after all, this recovers everything and the lossy
|
|
1432
|
+
// step below never runs.
|
|
1433
|
+
await client.exec(`ALTER TABLE ${quoted} RESUME WAL`, 10_000);
|
|
1434
|
+
await new Promise((resolve) => setTimeout(resolve, SKIP_RECHECK_DELAY_MS));
|
|
1435
|
+
const afterResume = await recheck();
|
|
1436
|
+
if (!afterResume) {
|
|
1437
|
+
res.json({
|
|
1438
|
+
skipped: false,
|
|
1439
|
+
healed: true,
|
|
1440
|
+
message: `Lossless resume succeeded on ${table} — no data was skipped. The backlog is now replaying.`,
|
|
1441
|
+
});
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (afterResume.writerTxn !== before.writerTxn) {
|
|
1445
|
+
res.json({
|
|
1446
|
+
skipped: false,
|
|
1447
|
+
healed: false,
|
|
1448
|
+
progressed: true,
|
|
1449
|
+
message: `The writer advanced on ${table} before re-suspending — the situation changed. Re-run the diagnosis.`,
|
|
1450
|
+
});
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
// Step 2: replay re-froze at the same transaction. Everything the
|
|
1454
|
+
// destructive statement depends on is validated from the state
|
|
1455
|
+
// read CLOSEST to execution: re-read the suspension state, require
|
|
1456
|
+
// the writer still frozen at the same txn and the lifecycle
|
|
1457
|
+
// untouched by stop/update/purge, recompute the plan from that
|
|
1458
|
+
// state, and require it to match the operator's confirmation in
|
|
1459
|
+
// every field. Only that final validated plan is executed.
|
|
1460
|
+
const preExec = await recheck();
|
|
1461
|
+
if (generation !== lifecycleGeneration ||
|
|
1462
|
+
epoch !== containerEpoch ||
|
|
1463
|
+
!preExec ||
|
|
1464
|
+
preExec.writerTxn !== before.writerTxn) {
|
|
1465
|
+
res.status(409).json({
|
|
1466
|
+
error: "The table's state changed while confirming the skip — nothing was skipped. Re-run the diagnosis.",
|
|
1467
|
+
});
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
const plan = (0, wal_monitor_1.computeSkipPlan)(await pendingSegments(client, preExec));
|
|
1471
|
+
if (!plan) {
|
|
1472
|
+
res.status(500).json({
|
|
1473
|
+
error: `Cannot compute a skip plan for ${table} — no pending transactions found.`,
|
|
1474
|
+
});
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (!(0, wal_monitor_1.skipPlansEqual)(plan, req.body?.confirmPlan)) {
|
|
1478
|
+
res.status(409).json({
|
|
1479
|
+
error: "The skip plan changed since the diagnosis was displayed. Re-run the diagnosis and confirm again.",
|
|
1480
|
+
skipPlan: plan,
|
|
1481
|
+
});
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
// Last state read before the destructive statement — only
|
|
1485
|
+
// synchronous code between this validation and the ALTER, so no
|
|
1486
|
+
// await window remains in which the writer could move or the
|
|
1487
|
+
// lifecycle could be swapped. (The two HTTP calls are still
|
|
1488
|
+
// distinct reads; that residue is irreducible client-side.)
|
|
1489
|
+
const finalState = await recheck();
|
|
1490
|
+
if (generation !== lifecycleGeneration ||
|
|
1491
|
+
epoch !== containerEpoch ||
|
|
1492
|
+
!finalState ||
|
|
1493
|
+
finalState.writerTxn !== before.writerTxn) {
|
|
1494
|
+
res.status(409).json({
|
|
1495
|
+
error: "The table's state changed while confirming the skip — nothing was skipped. Re-run the diagnosis.",
|
|
1496
|
+
skipPlan: plan,
|
|
1497
|
+
});
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
app.error(`Skipping ${plan.skippedTxns} unreadable WAL txn(s) on ${table} ` +
|
|
1501
|
+
`(walId=${plan.walId} segmentId=${plan.segmentId}, ` +
|
|
1502
|
+
`${plan.skipWindowStart} → ${plan.skipWindowEnd}) — ` +
|
|
1503
|
+
`RESUME WAL FROM TXN ${plan.skipToTxn}, requested via panel`);
|
|
1504
|
+
await client.exec(`ALTER TABLE ${quoted} RESUME WAL FROM TXN ${plan.skipToTxn}`, 10_000);
|
|
1505
|
+
await new Promise((resolve) => setTimeout(resolve, SKIP_RECHECK_DELAY_MS));
|
|
1506
|
+
const afterSkip = await recheck();
|
|
1507
|
+
res.json({
|
|
1508
|
+
skipped: true,
|
|
1509
|
+
skipPlan: plan,
|
|
1510
|
+
stillSuspended: Boolean(afterSkip),
|
|
1511
|
+
message: afterSkip
|
|
1512
|
+
? `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.`
|
|
1513
|
+
: `Skipped ${plan.skippedTxns} txn(s) on ${table} (${plan.skipWindowStart} → ${plan.skipWindowEnd}). The remaining backlog is replaying.`,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
catch (err) {
|
|
1517
|
+
res.status(500).json({
|
|
1518
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1173
1522
|
router.post("/api/purge-data", async (_req, res) => {
|
|
1174
1523
|
try {
|
|
1175
1524
|
// External mode is a config fact independent of the runtime, so
|
|
@@ -1212,6 +1561,11 @@ module.exports = (app) => {
|
|
|
1212
1561
|
clearInterval(schemaHealTimer);
|
|
1213
1562
|
schemaHealTimer = null;
|
|
1214
1563
|
}
|
|
1564
|
+
if (walMonitor) {
|
|
1565
|
+
walMonitor.stop();
|
|
1566
|
+
walMonitor = null;
|
|
1567
|
+
}
|
|
1568
|
+
clearWalAlertOnTeardown();
|
|
1215
1569
|
if (retentionTimer) {
|
|
1216
1570
|
clearInterval(retentionTimer);
|
|
1217
1571
|
retentionTimer = null;
|