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.
- package/README.md +112 -27
- package/dist/api-contract.d.ts +45 -0
- package/dist/configpanel/responses.d.ts +28 -1
- package/dist/configpanel/responses.js +74 -0
- package/dist/configpanel/responses.js.map +1 -1
- package/dist/ilp-writer.d.ts +52 -0
- package/dist/ilp-writer.js +88 -7
- package/dist/ilp-writer.js.map +1 -1
- package/dist/index.js +477 -45
- package/dist/index.js.map +1 -1
- package/dist/migration-checkpoint.d.ts +170 -0
- package/dist/migration-checkpoint.js +276 -0
- package/dist/migration-checkpoint.js.map +1 -0
- package/dist/migration-cleanup.d.ts +48 -0
- package/dist/migration-cleanup.js +155 -0
- package/dist/migration-cleanup.js.map +1 -0
- package/dist/migration.d.ts +134 -17
- package/dist/migration.js +694 -158
- package/dist/migration.js.map +1 -1
- package/dist/questdb-mount.d.ts +106 -0
- package/dist/questdb-mount.js +173 -0
- package/dist/questdb-mount.js.map +1 -0
- package/dist/restore.js +44 -8
- package/dist/restore.js.map +1 -1
- package/package.json +2 -2
- package/public/assets/AppPanel-p27aQlBS.js +1 -0
- package/public/assets/PluginConfigurationPanel-BO_4OxBo.js +9 -0
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BiBqFhVp.js +1 -0
- package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__275086164343916__loadShare__react__loadShare__.js-BWO9MGdR.js +1 -0
- package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__275086164343916__prebuild__react__prebuild__-D2g10OtU.js +1 -0
- package/public/assets/dist-BK97MX24.js +1 -0
- package/public/assets/helpers-Bizl8Raj.js +1 -0
- package/public/assets/hostInit-BFZIDSUI.js +1 -0
- package/public/assets/index-bERRzqQZ.js +2 -0
- package/public/assets/pendingShares-BS4kO2ec.js +2 -0
- package/public/assets/{dist-BgxWXJ5y.js → utils-jR9fY54i.js} +2 -2
- package/public/assets/{vite-preload-helper-HclGiUj8.js → vite-preload-helper-uBIymjUX.js} +1 -1
- package/public/remoteEntry.js +2 -2
- package/public/assets/AppPanel-CNFtJpeq.js +0 -1
- package/public/assets/PluginConfigurationPanel-CR4mD9IH.js +0 -9
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-7tb3Q-Bx.js +0 -1
- package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__1__loadShare__react__loadShare__.js-DzhF4wXp.js +0 -1
- package/public/assets/hostInit-CyS9_M1c.js +0 -1
- package/public/assets/index-Dh-t5VMR.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
});
|