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 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
@@ -28,7 +28,11 @@ const { csvToLineProtocol } = require('./lineproto')
28
28
  const HOUR_MS = 3600000
29
29
  const DEFAULTS = {
30
30
  windowMs: HOUR_MS,
31
- batchSize: 10000,
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/vnd.flux', Accept: 'application/csv' },
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
- async function doWindow (startMs, stopMs) {
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
- return doWindow(startMs, mid)
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
- // Verify: a 204 says the bytes were accepted, not that every point landed. This is
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 r = await doWindow(startMs, stopMs)
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.0",
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": {