signalk-questdb 2.1.4 → 2.1.5

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.
Files changed (45) hide show
  1. package/README.md +112 -27
  2. package/dist/api-contract.d.ts +45 -0
  3. package/dist/configpanel/responses.d.ts +28 -1
  4. package/dist/configpanel/responses.js +74 -0
  5. package/dist/configpanel/responses.js.map +1 -1
  6. package/dist/ilp-writer.d.ts +52 -0
  7. package/dist/ilp-writer.js +88 -7
  8. package/dist/ilp-writer.js.map +1 -1
  9. package/dist/index.js +477 -45
  10. package/dist/index.js.map +1 -1
  11. package/dist/migration-checkpoint.d.ts +170 -0
  12. package/dist/migration-checkpoint.js +276 -0
  13. package/dist/migration-checkpoint.js.map +1 -0
  14. package/dist/migration-cleanup.d.ts +48 -0
  15. package/dist/migration-cleanup.js +155 -0
  16. package/dist/migration-cleanup.js.map +1 -0
  17. package/dist/migration.d.ts +134 -17
  18. package/dist/migration.js +694 -158
  19. package/dist/migration.js.map +1 -1
  20. package/dist/questdb-mount.d.ts +106 -0
  21. package/dist/questdb-mount.js +173 -0
  22. package/dist/questdb-mount.js.map +1 -0
  23. package/dist/restore.js +44 -8
  24. package/dist/restore.js.map +1 -1
  25. package/package.json +2 -2
  26. package/public/assets/AppPanel-p27aQlBS.js +1 -0
  27. package/public/assets/PluginConfigurationPanel-BO_4OxBo.js +9 -0
  28. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BiBqFhVp.js +1 -0
  29. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__275086164343916__loadShare__react__loadShare__.js-BWO9MGdR.js +1 -0
  30. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__275086164343916__prebuild__react__prebuild__-D2g10OtU.js +1 -0
  31. package/public/assets/dist-BK97MX24.js +1 -0
  32. package/public/assets/helpers-Bizl8Raj.js +1 -0
  33. package/public/assets/hostInit-BFZIDSUI.js +1 -0
  34. package/public/assets/index-bERRzqQZ.js +2 -0
  35. package/public/assets/pendingShares-BS4kO2ec.js +2 -0
  36. package/public/assets/{dist-BgxWXJ5y.js → utils-jR9fY54i.js} +2 -2
  37. package/public/assets/{vite-preload-helper-HclGiUj8.js → vite-preload-helper-uBIymjUX.js} +1 -1
  38. package/public/remoteEntry.js +2 -2
  39. package/public/assets/AppPanel-CNFtJpeq.js +0 -1
  40. package/public/assets/PluginConfigurationPanel-CR4mD9IH.js +0 -9
  41. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-7tb3Q-Bx.js +0 -1
  42. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__1__loadShare__react__loadShare__.js-DzhF4wXp.js +0 -1
  43. package/public/assets/hostInit-CyS9_M1c.js +0 -1
  44. package/public/assets/index-Dh-t5VMR.js +0 -1
  45. package/public/assets/virtualExposes-BBIkbTtD.js +0 -1
package/dist/index.js CHANGED
@@ -10,7 +10,10 @@ import { RESTORE_SOURCE, restoreFromHistory } from "./restore.js";
10
10
  import { createConsoleProxy } from "./console-proxy.js";
11
11
  import { buildFullExportWhere } from "./full-export-range.js";
12
12
  import { detectInflux, validateInfluxUrl } from "./influx-detect.js";
13
- import { listBuckets, listMeasurements, runMigration, MigrationRun, } from "./migration.js";
13
+ import { DEFAULT_WINDOW_MS, isSignalKContext, listBuckets, listContexts, listMeasurements, runMigration, MigrationRun, } from "./migration.js";
14
+ import { FileCheckpointStore, migrationIdentity, sameIdentity, } from "./migration-checkpoint.js";
15
+ import { countLegacyImportRows, removeLegacyImportRows, } from "./migration-cleanup.js";
16
+ import { adoptDatabaseFromVolumeRoot, wipeDataInSignalk, resolveQuestdbMount, } from "./questdb-mount.js";
14
17
  import { WalMonitor, buildPendingSegmentsSQL, computeSkipPlan, extractApplyError, isPartitionOpenFailure, skipPlansEqual, } from "./wal-monitor.js";
15
18
  import { QUESTDB_INTERNAL_HTTP_PORT, QUESTDB_INTERNAL_ILP_PORT, QUESTDB_INTERNAL_PG_PORT, QUESTDB_ACCESSIBLE_PORTS, resolveManagedEndpoints, resolveLanExposureHost, lanExposureEndpoints, } from "./questdb-endpoint.js";
