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/migration.js
CHANGED
|
@@ -8,18 +8,21 @@
|
|
|
8
8
|
// dropped: a migration that quietly loses a third of the history looks
|
|
9
9
|
// identical to one that worked.
|
|
10
10
|
//
|
|
11
|
-
// Reads are windowed by time and
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// Reads are windowed by time, and each window is streamed: read a batch, write
|
|
12
|
+
// it, release it, read the next. A boat's InfluxDB is routinely larger than the
|
|
13
|
+
// Pi's RAM, and a single dense day of one path can be too — so nothing here
|
|
14
|
+
// holds a whole response, and reading stops while QuestDB is catching up. A
|
|
15
|
+
// window that returns nothing still advances, so a multi-year gap costs one
|
|
16
|
+
// empty query per window rather than a stall.
|
|
15
17
|
import { routeDeltaValue, flattenObjectValue } from "./delta-routing.js";
|
|
18
|
+
import { CheckpointTracker, migrationIdentity, sameIdentity, } from "./migration-checkpoint.js";
|
|
16
19
|
/** Nanoseconds per millisecond, the unit gap between `Date` and ILP. */
|
|
17
20
|
const NANOS_PER_MS = 1000000n;
|
|
18
21
|
/**
|
|
19
|
-
* How much time one
|
|
20
|
-
* not
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* How much time one query covers. Large enough that a sparse measurement does
|
|
23
|
+
* not need thousands of round trips, small enough that one query stays cheap
|
|
24
|
+
* for InfluxDB and progress moves in visible steps. It does not bound memory:
|
|
25
|
+
* a window is streamed in batches, however many points it holds.
|
|
23
26
|
*/
|
|
24
27
|
export const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
25
28
|
/**
|
|
@@ -193,6 +196,49 @@ schema.measurements(bucket: ${JSON.stringify(req.bucket)}, start: 1970-01-01T00:
|
|
|
193
196
|
const values = body2.results?.[0]?.series?.[0]?.values ?? [];
|
|
194
197
|
return values.map((row) => ({ name: String(row[0]), fields: [] }));
|
|
195
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* The `context` tag values in a bucket/database: the vessels the source holds.
|
|
201
|
+
* Both Signal K InfluxDB writers tag every point with the delta's context, so
|
|
202
|
+
* this is how the source's own vessel is told from the AIS targets it recorded.
|
|
203
|
+
* A source with no such tag — a non-Signal K schema — returns none.
|
|
204
|
+
*/
|
|
205
|
+
export async function listContexts(req, fetchImpl = fetch) {
|
|
206
|
+
const headers = authHeaders(req.type, req.auth);
|
|
207
|
+
if (req.type === "influxdb2") {
|
|
208
|
+
const flux = `import "influxdata/influxdb/schema"
|
|
209
|
+
schema.tagValues(bucket: ${JSON.stringify(req.bucket)}, tag: "context", start: 1970-01-01T00:00:00Z)`;
|
|
210
|
+
const rows = await runFlux(req, flux, headers, fetchImpl);
|
|
211
|
+
return [
|
|
212
|
+
...new Set(rows.map((r) => r.values["_value"]).filter((v) => !!v)),
|
|
213
|
+
].sort();
|
|
214
|
+
}
|
|
215
|
+
// Answered per measurement, each series a list of [key, value] rows.
|
|
216
|
+
const url = `${req.url}/query?db=${encodeURIComponent(req.bucket)}&q=${encodeURIComponent('SHOW TAG VALUES WITH KEY = "context"')}`;
|
|
217
|
+
const r = await fetchImpl(url, {
|
|
218
|
+
headers,
|
|
219
|
+
signal: AbortSignal.timeout(15000),
|
|
220
|
+
});
|
|
221
|
+
if (!r.ok)
|
|
222
|
+
throw new Error(await describeHttpError(r, "list contexts"));
|
|
223
|
+
const body = (await r.json());
|
|
224
|
+
if (body.results?.[0]?.error)
|
|
225
|
+
throw new Error(body.results[0].error);
|
|
226
|
+
const values = new Set();
|
|
227
|
+
for (const s of body.results?.[0]?.series ?? []) {
|
|
228
|
+
for (const row of s.values ?? []) {
|
|
229
|
+
if (typeof row[1] === "string" && row[1])
|
|
230
|
+
values.add(row[1]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...values].sort();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Whether a string is a Signal K context this plugin will file history under:
|
|
237
|
+
* a vessel, aton, aircraft or SAR target with a well-formed identifier.
|
|
238
|
+
*/
|
|
239
|
+
export function isSignalKContext(context) {
|
|
240
|
+
return /^(vessels|atons|aircraft|sar)\.[A-Za-z0-9:._-]+$/.test(context);
|
|
241
|
+
}
|
|
196
242
|
async function describeHttpError(r, what) {
|
|
197
243
|
const text = await r.text().catch(() => "");
|
|
198
244
|
// 401/403 against InfluxDB almost always means a missing or wrong token,
|
|
@@ -211,108 +257,234 @@ async function describeHttpError(r, what) {
|
|
|
211
257
|
* one POST and a CSV parse.
|
|
212
258
|
*/
|
|
213
259
|
async function runFlux(req, flux, headers, fetchImpl, timeoutMs = 120_000) {
|
|
260
|
+
const r = await fetchImpl(fluxQueryUrl(req), {
|
|
261
|
+
...fluxQueryInit(flux, headers),
|
|
262
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
263
|
+
});
|
|
264
|
+
if (!r.ok)
|
|
265
|
+
throw new Error(await describeHttpError(r, "run query"));
|
|
266
|
+
return parseAnnotatedCsv(await r.text());
|
|
267
|
+
}
|
|
268
|
+
function fluxQueryUrl(req) {
|
|
214
269
|
const org = req.auth?.org ?? "";
|
|
215
|
-
|
|
216
|
-
|
|
270
|
+
return `${req.url}/api/v2/query${org ? `?org=${encodeURIComponent(org)}` : ""}`;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A Flux query as a request.
|
|
274
|
+
*
|
|
275
|
+
* Sent as JSON, not as a raw `application/vnd.flux` body, because only the JSON
|
|
276
|
+
* form can carry a dialect — and without one InfluxDB answers with bare CSV, no
|
|
277
|
+
* annotation lines at all (verified against a live 2.7.12). The reader needs
|
|
278
|
+
* two of them: `#datatype`, without which a string field holding "3.5" is
|
|
279
|
+
* indistinguishable from the number and lands in the numeric table, and
|
|
280
|
+
* `#group`, which is how a pivoted record's tags are told from its fields.
|
|
281
|
+
*/
|
|
282
|
+
function fluxQueryInit(flux, headers) {
|
|
283
|
+
return {
|
|
217
284
|
method: "POST",
|
|
218
285
|
headers: {
|
|
219
286
|
...headers,
|
|
220
|
-
"Content-Type": "application/
|
|
287
|
+
"Content-Type": "application/json",
|
|
221
288
|
Accept: "text/csv",
|
|
222
289
|
},
|
|
223
|
-
body:
|
|
224
|
-
|
|
290
|
+
body: JSON.stringify({
|
|
291
|
+
query: flux,
|
|
292
|
+
type: "flux",
|
|
293
|
+
dialect: { header: true, annotations: ["datatype", "group", "default"] },
|
|
294
|
+
}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* How long InfluxDB may stay silent while a read is outstanding.
|
|
299
|
+
*
|
|
300
|
+
* Not a limit on the whole request: a streamed window is read only as fast as
|
|
301
|
+
* QuestDB accepts it, so a dense one legitimately stays open for many minutes
|
|
302
|
+
* while the writer drains. The clock runs only while this side is waiting for
|
|
303
|
+
* bytes it has asked for.
|
|
304
|
+
*/
|
|
305
|
+
const READ_IDLE_TIMEOUT_MS = 120_000;
|
|
306
|
+
/**
|
|
307
|
+
* Run a request and yield its response body one physical line at a time.
|
|
308
|
+
*
|
|
309
|
+
* The body is never held whole — a dense day of one measurement runs to
|
|
310
|
+
* hundreds of megabytes of CSV, and a heap exhausted by it cannot be caught:
|
|
311
|
+
* it ends the Signal K process. Reading is demand-driven, so a consumer that
|
|
312
|
+
* pauses (the import, waiting for the writer to drain) stops the reads, and
|
|
313
|
+
* TCP flow control carries that back to InfluxDB.
|
|
314
|
+
*
|
|
315
|
+
* Stopping early — cancelling a run — aborts the request rather than leaving
|
|
316
|
+
* InfluxDB streaming into a socket nobody reads.
|
|
317
|
+
*/
|
|
318
|
+
async function* streamLines(fetchImpl, url, init, what, idleTimeoutMs) {
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
// Rejects on its own clock rather than waiting for the abort to surface
|
|
321
|
+
// through the pending step: the abort is what frees the connection, but the
|
|
322
|
+
// run must fail on time even if the step never notices it.
|
|
323
|
+
const guarded = (step) => new Promise((resolve, reject) => {
|
|
324
|
+
const timer = setTimeout(() => {
|
|
325
|
+
controller.abort();
|
|
326
|
+
reject(new Error(`Cannot ${what}: InfluxDB sent no data for ${Math.round(idleTimeoutMs / 1000)}s`));
|
|
327
|
+
}, idleTimeoutMs);
|
|
328
|
+
step().then((value) => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
resolve(value);
|
|
331
|
+
}, (err) => {
|
|
332
|
+
clearTimeout(timer);
|
|
333
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
334
|
+
});
|
|
225
335
|
});
|
|
336
|
+
const r = await guarded(() => fetchImpl(url, { ...init, signal: controller.signal }));
|
|
226
337
|
if (!r.ok)
|
|
227
|
-
throw new Error(await describeHttpError(r,
|
|
228
|
-
|
|
338
|
+
throw new Error(await guarded(() => describeHttpError(r, what)));
|
|
339
|
+
if (!r.body)
|
|
340
|
+
return;
|
|
341
|
+
const reader = r.body.getReader();
|
|
342
|
+
const decoder = new TextDecoder();
|
|
343
|
+
let carry = "";
|
|
344
|
+
try {
|
|
345
|
+
for (;;) {
|
|
346
|
+
const { done, value } = await guarded(() => reader.read());
|
|
347
|
+
if (done)
|
|
348
|
+
break;
|
|
349
|
+
carry += decoder.decode(value, { stream: true });
|
|
350
|
+
let from = 0;
|
|
351
|
+
for (let nl = carry.indexOf("\n"); nl >= 0; nl = carry.indexOf("\n", from)) {
|
|
352
|
+
yield carry.slice(from, nl);
|
|
353
|
+
from = nl + 1;
|
|
354
|
+
}
|
|
355
|
+
carry = carry.slice(from);
|
|
356
|
+
}
|
|
357
|
+
carry += decoder.decode();
|
|
358
|
+
if (carry !== "")
|
|
359
|
+
yield carry;
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
controller.abort();
|
|
363
|
+
await reader.cancel().catch(() => { });
|
|
364
|
+
}
|
|
229
365
|
}
|
|
230
366
|
/**
|
|
231
|
-
*
|
|
367
|
+
* Incremental annotated-CSV reader for Flux responses.
|
|
368
|
+
*
|
|
369
|
+
* Fed one physical line at a time, so a response is never held whole: the
|
|
370
|
+
* caller streams the body through it and sees each record as it completes.
|
|
232
371
|
*
|
|
233
372
|
* Flux emits one or more *tables*, each preceded by annotation lines starting
|
|
234
373
|
* with `#` and then a header row. A response can therefore change its column
|
|
235
374
|
* layout partway through, which is why the header is tracked per block rather
|
|
236
375
|
* than read once at the top.
|
|
237
376
|
*/
|
|
238
|
-
export
|
|
239
|
-
|
|
240
|
-
let header = null;
|
|
377
|
+
export class AnnotatedCsvReader {
|
|
378
|
+
header = null;
|
|
241
379
|
// Per-block `#datatype` line. Without it a genuine STRING value of "3.5"
|
|
242
380
|
// is indistinguishable from the number 3.5 in the CSV body — verified
|
|
243
381
|
// against a live 2.9.1 — and would be imported into the numeric table.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
382
|
+
datatypes = null;
|
|
383
|
+
groups = null;
|
|
384
|
+
// Built once per table and shared by its records: a table can run to
|
|
385
|
+
// millions of rows, and a copy per record was most of what a window cost.
|
|
386
|
+
types = {};
|
|
387
|
+
grouped = new Set();
|
|
388
|
+
pending = null;
|
|
389
|
+
/**
|
|
390
|
+
* Feed one physical line, without its `\n`. Returns the record it
|
|
391
|
+
* completes, or null for annotations, headers, blanks and partial records.
|
|
392
|
+
*
|
|
393
|
+
* A quoted CSV value may contain a newline — verified against a live 2.9.1,
|
|
394
|
+
* which returns `"line1\r\nline2"` for a string value holding one. Splitting
|
|
395
|
+
* on newlines alone would tear that record in half and produce two garbage
|
|
396
|
+
* rows, so physical lines are joined until the quotes balance ("" inside a
|
|
397
|
+
* quoted value is an escaped quote and does not change the balance).
|
|
398
|
+
* \r is stripped per PHYSICAL line before joining: a quoted value spanning
|
|
399
|
+
* two CRLF lines would otherwise keep the \r of the first inside the joined
|
|
400
|
+
* value, so the imported string silently carries a stray carriage return.
|
|
401
|
+
*/
|
|
402
|
+
push(physicalLine) {
|
|
403
|
+
const stripped = physicalLine.endsWith("\r")
|
|
404
|
+
? physicalLine.slice(0, -1)
|
|
405
|
+
: physicalLine;
|
|
406
|
+
const line = this.pending === null ? stripped : `${this.pending}\n${stripped}`;
|
|
407
|
+
if (!quotesBalanced(line)) {
|
|
408
|
+
this.pending = line;
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
this.pending = null;
|
|
412
|
+
return this.logicalLine(line);
|
|
413
|
+
}
|
|
414
|
+
/** Call once after the last line. */
|
|
415
|
+
end() {
|
|
416
|
+
if (this.pending === null)
|
|
417
|
+
return null;
|
|
418
|
+
// Unterminated quote at EOF: emit what there is rather than dropping it.
|
|
419
|
+
const line = this.pending;
|
|
420
|
+
this.pending = null;
|
|
421
|
+
return this.logicalLine(line);
|
|
422
|
+
}
|
|
423
|
+
logicalLine(line) {
|
|
254
424
|
if (line === "") {
|
|
255
425
|
// Blank line separates tables; the next non-# line is a fresh header.
|
|
256
|
-
header = null;
|
|
257
|
-
datatypes = null;
|
|
258
|
-
|
|
426
|
+
this.header = null;
|
|
427
|
+
this.datatypes = null;
|
|
428
|
+
this.groups = null;
|
|
429
|
+
return null;
|
|
259
430
|
}
|
|
260
431
|
if (line.startsWith("#")) {
|
|
261
432
|
// Annotation (#datatype/#group/#default). A new annotation block means
|
|
262
433
|
// the previous header no longer applies.
|
|
263
434
|
if (line.startsWith("#datatype"))
|
|
264
|
-
datatypes = splitCsvLine(line);
|
|
265
|
-
|
|
266
|
-
|
|
435
|
+
this.datatypes = splitCsvLine(line);
|
|
436
|
+
else if (line.startsWith("#group"))
|
|
437
|
+
this.groups = splitCsvLine(line);
|
|
438
|
+
this.header = null;
|
|
439
|
+
return null;
|
|
267
440
|
}
|
|
268
441
|
const cells = splitCsvLine(line);
|
|
269
|
-
if (!header) {
|
|
270
|
-
header = cells;
|
|
271
|
-
|
|
442
|
+
if (!this.header) {
|
|
443
|
+
this.header = cells;
|
|
444
|
+
const types = {};
|
|
445
|
+
const grouped = new Set();
|
|
446
|
+
for (let i = 0; i < cells.length; i++) {
|
|
447
|
+
const key = cells[i];
|
|
448
|
+
if (key === "")
|
|
449
|
+
continue;
|
|
450
|
+
// Annotations are positionally aligned with the header row.
|
|
451
|
+
if (this.datatypes?.[i])
|
|
452
|
+
types[key] = this.datatypes[i];
|
|
453
|
+
if (this.groups?.[i] === "true")
|
|
454
|
+
grouped.add(key);
|
|
455
|
+
}
|
|
456
|
+
this.types = types;
|
|
457
|
+
this.grouped = grouped;
|
|
458
|
+
return null;
|
|
272
459
|
}
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const key = header[i];
|
|
460
|
+
const values = {};
|
|
461
|
+
for (let i = 0; i < this.header.length; i++) {
|
|
462
|
+
const key = this.header[i];
|
|
277
463
|
// Flux's leading empty column is the annotation gutter, not data.
|
|
278
464
|
if (key === "")
|
|
279
465
|
continue;
|
|
280
|
-
|
|
281
|
-
// #datatype is positionally aligned with the header row.
|
|
282
|
-
if (datatypes && datatypes[i])
|
|
283
|
-
types[key] = datatypes[i];
|
|
466
|
+
values[key] = cells[i] ?? "";
|
|
284
467
|
}
|
|
285
|
-
|
|
468
|
+
return { values, types: this.types, grouped: this.grouped };
|
|
286
469
|
}
|
|
287
|
-
return out;
|
|
288
470
|
}
|
|
289
|
-
/**
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
* A record is complete when its double quotes are balanced ("" inside a quoted
|
|
293
|
-
* value is an escaped quote and does not change the balance). Annotation and
|
|
294
|
-
* blank lines can never be mid-record, so they pass straight through and the
|
|
295
|
-
* caller's boundary handling is unaffected.
|
|
296
|
-
*/
|
|
297
|
-
function joinQuotedLines(lines) {
|
|
471
|
+
/** Parse a whole annotated-CSV response. For small results only. */
|
|
472
|
+
export function parseAnnotatedCsv(text) {
|
|
473
|
+
const reader = new AnnotatedCsvReader();
|
|
298
474
|
const out = [];
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
out.push(candidate);
|
|
304
|
-
pending = null;
|
|
305
|
-
}
|
|
306
|
-
else {
|
|
307
|
-
pending = candidate;
|
|
308
|
-
}
|
|
475
|
+
for (const line of text.split("\n")) {
|
|
476
|
+
const rec = reader.push(line);
|
|
477
|
+
if (rec)
|
|
478
|
+
out.push(rec);
|
|
309
479
|
}
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
out.push(
|
|
480
|
+
const last = reader.end();
|
|
481
|
+
if (last)
|
|
482
|
+
out.push(last);
|
|
313
483
|
return out;
|
|
314
484
|
}
|
|
315
485
|
function quotesBalanced(s) {
|
|
486
|
+
if (!s.includes('"'))
|
|
487
|
+
return true;
|
|
316
488
|
let inQuotes = false;
|
|
317
489
|
for (let i = 0; i < s.length; i++) {
|
|
318
490
|
if (s[i] !== '"')
|
|
@@ -326,6 +498,8 @@ function quotesBalanced(s) {
|
|
|
326
498
|
return !inQuotes;
|
|
327
499
|
}
|
|
328
500
|
function splitCsvLine(line) {
|
|
501
|
+
if (!line.includes('"'))
|
|
502
|
+
return line.split(",");
|
|
329
503
|
const cells = [];
|
|
330
504
|
let cur = "";
|
|
331
505
|
let inQuotes = false;
|
|
@@ -382,19 +556,72 @@ export function rfc3339ToNanos(value) {
|
|
|
382
556
|
const nanosPart = BigInt((frac + "000000000").slice(0, 9));
|
|
383
557
|
return BigInt(baseMs) * NANOS_PER_MS + nanosPart;
|
|
384
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* The field signalk-to-influxdb 1.x uses for a value that is neither a number,
|
|
561
|
+
* a string nor a boolean: the value itself, JSON-encoded. `navigation.position`
|
|
562
|
+
* is always stored this way; `lat`/`lon` fields exist only when that plugin's
|
|
563
|
+
* `separateLatLon` option is on, which it is not by default.
|
|
564
|
+
*/
|
|
565
|
+
const JSON_VALUE_FIELD = "jsonValue";
|
|
566
|
+
/**
|
|
567
|
+
* Field names that hold the value of the measurement's own path.
|
|
568
|
+
*
|
|
569
|
+
* signalk-to-influxdb2 writes every value to `value`. signalk-to-influxdb 1.x
|
|
570
|
+
* picks the field by the value's type — `value` for numbers, then
|
|
571
|
+
* `stringValue`, `boolValue` and `jsonValue` — because an InfluxDB 1.x field
|
|
572
|
+
* cannot change type once it has been written.
|
|
573
|
+
*/
|
|
574
|
+
const PATH_VALUE_FIELDS = new Set([
|
|
575
|
+
"value",
|
|
576
|
+
"",
|
|
577
|
+
"_value",
|
|
578
|
+
"stringValue",
|
|
579
|
+
"boolValue",
|
|
580
|
+
JSON_VALUE_FIELD,
|
|
581
|
+
]);
|
|
385
582
|
/**
|
|
386
583
|
* The Signal K path a measurement+field pair represents.
|
|
387
584
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* fields) is a non-Signal K schema, so the field name is appended to keep
|
|
391
|
-
* two apart instead of overwriting one with the other.
|
|
585
|
+
* The Signal K InfluxDB plugins write the path as the measurement name and the
|
|
586
|
+
* value in one of PATH_VALUE_FIELDS. Anything else (a measurement with several
|
|
587
|
+
* named fields) is a non-Signal K schema, so the field name is appended to keep
|
|
588
|
+
* the two apart instead of overwriting one with the other.
|
|
392
589
|
*/
|
|
393
590
|
export function toSignalKPath(measurement, field) {
|
|
394
|
-
if (field
|
|
591
|
+
if (PATH_VALUE_FIELDS.has(field))
|
|
395
592
|
return measurement;
|
|
396
593
|
return `${measurement}.${field}`;
|
|
397
594
|
}
|
|
595
|
+
/**
|
|
596
|
+
* Decode `jsonValue` fields back into the values they encode.
|
|
597
|
+
*
|
|
598
|
+
* Left as the string it arrives as, a position would be recorded in
|
|
599
|
+
* `signalk_str` as text and `signalk_position` would stay empty. Decoded, it
|
|
600
|
+
* routes exactly like the live value did: a position to the position table,
|
|
601
|
+
* any other object to its scalar leaves.
|
|
602
|
+
*
|
|
603
|
+
* A value that does not parse cannot be represented, so it is dropped — and
|
|
604
|
+
* reported, so it lands in the run's skipped total.
|
|
605
|
+
*/
|
|
606
|
+
export function decodeJsonValueRows(rows) {
|
|
607
|
+
if (!rows.some((row) => row.field === JSON_VALUE_FIELD))
|
|
608
|
+
return { rows, dropped: 0 };
|
|
609
|
+
let dropped = 0;
|
|
610
|
+
const decoded = [];
|
|
611
|
+
for (const row of rows) {
|
|
612
|
+
if (row.field !== JSON_VALUE_FIELD || typeof row.value !== "string") {
|
|
613
|
+
decoded.push(row);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
decoded.push({ ...row, value: JSON.parse(row.value) });
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
dropped++;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return { rows: decoded, dropped };
|
|
624
|
+
}
|
|
398
625
|
/** Coerce an InfluxDB field value (always text over CSV) to a JS value. */
|
|
399
626
|
export function coerceValue(raw, dataType) {
|
|
400
627
|
if (raw === "")
|
|
@@ -429,6 +656,7 @@ export class MigrationRun {
|
|
|
429
656
|
startedAt = new Date().toISOString();
|
|
430
657
|
finishedAt;
|
|
431
658
|
error;
|
|
659
|
+
resumedFrom;
|
|
432
660
|
progress = {
|
|
433
661
|
read: 0,
|
|
434
662
|
written: 0,
|
|
@@ -462,6 +690,7 @@ export async function runMigration(req, writer, run, deps = {}) {
|
|
|
462
690
|
const debug = deps.debug ?? (() => { });
|
|
463
691
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
464
692
|
const headers = authHeaders(req.type, req.auth);
|
|
693
|
+
const readIdleTimeoutMs = deps.readIdleTimeoutMs ?? READ_IDLE_TIMEOUT_MS;
|
|
465
694
|
// A non-positive window would make `start += windowMs` never advance and
|
|
466
695
|
// spin forever. Not reachable from the HTTP API (which does not expose the
|
|
467
696
|
// knob), but an infinite loop inside the Signal K process is severe enough
|
|
@@ -469,10 +698,39 @@ export async function runMigration(req, writer, run, deps = {}) {
|
|
|
469
698
|
const requestedWindow = req.windowMs ?? DEFAULT_WINDOW_MS;
|
|
470
699
|
const windowMs = requestedWindow > 0 ? requestedWindow : DEFAULT_WINDOW_MS;
|
|
471
700
|
const context = req.context;
|
|
701
|
+
const sourceSelf = req.sourceSelfContext;
|
|
702
|
+
const others = req.others ?? "keep";
|
|
472
703
|
const source = req.sourceLabel ?? "influxdb-import";
|
|
473
704
|
// The writer's drop counter is monotonic and shared with the live recorder,
|
|
474
705
|
// so only the delta across this run is attributable to it.
|
|
475
706
|
const droppedAtStart = writer.droppedLineCount ?? 0;
|
|
707
|
+
// The writer drops the OLDEST buffered lines when its cap is hit — while
|
|
708
|
+
// disconnected, or when QuestDB cannot keep up. Those rows were counted as
|
|
709
|
+
// written but never reached the database, so the run must not go on as if
|
|
710
|
+
// they had, and must not end as a success: the whole point of an import is
|
|
711
|
+
// knowing what actually landed.
|
|
712
|
+
//
|
|
713
|
+
// Checked before every row is written, not only at the end, and that is
|
|
714
|
+
// load-bearing for resume. A run that stops at its first drop never writes
|
|
715
|
+
// anything after a gap, so a window's last row, wherever it came from, is
|
|
716
|
+
// only ever in QuestDB with the whole window before it — which is what lets
|
|
717
|
+
// a saved position be confirmed by reading that row back, even one an
|
|
718
|
+
// earlier run wrote. Before the write rather than after: a drop registered
|
|
719
|
+
// while the run waited for the writer would otherwise be followed by one
|
|
720
|
+
// more row.
|
|
721
|
+
const failOnDrops = () => {
|
|
722
|
+
const droppedByWriter = (writer.droppedLineCount ?? 0) - droppedAtStart;
|
|
723
|
+
if (droppedByWriter > 0) {
|
|
724
|
+
throw new Error(`QuestDB could not keep up: ${droppedByWriter} buffered rows were dropped before reaching the database. ` +
|
|
725
|
+
`Re-run the import once QuestDB is healthy (already-imported rows are not duplicated).`);
|
|
726
|
+
}
|
|
727
|
+
// A write into a full buffer would itself cause the drop — and be the
|
|
728
|
+
// row behind the gap. Refused instead, before it is enqueued.
|
|
729
|
+
if (writer.atCapacity) {
|
|
730
|
+
throw new Error(`QuestDB could not keep up: the writer's buffer is full. ` +
|
|
731
|
+
`Re-run the import once QuestDB is healthy (already-imported rows are not duplicated).`);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
476
734
|
try {
|
|
477
735
|
// Checked BEFORE discovery: listMeasurements is a network round trip, and
|
|
478
736
|
// running it first means a bad range costs a request and — worse — any
|
|
@@ -486,6 +744,24 @@ export async function runMigration(req, writer, run, deps = {}) {
|
|
|
486
744
|
if (toMs <= fromMs) {
|
|
487
745
|
throw new Error("`to` must be after `from`");
|
|
488
746
|
}
|
|
747
|
+
// Before the first await, so a caller that started this run without
|
|
748
|
+
// awaiting it — the HTTP handler — already sees it as resumed, with the
|
|
749
|
+
// earlier totals, in the response it sends straight away.
|
|
750
|
+
const now = deps.now ?? Date.now;
|
|
751
|
+
const identity = migrationIdentity(req, windowMs);
|
|
752
|
+
const resume = deps.resumeFrom && sameIdentity(deps.resumeFrom.identity, identity)
|
|
753
|
+
? deps.resumeFrom
|
|
754
|
+
: undefined;
|
|
755
|
+
const done = new Set(resume?.done ?? []);
|
|
756
|
+
if (resume) {
|
|
757
|
+
run.progress.read = resume.progress.read;
|
|
758
|
+
run.progress.written = resume.progress.written;
|
|
759
|
+
run.progress.skipped = resume.progress.skipped;
|
|
760
|
+
run.resumedFrom = {
|
|
761
|
+
measurement: resume.current?.measurement,
|
|
762
|
+
windowStart: resume.current && new Date(resume.current.windowStart).toISOString(),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
489
765
|
let measurements = req.measurements ?? [];
|
|
490
766
|
if (measurements.length === 0) {
|
|
491
767
|
measurements = (await listMeasurements(req, fetchImpl)).map((m) => m.name);
|
|
@@ -500,6 +776,33 @@ export async function runMigration(req, writer, run, deps = {}) {
|
|
|
500
776
|
}
|
|
501
777
|
}
|
|
502
778
|
run.progress.measurementsTotal = measurements.length;
|
|
779
|
+
const tracker = deps.checkpoints
|
|
780
|
+
? new CheckpointTracker(deps.checkpoints, writer, deps.checkpointLagMs, now, deps.confirmStored)
|
|
781
|
+
: undefined;
|
|
782
|
+
// The newest row written to each table, kept up to date by writeRow: what
|
|
783
|
+
// a position offered after it is confirmed against.
|
|
784
|
+
const tail = { source };
|
|
785
|
+
// A checkpoint that cannot be written costs a later resume, nothing more.
|
|
786
|
+
// It must not cost the import that is running.
|
|
787
|
+
const offerCheckpoint = async (current) => {
|
|
788
|
+
try {
|
|
789
|
+
await tracker?.offer(() => ({
|
|
790
|
+
version: 1,
|
|
791
|
+
identity,
|
|
792
|
+
done: [...done],
|
|
793
|
+
current,
|
|
794
|
+
progress: {
|
|
795
|
+
read: run.progress.read,
|
|
796
|
+
written: run.progress.written,
|
|
797
|
+
skipped: run.progress.skipped,
|
|
798
|
+
},
|
|
799
|
+
updatedAt: new Date(now()).toISOString(),
|
|
800
|
+
}), tail);
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
debug(`migration: checkpoint not written: ${err instanceof Error ? err.message : String(err)}`);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
503
806
|
// Wait for the writer to fall back to the resume mark.
|
|
504
807
|
//
|
|
505
808
|
// Two marks, not one: resuming at the high-water mark would park the
|
|
@@ -524,56 +827,126 @@ export async function runMigration(req, writer, run, deps = {}) {
|
|
|
524
827
|
for (const measurement of measurements) {
|
|
525
828
|
if (run.isCancelled)
|
|
526
829
|
break;
|
|
830
|
+
if (done.has(measurement)) {
|
|
831
|
+
run.progress.measurementsDone++;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
527
834
|
run.progress.currentMeasurement = measurement;
|
|
528
|
-
|
|
835
|
+
// Only a start this run would itself arrive at: anything else in the
|
|
836
|
+
// file is not a position of this import, and the measurement is read
|
|
837
|
+
// from its beginning instead.
|
|
838
|
+
let firstStart = fromMs;
|
|
839
|
+
const resumeAt = resume?.current?.measurement === measurement
|
|
840
|
+
? resume.current.windowStart
|
|
841
|
+
: undefined;
|
|
842
|
+
if (resumeAt !== undefined &&
|
|
843
|
+
resumeAt > fromMs &&
|
|
844
|
+
resumeAt < toMs &&
|
|
845
|
+
(resumeAt - fromMs) % windowMs === 0) {
|
|
846
|
+
firstStart = resumeAt;
|
|
847
|
+
}
|
|
848
|
+
for (let start = firstStart; start < toMs; start += windowMs) {
|
|
529
849
|
if (run.isCancelled)
|
|
530
850
|
break;
|
|
531
851
|
const end = Math.min(start + windowMs, toMs);
|
|
532
852
|
run.progress.currentWindowStart = new Date(start).toISOString();
|
|
533
853
|
// Backpressure: let the writer drain before reading more.
|
|
534
854
|
await awaitDrain();
|
|
535
|
-
const
|
|
536
|
-
?
|
|
537
|
-
:
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
run.progress.skipped += dropped;
|
|
542
|
-
// Checked inside the row loop too, not only between windows: a single
|
|
543
|
-
// dense window can carry far more rows than the writer's cap on its
|
|
544
|
-
// own, and waiting only at the window boundary would let the buffer
|
|
545
|
-
// sail past MAX_BUFFER_LINES mid-window — dropping its OLDEST lines,
|
|
546
|
-
// which is exactly the silent data loss this guard exists to prevent.
|
|
855
|
+
const batches = req.type === "influxdb2"
|
|
856
|
+
? readWindowFlux(req, measurement, start, end, headers, fetchImpl, readIdleTimeoutMs)
|
|
857
|
+
: readWindowInfluxQl(req, measurement, start, end, headers, fetchImpl, readIdleTimeoutMs);
|
|
858
|
+
// The window is read a batch at a time and each batch is written
|
|
859
|
+
// before the next is asked for, so what is held never grows with the
|
|
860
|
+
// window. Leaving the loop early — a cancel — ends the read as well.
|
|
547
861
|
let sinceDrainCheck = 0;
|
|
548
|
-
for (const
|
|
862
|
+
for await (const { rows, dropped } of batches) {
|
|
549
863
|
if (run.isCancelled)
|
|
550
864
|
break;
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
865
|
+
// Half-pair positions never become rows, so they are counted here or
|
|
866
|
+
// not at all — otherwise read - written - skipped silently disagrees.
|
|
867
|
+
run.progress.read += dropped;
|
|
868
|
+
run.progress.skipped += dropped;
|
|
869
|
+
// Checked inside the row loop too, not only between batches: waiting
|
|
870
|
+
// only at a boundary would let the buffer sail past
|
|
871
|
+
// MAX_BUFFER_LINES mid-batch — dropping its OLDEST lines, which is
|
|
872
|
+
// exactly the silent data loss this guard exists to prevent.
|
|
873
|
+
for (const row of rows) {
|
|
874
|
+
if (run.isCancelled)
|
|
875
|
+
break;
|
|
876
|
+
if (++sinceDrainCheck >= 1000) {
|
|
877
|
+
sinceDrainCheck = 0;
|
|
878
|
+
await awaitDrain();
|
|
879
|
+
}
|
|
880
|
+
failOnDrops();
|
|
881
|
+
run.progress.read++;
|
|
882
|
+
const target = targetContext(row.context, sourceSelf, context, others);
|
|
883
|
+
if (target === null) {
|
|
884
|
+
run.progress.skipped++;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const written = writeRow(row, measurement, target, source, writer, tail);
|
|
888
|
+
if (written)
|
|
889
|
+
run.progress.written++;
|
|
890
|
+
else
|
|
891
|
+
run.progress.skipped++;
|
|
554
892
|
}
|
|
555
|
-
run
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
893
|
+
// Again after the batch, so a drop on its last row ends the run
|
|
894
|
+
// here rather than after the next window has been requested.
|
|
895
|
+
failOnDrops();
|
|
896
|
+
}
|
|
897
|
+
// A window left early is not an imported window.
|
|
898
|
+
if (run.isCancelled)
|
|
899
|
+
break;
|
|
900
|
+
if (start + windowMs < toMs) {
|
|
901
|
+
await offerCheckpoint({
|
|
902
|
+
measurement,
|
|
903
|
+
windowStart: start + windowMs,
|
|
904
|
+
});
|
|
561
905
|
}
|
|
562
906
|
}
|
|
907
|
+
if (run.isCancelled)
|
|
908
|
+
break;
|
|
909
|
+
done.add(measurement);
|
|
563
910
|
run.progress.measurementsDone++;
|
|
911
|
+
await offerCheckpoint();
|
|
564
912
|
debug(`migration: ${measurement} done (${run.progress.written} written, ${run.progress.skipped} skipped)`);
|
|
565
913
|
}
|
|
566
|
-
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
914
|
+
failOnDrops();
|
|
915
|
+
// "Done" is a claim about QuestDB, not about the writer. The last rows
|
|
916
|
+
// are still on their way when the loop ends, so wait for them to leave
|
|
917
|
+
// the writer and, where QuestDB can be asked, for it to read the newest
|
|
918
|
+
// of them back — the same standard a saved position is held to. A run
|
|
919
|
+
// that cannot get that within the drain timeout fails and keeps its
|
|
920
|
+
// checkpoint, so the last windows are done again rather than assumed.
|
|
921
|
+
if (!run.isCancelled) {
|
|
922
|
+
const mark = writer.enqueuedLineCount;
|
|
923
|
+
let waited = 0;
|
|
924
|
+
while (!run.isCancelled) {
|
|
925
|
+
failOnDrops();
|
|
926
|
+
const settled = mark === undefined ||
|
|
927
|
+
writer.settledLineCount === undefined ||
|
|
928
|
+
writer.settledLineCount + (writer.droppedLineCount ?? 0) >= mark;
|
|
929
|
+
const confirmed = settled &&
|
|
930
|
+
(!deps.confirmStored ||
|
|
931
|
+
(await deps.confirmStored(tail).catch(() => false)));
|
|
932
|
+
if (confirmed)
|
|
933
|
+
break;
|
|
934
|
+
if (waited >= WRITER_DRAIN_TIMEOUT_MS) {
|
|
935
|
+
throw new Error(`QuestDB has not confirmed the last rows after ${Math.round(WRITER_DRAIN_TIMEOUT_MS / 1000)}s. ` +
|
|
936
|
+
`Re-run the import once QuestDB is healthy (already-imported rows are not duplicated).`);
|
|
937
|
+
}
|
|
938
|
+
await sleep(200);
|
|
939
|
+
waited += 200;
|
|
940
|
+
}
|
|
575
941
|
}
|
|
576
942
|
run.state = run.isCancelled ? "cancelled" : "done";
|
|
943
|
+
// Nothing left to resume. Kept on a cancel or a failure, which is exactly
|
|
944
|
+
// when a later run wants it.
|
|
945
|
+
if (run.state === "done") {
|
|
946
|
+
await deps.checkpoints?.clear().catch((err) => {
|
|
947
|
+
debug(`migration: finished checkpoint not removed: ${err instanceof Error ? err.message : String(err)}`);
|
|
948
|
+
});
|
|
949
|
+
}
|
|
577
950
|
}
|
|
578
951
|
catch (err) {
|
|
579
952
|
run.state = "failed";
|
|
@@ -585,6 +958,27 @@ export async function runMigration(req, writer, run, deps = {}) {
|
|
|
585
958
|
run.progress.currentWindowStart = undefined;
|
|
586
959
|
}
|
|
587
960
|
}
|
|
961
|
+
/**
|
|
962
|
+
* The Signal K context a row is written under, or null for a row that is left
|
|
963
|
+
* out — counted as skipped by the caller.
|
|
964
|
+
*
|
|
965
|
+
* The source's own vessel becomes `self`, whether the point was tagged with
|
|
966
|
+
* the source's own context, with the literal `vessels.self`, or not at all.
|
|
967
|
+
* Any other tag is another vessel: kept under its own context if it is a
|
|
968
|
+
* well-formed Signal K one, since that is what the history API is queried by,
|
|
969
|
+
* or left out when other vessels are not wanted. With no `sourceSelf` given,
|
|
970
|
+
* every row is the own vessel — what a single-vessel source amounts to.
|
|
971
|
+
*/
|
|
972
|
+
export function targetContext(rowContext, sourceSelf, self, others) {
|
|
973
|
+
if (rowContext === undefined ||
|
|
974
|
+
rowContext === "vessels.self" ||
|
|
975
|
+
rowContext === sourceSelf ||
|
|
976
|
+
sourceSelf === undefined)
|
|
977
|
+
return self;
|
|
978
|
+
if (others === "skip")
|
|
979
|
+
return null;
|
|
980
|
+
return isSignalKContext(rowContext) ? rowContext : null;
|
|
981
|
+
}
|
|
588
982
|
/**
|
|
589
983
|
* Map one source row onto the plugin's tables, reusing the live path's routing
|
|
590
984
|
* so imported data is classified exactly like recorded data.
|
|
@@ -592,7 +986,7 @@ export async function runMigration(req, writer, run, deps = {}) {
|
|
|
592
986
|
* Returns false when the row could not be represented, which the caller counts
|
|
593
987
|
* as skipped.
|
|
594
988
|
*/
|
|
595
|
-
function writeRow(row, measurement, context, source, writer) {
|
|
989
|
+
function writeRow(row, measurement, context, source, writer, tail) {
|
|
596
990
|
const path = toSignalKPath(measurement, row.field);
|
|
597
991
|
if (!path)
|
|
598
992
|
return false;
|
|
@@ -600,16 +994,20 @@ function writeRow(row, measurement, context, source, writer) {
|
|
|
600
994
|
switch (route) {
|
|
601
995
|
case "number":
|
|
602
996
|
writer.writeAtNanos(path, context, row.value, row.tsNanos, source);
|
|
997
|
+
tail.numeric = { path, context, tsNanos: row.tsNanos };
|
|
603
998
|
return true;
|
|
604
999
|
case "string":
|
|
605
1000
|
writer.writeStringAtNanos(path, context, row.value, row.tsNanos, undefined, source);
|
|
1001
|
+
tail.string = { path, context, tsNanos: row.tsNanos };
|
|
606
1002
|
return true;
|
|
607
1003
|
case "boolean":
|
|
608
1004
|
writer.writeStringAtNanos(path, context, String(row.value), row.tsNanos, "boolean", source);
|
|
1005
|
+
tail.string = { path, context, tsNanos: row.tsNanos };
|
|
609
1006
|
return true;
|
|
610
1007
|
case "position": {
|
|
611
1008
|
const v = row.value;
|
|
612
1009
|
writer.writePositionAtNanos(context, v, row.tsNanos, source);
|
|
1010
|
+
tail.position = { context, tsNanos: row.tsNanos };
|
|
613
1011
|
return true;
|
|
614
1012
|
}
|
|
615
1013
|
case "flatten": {
|
|
@@ -623,12 +1021,15 @@ function writeRow(row, measurement, context, source, writer) {
|
|
|
623
1021
|
for (const leaf of leaves) {
|
|
624
1022
|
if (typeof leaf.value === "number") {
|
|
625
1023
|
writer.writeAtNanos(leaf.path, context, leaf.value, row.tsNanos, source);
|
|
1024
|
+
tail.numeric = { path: leaf.path, context, tsNanos: row.tsNanos };
|
|
626
1025
|
}
|
|
627
1026
|
else if (typeof leaf.value === "boolean") {
|
|
628
1027
|
writer.writeStringAtNanos(leaf.path, context, String(leaf.value), row.tsNanos, "boolean", source);
|
|
1028
|
+
tail.string = { path: leaf.path, context, tsNanos: row.tsNanos };
|
|
629
1029
|
}
|
|
630
1030
|
else {
|
|
631
1031
|
writer.writeStringAtNanos(leaf.path, context, leaf.value, row.tsNanos, undefined, source);
|
|
1032
|
+
tail.string = { path: leaf.path, context, tsNanos: row.tsNanos };
|
|
632
1033
|
}
|
|
633
1034
|
any = true;
|
|
634
1035
|
}
|
|
@@ -639,35 +1040,74 @@ function writeRow(row, measurement, context, source, writer) {
|
|
|
639
1040
|
}
|
|
640
1041
|
}
|
|
641
1042
|
/**
|
|
642
|
-
*
|
|
1043
|
+
* How many Flux records go into one batch. Bounds what a window holds at once:
|
|
1044
|
+
* a batch is parsed, written and released before the next is read.
|
|
1045
|
+
*/
|
|
1046
|
+
const FLUX_BATCH_RECORDS = 5_000;
|
|
1047
|
+
/**
|
|
1048
|
+
* Whether a measurement may hold a position split across latitude/longitude
|
|
1049
|
+
* fields — the measurements mergePositionRows reassembles.
|
|
1050
|
+
*/
|
|
1051
|
+
function isPositionMeasurement(measurement) {
|
|
1052
|
+
return measurement.includes("position");
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Turn the rows gathered so far into a batch.
|
|
643
1056
|
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
* instead of a position.
|
|
1057
|
+
* Decoding and position reassembly work within the batch, so a batch must
|
|
1058
|
+
* hold WHOLE points: every field of an instant together. Both readers
|
|
1059
|
+
* guarantee that — see each for how.
|
|
648
1060
|
*/
|
|
649
|
-
|
|
650
|
-
const
|
|
1061
|
+
function finishBatch(measurement, rows, unusable) {
|
|
1062
|
+
const decoded = decodeJsonValueRows(rows);
|
|
1063
|
+
const merged = mergePositionRows(measurement, decoded.rows);
|
|
1064
|
+
return {
|
|
1065
|
+
rows: merged.rows,
|
|
1066
|
+
dropped: merged.dropped + decoded.dropped + unusable,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Read one time window of a measurement from InfluxDB 2.x, in batches.
|
|
1071
|
+
*
|
|
1072
|
+
* Flux returns one table per field, each running the length of the window. For
|
|
1073
|
+
* most measurements that is fine — every record is a complete value. A
|
|
1074
|
+
* position is not: its latitude and longitude arrive as two separate tables,
|
|
1075
|
+
* the second starting only after the first has ended, so pairing them from the
|
|
1076
|
+
* stream would mean holding a whole table back. Position measurements are
|
|
1077
|
+
* therefore pivoted by InfluxDB, which puts every field of an instant on one
|
|
1078
|
+
* record and lets a position be assembled record by record.
|
|
1079
|
+
*/
|
|
1080
|
+
async function* readWindowFlux(req, measurement, startMs, endMs, headers, fetchImpl, idleTimeoutMs) {
|
|
1081
|
+
const pivoted = isPositionMeasurement(measurement);
|
|
1082
|
+
const source = `from(bucket: ${JSON.stringify(req.bucket)})
|
|
651
1083
|
|> range(start: ${new Date(startMs).toISOString()}, stop: ${new Date(endMs).toISOString()})
|
|
652
|
-
|> filter(fn: (r) => r._measurement == ${JSON.stringify(measurement)})
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1084
|
+
|> filter(fn: (r) => r._measurement == ${JSON.stringify(measurement)})`;
|
|
1085
|
+
const flux = pivoted
|
|
1086
|
+
? `${source}
|
|
1087
|
+
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
|
1088
|
+
|> drop(columns: ["_start", "_stop", "_measurement"])`
|
|
1089
|
+
: `${source}
|
|
1090
|
+
|> keep(columns: ["_time", "_field", "_value", "context"])`;
|
|
1091
|
+
const csv = new AnnotatedCsvReader();
|
|
1092
|
+
let rows = [];
|
|
1093
|
+
let records = 0;
|
|
656
1094
|
// A record with no usable timestamp cannot be placed in time, so it is not
|
|
657
1095
|
// importable — but it IS reported. The module's contract is that nothing is
|
|
658
1096
|
// dropped silently, and a `read` total that quietly excludes these makes an
|
|
659
1097
|
// import look smaller than the source rather than showing what was lost.
|
|
660
1098
|
let unusable = 0;
|
|
661
|
-
|
|
1099
|
+
const take = (rec) => {
|
|
1100
|
+
records++;
|
|
662
1101
|
const t = rec.values["_time"];
|
|
663
|
-
|
|
664
|
-
unusable++;
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
const tsNanos = rfc3339ToNanos(t);
|
|
1102
|
+
const tsNanos = t ? rfc3339ToNanos(t) : null;
|
|
668
1103
|
if (tsNanos === null) {
|
|
669
1104
|
unusable++;
|
|
670
|
-
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const context = rec.values["context"] || undefined;
|
|
1108
|
+
if (pivoted) {
|
|
1109
|
+
pushPivotedFields(rec, tsNanos, context, rows);
|
|
1110
|
+
return;
|
|
671
1111
|
}
|
|
672
1112
|
rows.push({
|
|
673
1113
|
tsNanos,
|
|
@@ -675,13 +1115,72 @@ async function readWindowFlux(req, measurement, startMs, endMs, headers, fetchIm
|
|
|
675
1115
|
// The declared type decides: a genuine string "3.5" must stay a string
|
|
676
1116
|
// rather than being parsed into the numeric table.
|
|
677
1117
|
value: coerceValue(rec.values["_value"] ?? "", rec.types["_value"]),
|
|
1118
|
+
context,
|
|
678
1119
|
});
|
|
1120
|
+
};
|
|
1121
|
+
const lines = streamLines(fetchImpl, fluxQueryUrl(req), fluxQueryInit(flux, headers), "read data", idleTimeoutMs);
|
|
1122
|
+
for await (const line of lines) {
|
|
1123
|
+
const rec = csv.push(line);
|
|
1124
|
+
if (!rec)
|
|
1125
|
+
continue;
|
|
1126
|
+
take(rec);
|
|
1127
|
+
if (records >= FLUX_BATCH_RECORDS) {
|
|
1128
|
+
yield finishBatch(measurement, rows, unusable);
|
|
1129
|
+
rows = [];
|
|
1130
|
+
records = 0;
|
|
1131
|
+
unusable = 0;
|
|
1132
|
+
}
|
|
679
1133
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
1134
|
+
const last = csv.end();
|
|
1135
|
+
if (last)
|
|
1136
|
+
take(last);
|
|
1137
|
+
if (records > 0)
|
|
1138
|
+
yield finishBatch(measurement, rows, unusable);
|
|
682
1139
|
}
|
|
683
|
-
/**
|
|
684
|
-
|
|
1140
|
+
/** Columns of a pivoted record that are bookkeeping rather than fields. */
|
|
1141
|
+
const PIVOT_NON_FIELD_COLUMNS = new Set([
|
|
1142
|
+
"result",
|
|
1143
|
+
"table",
|
|
1144
|
+
"_time",
|
|
1145
|
+
]);
|
|
1146
|
+
/**
|
|
1147
|
+
* The fields of one pivoted record, as rows at its instant.
|
|
1148
|
+
*
|
|
1149
|
+
* A pivoted record carries the series' tags as columns too. They are what the
|
|
1150
|
+
* table is grouped by, which is how they are told from fields: reading them as
|
|
1151
|
+
* fields would import a bogus `measurement.source` path for every point.
|
|
1152
|
+
*
|
|
1153
|
+
* An empty cell is a field this point does not have, not a value.
|
|
1154
|
+
*/
|
|
1155
|
+
function pushPivotedFields(rec, tsNanos, context, out) {
|
|
1156
|
+
for (const column of Object.keys(rec.values)) {
|
|
1157
|
+
if (PIVOT_NON_FIELD_COLUMNS.has(column) || rec.grouped.has(column))
|
|
1158
|
+
continue;
|
|
1159
|
+
const raw = rec.values[column];
|
|
1160
|
+
if (raw === "")
|
|
1161
|
+
continue;
|
|
1162
|
+
out.push({
|
|
1163
|
+
tsNanos,
|
|
1164
|
+
field: column,
|
|
1165
|
+
value: coerceValue(raw, rec.types[column]),
|
|
1166
|
+
context,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* How many points InfluxDB 1.x puts in one chunk of a chunked response. Each
|
|
1172
|
+
* chunk becomes a batch, so this bounds what a window holds at once.
|
|
1173
|
+
*/
|
|
1174
|
+
const INFLUXQL_CHUNK_POINTS = 10_000;
|
|
1175
|
+
/**
|
|
1176
|
+
* Read one time window of a measurement from InfluxDB 1.x, in batches.
|
|
1177
|
+
*
|
|
1178
|
+
* `chunked=true` makes InfluxDB answer with a series of complete JSON
|
|
1179
|
+
* documents, one per line, instead of one document for the whole window. A
|
|
1180
|
+
* 1.x row already carries every field of its point, so each chunk holds whole
|
|
1181
|
+
* points and can be finished on its own.
|
|
1182
|
+
*/
|
|
1183
|
+
async function* readWindowInfluxQl(req, measurement, startMs, endMs, headers, fetchImpl, idleTimeoutMs) {
|
|
685
1184
|
// Measurement names come from SHOW MEASUREMENTS on this same server, but
|
|
686
1185
|
// they still land inside a query string — quote them as identifiers.
|
|
687
1186
|
//
|
|
@@ -696,28 +1195,46 @@ async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fet
|
|
|
696
1195
|
// bogus `measurement.source = "n2k"` string path for every single point.
|
|
697
1196
|
// Signal K's own InfluxDB writers tag their points, so this is the common
|
|
698
1197
|
// case, not an exotic one.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1198
|
+
// The context tag rides along as one more column, named after the tag. A
|
|
1199
|
+
// measurement without it simply has no such column.
|
|
1200
|
+
const q = `SELECT *::field, "context"::tag FROM ${quoted} WHERE time >= ${BigInt(startMs) * NANOS_PER_MS} AND time < ${BigInt(endMs) * NANOS_PER_MS}`;
|
|
1201
|
+
const url = `${req.url}/query?db=${encodeURIComponent(req.bucket)}&epoch=ns&chunked=true&chunk_size=${INFLUXQL_CHUNK_POINTS}&q=${encodeURIComponent(q)}`;
|
|
1202
|
+
for await (const line of streamLines(fetchImpl, url, { headers }, "read data", idleTimeoutMs)) {
|
|
1203
|
+
if (line.trim() === "")
|
|
1204
|
+
continue;
|
|
1205
|
+
let body;
|
|
1206
|
+
try {
|
|
1207
|
+
body = JSON.parse(line);
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
throw new Error("Cannot read data: InfluxDB returned a malformed response");
|
|
1211
|
+
}
|
|
1212
|
+
if (body.error)
|
|
1213
|
+
throw new Error(body.error);
|
|
1214
|
+
if (body.results?.[0]?.error)
|
|
1215
|
+
throw new Error(body.results[0].error);
|
|
1216
|
+
const rows = [];
|
|
1217
|
+
const unusable = pushInfluxQlSeries(body.results?.[0]?.series ?? [], rows);
|
|
1218
|
+
if (rows.length > 0 || unusable > 0)
|
|
1219
|
+
yield finishBatch(measurement, rows, unusable);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* The points of one InfluxQL result, as rows. Returns how many values could
|
|
1224
|
+
* not be imported — reported, like readWindowFlux's, not dropped in silence.
|
|
1225
|
+
*/
|
|
1226
|
+
function pushInfluxQlSeries(series, rows) {
|
|
714
1227
|
let unusable = 0;
|
|
715
1228
|
for (const s of series) {
|
|
716
1229
|
const columns = s.columns ?? [];
|
|
717
1230
|
const timeIdx = columns.indexOf("time");
|
|
718
1231
|
if (timeIdx < 0)
|
|
719
1232
|
continue;
|
|
1233
|
+
const contextIdx = columns.indexOf("context");
|
|
720
1234
|
for (const values of s.values ?? []) {
|
|
1235
|
+
const context = contextIdx >= 0 && typeof values[contextIdx] === "string"
|
|
1236
|
+
? values[contextIdx] || undefined
|
|
1237
|
+
: undefined;
|
|
721
1238
|
// epoch=ns returns the time as a number; going through BigInt(String)
|
|
722
1239
|
// avoids the precision loss a float64 would suffer past 2^53 ns.
|
|
723
1240
|
const rawTime = values[timeIdx];
|
|
@@ -731,7 +1248,7 @@ async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fet
|
|
|
731
1248
|
continue;
|
|
732
1249
|
}
|
|
733
1250
|
for (let i = 0; i < columns.length; i++) {
|
|
734
|
-
if (i === timeIdx)
|
|
1251
|
+
if (i === timeIdx || i === contextIdx)
|
|
735
1252
|
continue;
|
|
736
1253
|
const raw = values[i];
|
|
737
1254
|
// A wide row has a column per field; nulls are fields absent from
|
|
@@ -758,39 +1275,55 @@ async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fet
|
|
|
758
1275
|
tsNanos,
|
|
759
1276
|
field: columns[i],
|
|
760
1277
|
value: raw,
|
|
1278
|
+
context,
|
|
761
1279
|
});
|
|
762
1280
|
}
|
|
763
1281
|
}
|
|
764
1282
|
}
|
|
765
|
-
|
|
766
|
-
return { rows: merged.rows, dropped: merged.dropped + unusable };
|
|
1283
|
+
return unusable;
|
|
767
1284
|
}
|
|
768
1285
|
/**
|
|
769
1286
|
* Recombine latitude/longitude fields of a position measurement into one
|
|
770
1287
|
* object value at the same instant.
|
|
771
1288
|
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
* two numeric paths and the position history would be unusable
|
|
775
|
-
* positions in their own table.
|
|
1289
|
+
* signalk-to-influxdb2 stores navigation.position as two fields (`lat`/`lon`),
|
|
1290
|
+
* as does signalk-to-influxdb 1.x with `separateLatLon` on. Left as-is they
|
|
1291
|
+
* would import as two numeric paths and the position history would be unusable
|
|
1292
|
+
* — QuestDB keeps positions in their own table. The 1.x default encoding, a
|
|
1293
|
+
* single `jsonValue`, is already whole by the time it gets here (see
|
|
1294
|
+
* decodeJsonValueRows).
|
|
776
1295
|
*/
|
|
777
1296
|
export function mergePositionRows(measurement, rows) {
|
|
778
1297
|
if (!measurement.includes("position"))
|
|
779
1298
|
return { rows, dropped: 0 };
|
|
780
1299
|
const latKeys = new Set(["latitude", "lat"]);
|
|
781
1300
|
const lonKeys = new Set(["longitude", "lon", "lng"]);
|
|
1301
|
+
// Keyed by vessel AND instant: two vessels' fixes at the same instant are
|
|
1302
|
+
// two positions, and a latitude of one must never pair with a longitude of
|
|
1303
|
+
// the other.
|
|
782
1304
|
const byTime = new Map();
|
|
783
1305
|
const passthrough = [];
|
|
1306
|
+
// Instants that already carry a whole position. signalk-to-influxdb 1.x with
|
|
1307
|
+
// `separateLatLon` on writes `jsonValue` AND `lat`/`lon` for the same fix, so
|
|
1308
|
+
// the pair is a second copy of a row that is already going to be written.
|
|
1309
|
+
const wholePositionTimes = new Set();
|
|
784
1310
|
for (const row of rows) {
|
|
785
|
-
const key = row.tsNanos
|
|
786
|
-
|
|
1311
|
+
const key = `${row.context ?? ""}|${row.tsNanos}`;
|
|
1312
|
+
// Routed on the mapped path, as writeRow does, so a row is only counted as
|
|
1313
|
+
// a whole position here if it is going to be written as one.
|
|
1314
|
+
if (routeDeltaValue(toSignalKPath(measurement, row.field), row.value) ===
|
|
1315
|
+
"position") {
|
|
1316
|
+
wholePositionTimes.add(key);
|
|
1317
|
+
passthrough.push(row);
|
|
1318
|
+
}
|
|
1319
|
+
else if (latKeys.has(row.field) && typeof row.value === "number") {
|
|
787
1320
|
if (!byTime.has(key))
|
|
788
|
-
byTime.set(key, {});
|
|
1321
|
+
byTime.set(key, { context: row.context, tsNanos: row.tsNanos });
|
|
789
1322
|
byTime.get(key).lat = row.value;
|
|
790
1323
|
}
|
|
791
1324
|
else if (lonKeys.has(row.field) && typeof row.value === "number") {
|
|
792
1325
|
if (!byTime.has(key))
|
|
793
|
-
byTime.set(key, {});
|
|
1326
|
+
byTime.set(key, { context: row.context, tsNanos: row.tsNanos });
|
|
794
1327
|
byTime.get(key).lon = row.value;
|
|
795
1328
|
}
|
|
796
1329
|
else {
|
|
@@ -799,7 +1332,9 @@ export function mergePositionRows(measurement, rows) {
|
|
|
799
1332
|
}
|
|
800
1333
|
const merged = [];
|
|
801
1334
|
let dropped = 0;
|
|
802
|
-
for (const [key, { lat, lon }] of byTime) {
|
|
1335
|
+
for (const [key, { context, tsNanos, lat, lon }] of byTime) {
|
|
1336
|
+
if (wholePositionTimes.has(key))
|
|
1337
|
+
continue;
|
|
803
1338
|
// A half-pair (lat with no lon at the same instant) is not a position.
|
|
804
1339
|
// Emitting it as a bare number would be worse than skipping it, so it is
|
|
805
1340
|
// dropped — and REPORTED, so it lands in the run's skipped total instead
|
|
@@ -809,9 +1344,10 @@ export function mergePositionRows(measurement, rows) {
|
|
|
809
1344
|
continue;
|
|
810
1345
|
}
|
|
811
1346
|
merged.push({
|
|
812
|
-
tsNanos
|
|
1347
|
+
tsNanos,
|
|
813
1348
|
field: "value",
|
|
814
1349
|
value: { latitude: lat, longitude: lon },
|
|
1350
|
+
...(context === undefined ? {} : { context }),
|
|
815
1351
|
});
|
|
816
1352
|
}
|
|
817
1353
|
return { rows: [...passthrough, ...merged], dropped };
|