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 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
- let walResult;
762
- if (walTablesHasErrorColumns === null) {
763
- try {
764
- walResult = await queryClient.exec(RICH_WAL_QUERY);
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 walResult = await client.exec("SELECT name, writerTxn, sequencerTxn FROM wal_tables() WHERE suspended = true", 10_000);
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 = String(row.name);
1300
+ const table = row.name;
1135
1301
  const base = {
1136
1302
  table,
1137
- writerTxn: Number(row.writerTxn ?? 0),
1138
- sequencerTxn: Number(row.sequencerTxn ?? 0),
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;