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/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
- 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
- });
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 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)));
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 = String(row.name);
1332
+ const table = row.name;
1135
1333
  const base = {
1136
1334
  table,
1137
- writerTxn: Number(row.writerTxn ?? 0),
1138
- sequencerTxn: Number(row.sequencerTxn ?? 0),
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;