16
19
  import { nofileClampSatisfied, readMaxMapCount } from "./host-limits.js";
@@ -102,6 +105,23 @@ export default (app) => {
102
105
  // no writer to resume it onto. A restart therefore forgets it, and re-running
103
106
  // is safe because imported rows upsert on (ts, path, context, source).
104
107
  let activeMigration = null;
108
+ // While the string table is being rebuilt — or a rebuild that failed after
109
+ // its swap is being finished — nothing may import into it, and the schema
110
+ // heal must keep its hands off it.
111
+ let legacyCleanupRunning = false;
112
+ // The rebuild in flight, so stop() can wait for it: disconnecting the
113
+ // writer while it is held would drop the held lines, and a swap left half
114
+ // done is work for the next run rather than a loss, but still work.
115
+ let legacyCleanup = null;
116
+ // A heal in progress drops and recreates a mismatched table; the rebuild
117
+ // waits it out rather than rename underneath it.
118
+ const awaitHealIdle = async () => {
119
+ for (let waited = 0; healing && waited < 30_000; waited += 100) {
120
+ await new Promise((r) => setTimeout(r, 100));
121
+ }
122
+ if (healing)
123
+ throw new Error("A schema repair is in progress; try again");
124
+ };
105
125
  let migrationCounter = 0;
106
126
  let queryClient = null;
107
127
  let retentionTimer = null;
@@ -217,6 +237,10 @@ export default (app) => {
217
237
  // anyway. After this it forces its teardown through even if a hung start
218
238
  // never released the lock (see PURGE_LOCK_TIMEOUT_MS use below).
219
239
  const PURGE_LOCK_TIMEOUT_MS = 30000;
240
+ // A rebuild of the string table waits this long for the lifecycle lock. A
241
+ // wedged start pins the lock (see PURGE_LOCK_TIMEOUT_MS); unlike a purge
242
+ // the rebuild then gives up rather than proceed, since it is optional.
243
+ const LEGACY_CLEANUP_LOCK_TIMEOUT_MS = 30000;
220
244
  // Record a clamp event so /api/status and the config-panel banner can
221
245
  // surface it. (The plugin status line is not used — it is driven by the
222
246
  // recording state and would immediately overwrite a warning set here.)
@@ -250,7 +274,10 @@ export default (app) => {
250
274
  // is rebuilt with the correct `ts` schema. Best-effort: introspection/heal
251
275
  // errors are logged, not thrown, so they never break the lifecycle.
252
276
  const healSchemaTables = async () => {
253
- if (!queryClient || healing)
277
+ // Not while the string table is being rebuilt: the rebuild renames it
278
+ // away for a moment, and a heal finding it absent would create a fresh
279
+ // one for the rebuilt table to then collide with.
280
+ if (!queryClient || healing || legacyCleanupRunning)
254
281
  return;
255
282
  healing = true;
256
283
  try {
@@ -319,34 +346,9 @@ export default (app) => {
319
346
  debug: (msg) => app.debug(msg),
320
347
  mountPath: "/plugins/signalk-questdb/console",
321
348
  });
322
- /**
323
- * Compute the bind-mount source for QuestDB's /var/lib/questdb volume.
324
- *
325
- * `app.getDataDirPath()` returns the path from SK's own perspective. On
326
- * bare-metal that is the host path and the runtime can use it directly.
327
- * When SK runs inside a container the same string is the SK-container-
328
- * internal path, and the host's runtime daemon — which is on the host,
329
- * not inside SK — cannot resolve it. signalk-container 1.9.0+ exposes
330
- * `resolveHostPath()` to translate such paths back to the host source;
331
- * if it returns null (older signalk-container or no covering mount) we
332
- * fall back to the original path, preserving bare-metal behaviour.
333
- */
334
- async function resolveQuestdbVolumeSource(containers) {
335
- const dataPath = app.getDataDirPath();
336
- if (typeof containers.resolveHostPath !== "function")
337
- return dataPath;
338
- // signalk-container's resolveHostPath is documented as non-throwing, but
339
- // we consume it through a runtime cross-plugin API (cast through `any`),
340
- // so an unexpected throw from a future or older version must not abort
341
- // startup — fall back to the original path instead.
342
- try {
343
- const resolved = await containers.resolveHostPath(dataPath);
344
- return resolved?.source ?? dataPath;
345
- }
346
- catch (err) {
347
- app.debug("resolveHostPath threw, falling back to dataPath:", err);
348
- return dataPath;
349
- }
349
+ /** The mount for QuestDB's data directory — see questdb-mount.ts. */
350
+ async function resolveQuestdbDataMount(containers) {
351
+ return resolveQuestdbMount(containers, app.getDataDirPath(), (msg) => app.debug(msg));
350
352
  }
351
353
  /**
352
354
  * Apply the chosen networking to a managed-QuestDB ContainerConfig and
@@ -718,16 +720,36 @@ export default (app) => {
718
720
  app.debug("container runtime ready, starting QuestDB");
719
721
  try {
720
722
  const containerEnv = buildContainerEnv(config);
721
- const volumeSource = await resolveQuestdbVolumeSource(containers);
723
+ const mount = await resolveQuestdbDataMount(containers);
722
724
  if (signal.aborted)
723
725
  return;
726
+ // A volume-backed data directory: what QuestDB wrote at the volume's
727
+ // root while the offset went unhonoured is its database, and would be
728
+ // left behind. Both places are one volume, so it is renamed across.
729
+ if (mount.volumeRootInSignalk) {
730
+ const fs = await import("fs/promises");
731
+ const dataFs = {
732
+ exists: (p) => fs.access(p).then(() => true, () => false),
733
+ rename: (from, to) => fs.rename(from, to),
734
+ mkdir: (p) => fs.mkdir(p, { recursive: true }).then(() => { }),
735
+ };
736
+ const moved = await adoptDatabaseFromVolumeRoot(dataFs, mount.volumeRootInSignalk, app.getDataDirPath(),
737
+ // The container from before may still be running on that root,
738
+ // and QuestDB writes to paths under its root, so it is stopped
739
+ // before its directories move — once the move is known to go
740
+ // ahead; ensureRunning below brings it back on the new mount.
741
+ () => containers.stop(QUESTDB_CONTAINER_NAME));
742
+ if (signal.aborted)
743
+ return;
744
+ if (moved.length > 0) {
745
+ app.debug(`moved QuestDB's database from the volume root into the data directory: ${moved.join(", ")}`);
746
+ }
747
+ }
724
748
  const containerConfig = {
725
749
  image: "questdb/questdb",
726
750
  tag: config.questdbVersion ?? "latest",
727
- volumes: {
728
- "/var/lib/questdb": volumeSource,
729
- },
730
- env: containerEnv,
751
+ volumes: mount.volumes,
752
+ env: { ...containerEnv, ...mount.env },
731
753
  restart: "unless-stopped",
732
754
  resources: buildResourceLimits(config),
733
755
  ulimits: QUESTDB_ULIMITS,
@@ -1225,6 +1247,10 @@ export default (app) => {
1225
1247
  // Clear the clamp warning so it doesn't survive into the next start
1226
1248
  // (e.g. a switch to external/unmanaged mode that never calls ensureRunning).
1227
1249
  ulimitClamp = null;
1250
+ // A rebuild of the string table first: its hold keeps the writer's last
1251
+ // lines back, and disconnecting under it would lose them.
1252
+ if (legacyCleanup)
1253
+ await legacyCleanup.catch(() => { });
1228
1254
  // Stop any import before the writer goes away: runMigration holds a
1229
1255
  // direct reference to it, so a run left going would keep enqueueing
1230
1256
  // lines onto a disconnected writer for as long as its read loop lasts.
@@ -1549,6 +1575,14 @@ export default (app) => {
1549
1575
  });
1550
1576
  router.post("/api/update/apply", async (_req, res) => {
1551
1577
  try {
1578
+ // Not under a rebuild of the string table: it holds the writer
1579
+ // across a swap, and an update tears the writer down.
1580
+ if (legacyCleanupRunning) {
1581
+ res.status(409).json({
1582
+ error: "The string table is being rebuilt; wait for it to finish first",
1583
+ });
1584
+ return;
1585
+ }
1552
1586
  // Captured at route entry, before ANY await: a stop() that lands
1553
1587
  // during the release fetch below must already invalidate this
1554
1588
  // update, not just one that lands after the lock is acquired.
@@ -1601,15 +1635,16 @@ export default (app) => {
1601
1635
  // recreate without throwing, and persisting newTag now would make
1602
1636
  // the plugin claim a version the still-running old container is not
1603
1637
  // actually on, surviving restarts.
1604
- const updateVolumeSource = await resolveQuestdbVolumeSource(containers);
1638
+ const updateMount = await resolveQuestdbDataMount(containers);
1605
1639
  app.setPluginStatus(`Starting QuestDB ${newTag}...`);
1606
1640
  const updateConfig = {
1607
1641
  image: "questdb/questdb",
1608
1642
  tag: newTag,
1609
- volumes: {
1610
- "/var/lib/questdb": updateVolumeSource,
1643
+ volumes: updateMount.volumes,
1644
+ env: {
1645
+ ...buildContainerEnv(currentConfig ?? {}),
1646
+ ...updateMount.env,
1611
1647
  },
1612
- env: buildContainerEnv(currentConfig ?? {}),
1613
1648
  restart: "unless-stopped",
1614
1649
  resources: currentConfig
1615
1650
  ? buildResourceLimits(currentConfig)
@@ -1999,8 +2034,21 @@ export default (app) => {
1999
2034
  });
2000
2035
  }
2001
2036
  });
2037
+ // One import runs at a time, so one checkpoint is all there is to keep.
2038
+ // Beside the data directory, not in it: QuestDB's entrypoint chowns its
2039
+ // data root to its own uid on start, after which the Signal K user can
2040
+ // no longer create a file there.
2041
+ const checkpoints = new FileCheckpointStore(`${app.getDataDirPath()}.influx-import-checkpoint.json`);
2002
2042
  router.post("/api/purge-data", async (_req, res) => {
2003
2043
  try {
2044
+ // Not under a rebuild of the string table: it holds the writer
2045
+ // across a swap, and this tears the writer down.
2046
+ if (legacyCleanupRunning) {
2047
+ res.status(409).json({
2048
+ error: "The string table is being rebuilt; wait for it to finish first",
2049
+ });
2050
+ return;
2051
+ }
2004
2052
  // External mode is a config fact independent of the runtime, so
2005
2053
  // answer it first — an external-mode install without signalk-container
2006
2054
  // should get the clear 400, not a 503 about a missing container
@@ -2016,7 +2064,10 @@ export default (app) => {
2016
2064
  res.status(503).json({ error: "Container manager not available" });
2017
2065
  return;
2018
2066
  }
2019
- if (!containers.removeManagedData) {
2067
+ // A volume is purged from this process; only a host path needs
2068
+ // signalk-container's in-userns wipe.
2069
+ const mount = await resolveQuestdbDataMount(containers);
2070
+ if (mount.wipe === "runtime" && !containers.removeManagedData) {
2020
2071
  res.status(501).json({
2021
2072
  error: "Data removal requires signalk-container 1.19.0 or newer. Update it, or delete the QuestDB data directory manually.",
2022
2073
  });
@@ -2060,9 +2111,29 @@ export default (app) => {
2060
2111
  writer = null;
2061
2112
  }
2062
2113
  queryClient = null;
2063
- const hostPath = await resolveQuestdbVolumeSource(containers);
2064
2114
  app.setPluginStatus("Removing QuestDB container and data...");
2065
- await containers.removeManagedData(QUESTDB_CONTAINER_NAME, hostPath, { ownerPluginId: "signalk-questdb" });
2115
+ try {
2116
+ if (mount.wipe === "runtime") {
2117
+ await containers.removeManagedData(QUESTDB_CONTAINER_NAME, mount.wipePath, { ownerPluginId: "signalk-questdb" });
2118
+ }
2119
+ else {
2120
+ // A volume: the runtime cannot mount it by Signal K's path,
2121
+ // so signalk-container's in-userns wipe would mount the wrong
2122
+ // directory. Delete from here, the one process that reaches
2123
+ // it.
2124
+ await containers.remove(QUESTDB_CONTAINER_NAME);
2125
+ const fs = await import("fs/promises");
2126
+ await wipeDataInSignalk((p) => fs.rm(p, { recursive: true, force: true }), mount.wipePath);
2127
+ }
2128
+ }
2129
+ finally {
2130
+ // The checkpoint names a position in the database being
2131
+ // deleted; a fresh one must not be offered a resume into it.
2132
+ // Cleared even when the delete is refused: the purge was asked
2133
+ // for, and the position would outlive the database once it is
2134
+ // deleted by hand.
2135
+ await checkpoints.clear();
2136
+ }
2066
2137
  };
2067
2138
  // Purge is the RECOVERY action, so it must make progress even when a
2068
2139
  // start is wedged on a never-settling container call. Step 1: signal
@@ -2140,6 +2211,52 @@ export default (app) => {
2140
2211
  finishedAt: run.finishedAt,
2141
2212
  progress: { ...run.progress },
2142
2213
  error: run.error,
2214
+ resumedFrom: run.resumedFrom,
2215
+ });
2216
+ const CONFIRM_STORED_TIMEOUT_MS = 5_000;
2217
+ // QuestDB's word that the rows behind a position are committed and
2218
+ // applied: the newest row written to each table can be read back. ILP
2219
+ // carries nanoseconds and QuestDB stores microseconds, so the row is
2220
+ // looked up at the truncated instant.
2221
+ const instantLiteral = (tsNanos) => {
2222
+ const micros = tsNanos / 1000n;
2223
+ const iso = new Date(Number(micros / 1000n)).toISOString();
2224
+ return `'${iso.replace("Z", `${String(micros % 1000n).padStart(3, "0")}Z`)}'`;
2225
+ };
2226
+ const stringLiteral = (s) => `'${s.replace(/'/g, "''")}'`;
2227
+ const confirmStored = async (tail) => {
2228
+ if (!queryClient)
2229
+ return false;
2230
+ const source = stringLiteral(tail.source);
2231
+ const lookups = [];
2232
+ if (tail.numeric) {
2233
+ lookups.push(`SELECT count() FROM signalk WHERE ts = ${instantLiteral(tail.numeric.tsNanos)} AND path = ${stringLiteral(tail.numeric.path)} AND context = ${stringLiteral(tail.numeric.context)} AND source = ${source}`);
2234
+ }
2235
+ if (tail.string) {
2236
+ lookups.push(`SELECT count() FROM signalk_str WHERE ts = ${instantLiteral(tail.string.tsNanos)} AND path = ${stringLiteral(tail.string.path)} AND context = ${stringLiteral(tail.string.context)} AND source = ${source}`);
2237
+ }
2238
+ if (tail.position) {
2239
+ lookups.push(`SELECT count() FROM signalk_position WHERE ts = ${instantLiteral(tail.position.tsNanos)} AND context = ${stringLiteral(tail.position.context)} AND source = ${source}`);
2240
+ }
2241
+ // Short-fused: a QuestDB that does not answer is a "no" — the position
2242
+ // waits — rather than a stall of the import behind an open request.
2243
+ for (const sql of lookups) {
2244
+ const result = await queryClient.exec(sql, CONFIRM_STORED_TIMEOUT_MS);
2245
+ if (Number(result.dataset[0]?.[0] ?? 0) < 1)
2246
+ return false;
2247
+ }
2248
+ return true;
2249
+ };
2250
+ const interruptedView = (cp) => ({
2251
+ url: cp.identity.url,
2252
+ type: cp.identity.type,
2253
+ bucket: cp.identity.bucket,
2254
+ from: cp.identity.from,
2255
+ to: cp.identity.to,
2256
+ measurement: cp.current?.measurement,
2257
+ windowStart: cp.current && new Date(cp.current.windowStart).toISOString(),
2258
+ measurementsDone: cp.done.length,
2259
+ updatedAt: cp.updatedAt,
2143
2260
  });
2144
2261
  // Auth travels in the request BODY rather than plugin config: the token
2145
2262
  // is only needed for the duration of an import and storing it in the
@@ -2245,6 +2362,41 @@ export default (app) => {
2245
2362
  });
2246
2363
  }
2247
2364
  });
2365
+ // POST like /buckets and /measurements: the credentials travel in the
2366
+ // body. Answers with the vessels the source holds, and which of them is
2367
+ // this server's own vessel, if any is.
2368
+ router.post("/api/migration/contexts", async (req, res) => {
2369
+ const body = (req.body ?? {});
2370
+ const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
2371
+ const bucket = typeof body.bucket === "string" ? body.bucket : "";
2372
+ if (!baseUrl || !bucket) {
2373
+ res.status(400).json({
2374
+ error: "Missing or invalid url/bucket",
2375
+ contexts: [],
2376
+ });
2377
+ return;
2378
+ }
2379
+ try {
2380
+ const contexts = await listContexts({
2381
+ url: baseUrl,
2382
+ type: typeof body.type === "string" ? body.type : "influxdb2",
2383
+ bucket,
2384
+ auth: readAuth(body),
2385
+ });
2386
+ res.json({
2387
+ contexts,
2388
+ self: contexts.includes(app.selfContext)
2389
+ ? app.selfContext
2390
+ : undefined,
2391
+ });
2392
+ }
2393
+ catch (err) {
2394
+ res.status(502).json({
2395
+ error: err instanceof Error ? err.message : "Unknown error",
2396
+ contexts: [],
2397
+ });
2398
+ }
2399
+ });
2248
2400
  router.post("/api/migration/start", async (req, res) => {
2249
2401
  // One import at a time. Two concurrent runs would interleave on the
2250
2402
  // single ILP writer and race each other for its buffer, and there is
@@ -2262,7 +2414,35 @@ export default (app) => {
2262
2414
  });
2263
2415
  return;
2264
2416
  }
2265
- const body = (req.body ?? {});
2417
+ if (legacyCleanupRunning) {
2418
+ res.status(409).json({
2419
+ error: "The string table is being rebuilt; try again in a moment",
2420
+ });
2421
+ return;
2422
+ }
2423
+ const posted = (req.body ?? {});
2424
+ const stored = await checkpoints.load();
2425
+ // Resuming takes the import's parameters from the checkpoint and only
2426
+ // the credentials from the request: after a restart the panel no
2427
+ // longer knows what range was entered, and credentials are never
2428
+ // stored. The stored parameters then go through the same validation
2429
+ // as posted ones — the file is on disk and could have been edited.
2430
+ let body = posted;
2431
+ if (posted.resume === true) {
2432
+ if (!stored) {
2433
+ res.status(409).json({
2434
+ error: "There is no interrupted import to resume",
2435
+ });
2436
+ return;
2437
+ }
2438
+ body = {
2439
+ ...stored.identity,
2440
+ token: posted.token,
2441
+ username: posted.username,
2442
+ password: posted.password,
2443
+ org: posted.org,
2444
+ };
2445
+ }
2266
2446
  const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
2267
2447
  const bucket = typeof body.bucket === "string" ? body.bucket : "";
2268
2448
  if (!baseUrl || !bucket) {
@@ -2309,7 +2489,7 @@ export default (app) => {
2309
2489
  rawContext === `vessels.${app.selfId}`) {
2310
2490
  importContext = "self";
2311
2491
  }
2312
- else if (/^(vessels|atons|aircraft|sar)\.[A-Za-z0-9:._-]+$/.test(rawContext)) {
2492
+ else if (isSignalKContext(rawContext)) {
2313
2493
  importContext = rawContext;
2314
2494
  }
2315
2495
  else {
@@ -2318,6 +2498,66 @@ export default (app) => {
2318
2498
  });
2319
2499
  return;
2320
2500
  }
2501
+ // Which of the source's vessels is the own one. The source tags every
2502
+ // point with the context the recording server used, and that is not
2503
+ // necessarily this server's — a new install has a new identity — so
2504
+ // it is chosen rather than matched. Absent, a single-vessel source is
2505
+ // unambiguous; a source holding several vessels is refused rather
2506
+ // than filed wholesale under one of them.
2507
+ // A supplied choice is checked against the source too: one that is
2508
+ // not a vessel there would make nothing the own vessel, and the whole
2509
+ // import would land under other contexts without a word.
2510
+ const rawSelf = typeof body.sourceSelfContext === "string"
2511
+ ? body.sourceSelfContext.trim()
2512
+ : "";
2513
+ // A resume of a checkpoint from before vessels were told apart keeps
2514
+ // its meaning — every row the own vessel — rather than being given a
2515
+ // choice it never made, which would make it a different import and
2516
+ // start it over.
2517
+ const legacyResume = posted.resume === true &&
2518
+ stored !== null &&
2519
+ stored.identity.sourceSelfContext === undefined;
2520
+ let found = [];
2521
+ if (!legacyResume) {
2522
+ try {
2523
+ found = await listContexts({
2524
+ url: baseUrl,
2525
+ type: typeof body.type === "string" ? body.type : "influxdb2",
2526
+ bucket,
2527
+ auth: readAuth(body),
2528
+ });
2529
+ }
2530
+ catch (err) {
2531
+ res.status(502).json({
2532
+ error: err instanceof Error ? err.message : "Unknown error",
2533
+ });
2534
+ return;
2535
+ }
2536
+ }
2537
+ let sourceSelfContext;
2538
+ if (found.length === 0) {
2539
+ // No vessels to tell apart: every row is the own vessel.
2540
+ sourceSelfContext = undefined;
2541
+ }
2542
+ else if (rawSelf) {
2543
+ if (!found.includes(rawSelf)) {
2544
+ res.status(400).json({
2545
+ error: `\`${rawSelf}\` is not a vessel in the source; it holds ${found.join(", ")}`,
2546
+ });
2547
+ return;
2548
+ }
2549
+ sourceSelfContext = rawSelf;
2550
+ }
2551
+ else if (found.length === 1) {
2552
+ sourceSelfContext = found[0];
2553
+ }
2554
+ else {
2555
+ res.status(400).json({
2556
+ error: "The source holds several vessels; say which is yours with `sourceSelfContext`",
2557
+ });
2558
+ return;
2559
+ }
2560
+ const others = body.others === "skip" ? "skip" : "keep";
2321
2561
  // Kept deliberately narrow: a label is a short identifier, not free
2322
2562
  // text. Anything else falls back to the default.
2323
2563
  const rawLabel = typeof body.sourceLabel === "string" ? body.sourceLabel.trim() : "";
@@ -2337,6 +2577,8 @@ export default (app) => {
2337
2577
  // a SYMBOL value on every imported row and is what the history API
2338
2578
  // queries by, so a malformed one writes history nothing can read.
2339
2579
  context: importContext,
2580
+ sourceSelfContext,
2581
+ others,
2340
2582
  measurements: Array.isArray(body.measurements)
2341
2583
  ? body.measurements.filter((m) => typeof m === "string")
2342
2584
  : undefined,
@@ -2351,6 +2593,43 @@ export default (app) => {
2351
2593
  };
2352
2594
  // The id only has to be unique within this process, where the counter
2353
2595
  // already guarantees it; the timestamp just makes logs readable.
2596
+ // The same import started again continues, whether it was asked to or
2597
+ // not: the rows upsert, so the result is the same and only the time
2598
+ // differs. A different import replaces the old checkpoint, and it is
2599
+ // removed now rather than when the new run first saves — otherwise a
2600
+ // new run that stopped early would offer to resume the old one.
2601
+ const resumeFrom = stored &&
2602
+ sameIdentity(stored.identity, migrationIdentity(request, DEFAULT_WINDOW_MS))
2603
+ ? stored
2604
+ : undefined;
2605
+ if (stored && !resumeFrom) {
2606
+ // Not worth refusing the import over: the new run overwrites the
2607
+ // file with its own first position anyway.
2608
+ await checkpoints.clear().catch((err) => {
2609
+ app.debug(`migration: old checkpoint not removed: ${err instanceof Error ? err.message : String(err)}`);
2610
+ });
2611
+ }
2612
+ // The awaits above yielded; another start, or a rebuild of the string
2613
+ // table, may have got in.
2614
+ if (activeMigration && activeMigration.state === "running") {
2615
+ res.status(409).json({
2616
+ error: "A migration is already running",
2617
+ run: migrationRunView(activeMigration),
2618
+ });
2619
+ return;
2620
+ }
2621
+ if (legacyCleanupRunning) {
2622
+ res.status(409).json({
2623
+ error: "The string table is being rebuilt; try again in a moment",
2624
+ });
2625
+ return;
2626
+ }
2627
+ if (!writer) {
2628
+ res.status(503).json({
2629
+ error: "QuestDB writer not connected",
2630
+ });
2631
+ return;
2632
+ }
2354
2633
  const run = new MigrationRun(`mig-${Date.now()}-${++migrationCounter}`, baseUrl, bucket);
2355
2634
  activeMigration = run;
2356
2635
  const startedWriter = writer;
@@ -2358,6 +2637,9 @@ export default (app) => {
2358
2637
  // the HTTP call returns immediately and the panel polls /status.
2359
2638
  void runMigration(request, startedWriter, run, {
2360
2639
  debug: (msg) => app.debug(msg),
2640
+ checkpoints,
2641
+ resumeFrom,
2642
+ confirmStored,
2361
2643
  })
2362
2644
  .catch((err) => {
2363
2645
  // runMigration traps its own failures, but a few statements run
@@ -2377,7 +2659,157 @@ export default (app) => {
2377
2659
  run: migrationRunView(run),
2378
2660
  });
2379
2661
  });
2380
- router.get("/api/migration/status", (_req, res) => {
2662
+ router.get("/api/migration/status", async (_req, res) => {
2663
+ // Not while a run is active: its checkpoint is that run's own, and
2664
+ // offering to resume an import that is still going makes no sense.
2665
+ const stored = activeMigration?.state === "running"
2666
+ ? null
2667
+ : await checkpoints.load();
2668
+ res.json({
2669
+ run: activeMigration ? migrationRunView(activeMigration) : undefined,
2670
+ interrupted: stored ? interruptedView(stored) : undefined,
2671
+ });
2672
+ });
2673
+ // What an import made before 2.1.5 left under suffixed paths. Counted
2674
+ // on request rather than at start: it is a scan of the string table,
2675
+ // and a one-time question.
2676
+ router.get("/api/migration/legacy-rows", async (_req, res) => {
2677
+ if (!queryClient) {
2678
+ res.status(503).json({
2679
+ error: "QuestDB not connected",
2680
+ rows: 0,
2681
+ });
2682
+ return;
2683
+ }
2684
+ // Counting also finishes a rebuild that failed after its swap, which
2685
+ // must not run alongside one in progress — so it takes the same flag.
2686
+ if (legacyCleanupRunning) {
2687
+ res.status(409).json({
2688
+ error: "The rows are being removed",
2689
+ rows: 0,
2690
+ });
2691
+ return;
2692
+ }
2693
+ legacyCleanupRunning = true;
2694
+ try {
2695
+ await awaitHealIdle();
2696
+ res.json({
2697
+ rows: await countLegacyImportRows(queryClient),
2698
+ });
2699
+ }
2700
+ catch (err) {
2701
+ res.status(500).json({
2702
+ error: err instanceof Error ? err.message : "Unknown error",
2703
+ rows: 0,
2704
+ });
2705
+ }
2706
+ finally {
2707
+ legacyCleanupRunning = false;
2708
+ }
2709
+ });
2710
+ // Rebuilds the string table without those rows. Not while an import is
2711
+ // writing to it: the rebuild would race the import for the table.
2712
+ router.post("/api/migration/legacy-rows/remove", async (_req, res) => {
2713
+ if (!pluginRunning || !queryClient || !writer) {
2714
+ res.status(503).json({
2715
+ error: "QuestDB not connected",
2716
+ rows: 0,
2717
+ });
2718
+ return;
2719
+ }
2720
+ if (activeMigration && activeMigration.state === "running") {
2721
+ res.status(409).json({
2722
+ error: "An import is running; wait for it or cancel it first",
2723
+ rows: 0,
2724
+ });
2725
+ return;
2726
+ }
2727
+ if (legacyCleanupRunning) {
2728
+ res.status(409).json({
2729
+ error: "The rows are already being removed",
2730
+ rows: 0,
2731
+ });
2732
+ return;
2733
+ }
2734
+ legacyCleanupRunning = true;
2735
+ // Under the lifecycle lock, like the update and the purge teardown:
2736
+ // neither can then disconnect the writer while a swap holds it. The
2737
+ // route-entry checks above and in those handlers only fail fast; this
2738
+ // is the exclusion. (A purge that has waited its 30 s for the lock
2739
+ // proceeds regardless, by design — it discards the data anyway.)
2740
+ //
2741
+ // Bounded: a wedged start pins the lock, and a rebuild queued behind
2742
+ // it would hang this request and keep the flag set for good. Past the
2743
+ // timeout the request fails, and the queued callback is abandoned so
2744
+ // it cannot run later, unasked, once the lock frees.
2745
+ let acquired = false;
2746
+ let abandoned = false;
2747
+ const rebuild = withLifecycleLock(async () => {
2748
+ if (abandoned)
2749
+ return null;
2750
+ acquired = true;
2751
+ if (!queryClient || !writer) {
2752
+ throw new Error("QuestDB not connected");
2753
+ }
2754
+ await awaitHealIdle();
2755
+ return removeLegacyImportRows(queryClient, writer);
2756
+ });
2757
+ const lockTimeout = new Promise((_, reject) => {
2758
+ const timer = setTimeout(() => {
2759
+ if (acquired)
2760
+ return;
2761
+ abandoned = true;
2762
+ reject(new Error("The plugin is busy starting or updating; try again in a moment"));
2763
+ }, LEGACY_CLEANUP_LOCK_TIMEOUT_MS);
2764
+ void rebuild.then(() => clearTimeout(timer), () => clearTimeout(timer));
2765
+ });
2766
+ // What stop() waits for is the bounded request, not the queued
2767
+ // callback: behind a wedged start, the latter would hold stop() too.
2768
+ const bounded = Promise.race([rebuild, lockTimeout]);
2769
+ legacyCleanup = bounded;
2770
+ try {
2771
+ const result = await bounded;
2772
+ if (result === null)
2773
+ throw new Error("The rebuild did not run");
2774
+ res.json({
2775
+ rows: result.removed,
2776
+ dropped: result.dropped,
2777
+ });
2778
+ }
2779
+ catch (err) {
2780
+ // Busy is a 409, like the other lifecycle refusals; a rebuild that
2781
+ // ran and failed is a 500.
2782
+ res.status(abandoned ? 409 : 500).json({
2783
+ error: err instanceof Error ? err.message : "Unknown error",
2784
+ rows: 0,
2785
+ });
2786
+ }
2787
+ finally {
2788
+ legacyCleanupRunning = false;
2789
+ if (legacyCleanup === bounded)
2790
+ legacyCleanup = null;
2791
+ }
2792
+ });
2793
+ // Start over: forget the stopped import, so the next start is a new one.
2794
+ router.post("/api/migration/discard", async (_req, res) => {
2795
+ if (activeMigration && activeMigration.state === "running") {
2796
+ res.status(409).json({
2797
+ error: "A migration is running",
2798
+ run: migrationRunView(activeMigration),
2799
+ });
2800
+ return;
2801
+ }
2802
+ try {
2803
+ await checkpoints.clear();
2804
+ }
2805
+ catch (err) {
2806
+ // Said, not swallowed: the position is still there, and the next
2807
+ // start of the same import would continue from it.
2808
+ res.status(500).json({
2809
+ error: `Could not remove the saved position: ${err instanceof Error ? err.message : String(err)}`,
2810
+ });
2811
+ return;
2812
+ }
2381
2813
  res.json({
2382
2814
  run: activeMigration ? migrationRunView(activeMigration) : undefined,
2383
2815
  });