sailkick-boat 0.18.0 → 0.18.2
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 +16 -0
- package/lib/backfill/index.js +50 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -236,6 +236,22 @@ a source archive that is still being written — a `signalk-to-influxdb-v2` buck
|
|
|
236
236
|
recording — makes the first windows re-upload today's data. The timestamps are correct,
|
|
237
237
|
but it is data the cloud already has, and a lot of wasted uplink.
|
|
238
238
|
|
|
239
|
+
**Latency is the cost, not bandwidth.** Measured on a real archive, about 3 s of every
|
|
240
|
+
4.2 s chunk was cloud round trips while all the boat-side work (query, parse, convert)
|
|
241
|
+
took ~1.2 s. So batches carry up to 50k lines rather than 10k — one or two round trips
|
|
242
|
+
per chunk instead of ten — and each **hour** is verified once rather than each of its
|
|
243
|
+
~32 chunks. The verification itself is unchanged in kind: a `204` means the bytes were
|
|
244
|
+
accepted, not that every point landed, so the destination is still counted before an
|
|
245
|
+
hour is marked done. On a mismatch the hour is left unmarked and simply redone, which is
|
|
246
|
+
safe because writes are idempotent.
|
|
247
|
+
|
|
248
|
+
**Field types come from the source, not from guessing.** Queries ask InfluxDB for the
|
|
249
|
+
`#datatype` annotation explicitly. Without it the response is unannotated and types have
|
|
250
|
+
to be inferred from the text — which fails hard on a string field whose values sometimes
|
|
251
|
+
look numeric (`"8"` emitted bare as a float, `"1.2.3.4"` quoted as a string), producing
|
|
252
|
+
`422 field type conflict` and aborting the run. Integers also keep their type instead of
|
|
253
|
+
silently becoming floats.
|
|
254
|
+
|
|
239
255
|
**Dense archives are subdivided.** A window is read whole and converted in memory, and a
|
|
240
256
|
busy boat can produce millions of points an hour (54M/day was measured on a real boat —
|
|
241
257
|
about 400 MB of CSV per hour). When a window holds more than `maxRowsPerChunk` points it
|
package/lib/backfill/index.js
CHANGED
|
@@ -28,7 +28,11 @@ const { csvToLineProtocol } = require('./lineproto')
|
|
|
28
28
|
const HOUR_MS = 3600000
|
|
29
29
|
const DEFAULTS = {
|
|
30
30
|
windowMs: HOUR_MS,
|
|
31
|
-
|
|
31
|
+
// Latency dominates, not bandwidth: measured on a real run, ~3s of every ~4.2s chunk
|
|
32
|
+
// was cloud round trips while all the Pi-side work took ~1.2s. At 10k a 100k chunk
|
|
33
|
+
// cost TEN sequential TLS round trips. 50k halves the peak memory of the gzip buffer
|
|
34
|
+
// versus one giant batch while still cutting the round trips fivefold.
|
|
35
|
+
batchSize: 50000,
|
|
32
36
|
// A window is read whole and converted in memory. A busy archive can hold millions of
|
|
33
37
|
// points per hour (54M/day was measured on a real boat = ~2.25M/hour ~ 400 MB of CSV),
|
|
34
38
|
// which would exhaust a Raspberry Pi. When a window is denser than this it is halved
|
|
@@ -79,14 +83,26 @@ function createBackfill (app, options) {
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// --- InfluxDB reads -----------------------------------------------------------
|
|
86
|
+
// Always ask for the #datatype annotation. A raw-Flux body (Content-Type
|
|
87
|
+
// application/vnd.flux) cannot carry a dialect, and InfluxDB then returns UNANNOTATED
|
|
88
|
+
// CSV — so field types have to be guessed from the text, and guessing is wrong in a
|
|
89
|
+
// way that breaks the write outright.
|
|
90
|
+
//
|
|
91
|
+
// The case that bit: a STRING field whose values sometimes look numeric. "8" is
|
|
92
|
+
// emitted bare (a float) while "1.2.3.4" is quoted (a string), so one batch declares
|
|
93
|
+
// two types for one field and InfluxDB rejects the lot with
|
|
94
|
+
// 422 field type conflict: input field "value" on measurement "network..."
|
|
95
|
+
// With the annotation the source declares `string` and every value is quoted
|
|
96
|
+
// consistently. Integers keep their `i` suffix too, instead of silently becoming
|
|
97
|
+
// floats.
|
|
82
98
|
async function flux (conn, body) {
|
|
83
99
|
const url = `${conn.url.replace(/\/+$/, '')}/api/v2/query?org=${encodeURIComponent(conn.org)}`
|
|
84
100
|
let resp
|
|
85
101
|
try {
|
|
86
102
|
resp = await fetch(url, {
|
|
87
103
|
method: 'POST',
|
|
88
|
-
headers: { Authorization: `Token ${conn.token}`, 'Content-Type': 'application/
|
|
89
|
-
body,
|
|
104
|
+
headers: { Authorization: `Token ${conn.token}`, 'Content-Type': 'application/json', Accept: 'application/csv' },
|
|
105
|
+
body: JSON.stringify({ type: 'flux', query: body, dialect: { header: true, annotations: ['datatype'] } }),
|
|
90
106
|
signal: AbortSignal.timeout(cfg.queryTimeoutMs)
|
|
91
107
|
})
|
|
92
108
|
} catch (e) { return { ok: false, message: e.message } }
|
|
@@ -176,8 +192,10 @@ function createBackfill (app, options) {
|
|
|
176
192
|
|
|
177
193
|
// --- one window ---------------------------------------------------------------
|
|
178
194
|
// Returns 'done' | 'empty' | 'retry' | 'fatal' | 'stopped'. Subdivides itself when a
|
|
179
|
-
// window holds more points than can be held in memory at once.
|
|
180
|
-
|
|
195
|
+
// window holds more points than can be held in memory at once. Points written are
|
|
196
|
+
// added to `tally` so the CALLER can verify once per hour rather than once per chunk —
|
|
197
|
+
// an hour is ~32 chunks, and each verification is a cloud round trip.
|
|
198
|
+
async function doWindow (startMs, stopMs, tally) {
|
|
181
199
|
const srcCount = await count(cfg.src, cfg.src.bucket, startMs, stopMs)
|
|
182
200
|
if (srcCount == null) return 'retry'
|
|
183
201
|
if (srcCount === 0) return 'empty'
|
|
@@ -188,9 +206,11 @@ function createBackfill (app, options) {
|
|
|
188
206
|
// done, so a failure part-way is simply retried next time.
|
|
189
207
|
const mid = startMs + Math.floor((stopMs - startMs) / 2)
|
|
190
208
|
log(`${iso(startMs)} holds ${srcCount} points — splitting`)
|
|
191
|
-
const newer = await doWindow(mid, stopMs)
|
|
209
|
+
const newer = await doWindow(mid, stopMs, tally)
|
|
192
210
|
if (newer !== 'done' && newer !== 'empty') return newer
|
|
193
|
-
|
|
211
|
+
const older = await doWindow(startMs, mid, tally)
|
|
212
|
+
if (older !== 'done' && older !== 'empty') return older
|
|
213
|
+
return (newer === 'done' || older === 'done') ? 'done' : 'empty'
|
|
194
214
|
}
|
|
195
215
|
|
|
196
216
|
const filter = cfg._filter || ''
|
|
@@ -213,15 +233,7 @@ function createBackfill (app, options) {
|
|
|
213
233
|
}
|
|
214
234
|
}
|
|
215
235
|
|
|
216
|
-
|
|
217
|
-
// the check the write-only sync token could never perform.
|
|
218
|
-
const dstCount = await count(cfg.dst, cfg.dst.bucket, startMs, stopMs)
|
|
219
|
-
if (dstCount == null) { warn(`could not verify ${iso(startMs)} — leaving it for the next run`); return 'retry' }
|
|
220
|
-
if (dstCount < lines.length) {
|
|
221
|
-
warn(`${iso(startMs)} MISMATCH: wrote ${lines.length}, destination has ${dstCount} — not marking done`)
|
|
222
|
-
return 'retry'
|
|
223
|
-
}
|
|
224
|
-
if (state) state.points += lines.length
|
|
236
|
+
if (tally) tally.written += lines.length
|
|
225
237
|
return 'done'
|
|
226
238
|
}
|
|
227
239
|
|
|
@@ -288,13 +300,34 @@ function createBackfill (app, options) {
|
|
|
288
300
|
}
|
|
289
301
|
if (stopped) break
|
|
290
302
|
|
|
291
|
-
const
|
|
303
|
+
const tally = { written: 0 }
|
|
304
|
+
const r = await doWindow(startMs, stopMs, tally)
|
|
292
305
|
if (r === 'stopped') break
|
|
293
306
|
if (r === 'fatal') { statusLine = 'backfill: stopped — write rejected, see the log'; return }
|
|
294
307
|
if (r === 'retry') {
|
|
295
308
|
if (++errStreak >= cfg.maxErrorStreak) { warn(`${errStreak} consecutive failures — stopping this run, it resumes on restart`); statusLine = 'backfill: paused after repeated errors'; return }
|
|
296
309
|
continue
|
|
297
310
|
}
|
|
311
|
+
|
|
312
|
+
// Verify ONCE for the whole hour. A 204 says the bytes were accepted, not that
|
|
313
|
+
// every point landed, so this check still has to happen — but at the hour rather
|
|
314
|
+
// than the chunk it costs one cloud round trip instead of ~32. On a mismatch the
|
|
315
|
+
// hour is left unmarked and simply redone, which is safe because writes are
|
|
316
|
+
// idempotent. (>= not ==: the boundary hour also holds live-sync data.)
|
|
317
|
+
if (tally.written > 0) {
|
|
318
|
+
const dstCount = await count(cfg.dst, cfg.dst.bucket, startMs, stopMs)
|
|
319
|
+
if (dstCount == null) {
|
|
320
|
+
warn(`could not verify ${key} — leaving it for the next run`)
|
|
321
|
+
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return }
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
if (dstCount < tally.written) {
|
|
325
|
+
warn(`${key} MISMATCH: wrote ${tally.written}, destination has ${dstCount} — not marking done`)
|
|
326
|
+
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return }
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
state.points += tally.written
|
|
330
|
+
}
|
|
298
331
|
errStreak = 0
|
|
299
332
|
state.done[key] = r === 'empty' ? 'empty' : 'ok'
|
|
300
333
|
didWork++
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|