sailkick-boat 0.21.0 → 0.21.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 -1
- package/lib/backfill/index.js +76 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,9 +307,24 @@ arriving now, and the spool only replays what it captured itself while offline.
|
|
|
307
307
|
|
|
308
308
|
Fill the **Copy older history to the cloud** section and save. It walks backwards in
|
|
309
309
|
one-hour windows (newest first, so recent history lands first), resumes after a restart
|
|
310
|
-
from a manifest, and stands aside whenever live telemetry has a backlog — the data-
|
|
310
|
+
from a manifest, and stands aside whenever live telemetry has a **backlog** — the data-
|
|
311
311
|
critical path is never starved by a bulk upload. Progress shows in the status line.
|
|
312
312
|
|
|
313
|
+
"Backlog" means several files waiting, not merely an upload in flight. Requiring an empty
|
|
314
|
+
spool was a race: live sync flushes every second, so once the round trip to the cloud grew
|
|
315
|
+
past a second the spool was never empty for an instant and the backfill stood down for
|
|
316
|
+
ever — silently, since that path logs nothing. It now yields at a real backlog, which an
|
|
317
|
+
outage produces within a minute, and logs both the stand-down and the resume.
|
|
318
|
+
|
|
319
|
+
**It restarts itself.** A run stops after a streak of failed writes, so a boat that has
|
|
320
|
+
gone offline never marks a window falsely done — but it then schedules a fresh walk (1 min,
|
|
321
|
+
backing off to 30 min, reset by any successful window) instead of waiting for a human. It
|
|
322
|
+
used to end with "resumes on restart", and nothing restarted it: one boat sat idle for 8.6
|
|
323
|
+
hours at 96% complete after seven link drops in 13 minutes, while live sync rode out the
|
|
324
|
+
same drops with a single retry. A rejected destination (a renamed bucket, a bad token) is
|
|
325
|
+
retried the same way, since those are settings and settings get corrected. Genuinely
|
|
326
|
+
malformed data still stops for good — retrying cannot fix it.
|
|
327
|
+
|
|
313
328
|
It needs a **cloud read+write token**, not the write token from signup. Every hour it
|
|
314
329
|
uploads is verified by counting the destination, and a write-only token cannot read. A
|
|
315
330
|
`204` means InfluxDB accepted the bytes, not that every point landed — without the count
|
package/lib/backfill/index.js
CHANGED
|
@@ -41,10 +41,30 @@ const DEFAULTS = {
|
|
|
41
41
|
minWindowMs: 60000, // never subdivide below a minute
|
|
42
42
|
idleMs: 250, // pending() is the real backpressure; this is just politeness
|
|
43
43
|
backlogWaitMs: 15000, // how long to stand down when live sync has a backlog
|
|
44
|
+
// Stand down at a genuine BACKLOG, not at any activity. Requiring an EMPTY spool was a
|
|
45
|
+
// race rather than a design: live sync flushes every second, and a file exists for as
|
|
46
|
+
// long as its upload is in flight. Once the round trip to the cloud grew past the flush
|
|
47
|
+
// interval — which it did — the spool stopped being empty even for an instant (measured
|
|
48
|
+
// on the boat: non-empty in 30 of 30 one-second samples) and the backfill parked for
|
|
49
|
+
// ever, silently, because that path logs nothing. A real backlog is unmistakable by
|
|
50
|
+
// comparison: an outage puts hundreds of files on disk within a minute.
|
|
51
|
+
backlogFiles: 5,
|
|
44
52
|
maxErrorStreak: 5,
|
|
53
|
+
// A run used to END on a streak of failures — "it resumes on restart", except nothing
|
|
54
|
+
// restarts it, so a boat 96% through its archive sat idle for 8.6 hours after seven
|
|
55
|
+
// Starlink drops in 13 minutes. Live sync shrugged the same drops off in one retry.
|
|
56
|
+
// Transient exits now schedule a fresh walk instead: short at first, backing off if the
|
|
57
|
+
// condition persists, reset as soon as a window succeeds.
|
|
58
|
+
retryRunMinMs: 60000,
|
|
59
|
+
retryRunMaxMs: 1800000,
|
|
45
60
|
queryTimeoutMs: 120000
|
|
46
61
|
}
|
|
47
62
|
|
|
63
|
+
// Returned by run() when it stopped for something that may clear on its own — an
|
|
64
|
+
// unreachable source, a rejected destination, a streak of failed writes. Anything else
|
|
65
|
+
// (complete, misconfigured, stopped) is final and schedules nothing.
|
|
66
|
+
const TRANSIENT = 'transient'
|
|
67
|
+
|
|
48
68
|
const iso = (ms) => new Date(ms).toISOString()
|
|
49
69
|
const hourFloor = (ms) => Math.floor(ms / HOUR_MS) * HOUR_MS
|
|
50
70
|
const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref() })
|
|
@@ -64,6 +84,8 @@ function createBackfill (app, options) {
|
|
|
64
84
|
let stopped = false
|
|
65
85
|
let statusLine = 'backfill: off'
|
|
66
86
|
let runPromise = null
|
|
87
|
+
let retryTimer = null
|
|
88
|
+
let retryDelay = 0 // set from cfg on first use; grows while a transient condition holds
|
|
67
89
|
|
|
68
90
|
// --- persisted manifest -------------------------------------------------------
|
|
69
91
|
function load () {
|
|
@@ -226,7 +248,15 @@ function createBackfill (app, options) {
|
|
|
226
248
|
const body = lines.slice(i, i + cfg.batchSize).join('\n') + '\n'
|
|
227
249
|
const res = await writeLines({ influxUrl: cfg.dst.url, org: cfg.dst.org, bucket: cfg.dst.bucket, token: cfg.dst.token, timeoutMs: cfg.queryTimeoutMs }, body)
|
|
228
250
|
if (!res.ok) {
|
|
229
|
-
//
|
|
251
|
+
// A wrong bucket or token is a SETTING, not bad data — it will keep rejecting
|
|
252
|
+
// until someone changes the config, and then start working with no code change.
|
|
253
|
+
// Worth retrying slowly rather than stopping for good. (See the same split in
|
|
254
|
+
// lib/sync/influxWrite.js.)
|
|
255
|
+
if (res.configError) {
|
|
256
|
+
warn(`write to ${cfg.dst.bucket} rejected (HTTP ${res.status}) — ${res.status === 404 ? 'that bucket does not exist; check "Data bucket" in the plugin config' : 'check the cloud read+write token'}`)
|
|
257
|
+
return 'config'
|
|
258
|
+
}
|
|
259
|
+
// Other 4xx: malformed data. Retrying cannot fix it.
|
|
230
260
|
if (!res.retryable) {
|
|
231
261
|
// Name the destination: after a bucket rename this is the first thing to check.
|
|
232
262
|
warn(`write to ${cfg.dst.bucket} REJECTED (HTTP ${res.status}) — ${res.body ? String(res.body).slice(0, 160) : 'no detail'}`)
|
|
@@ -247,7 +277,7 @@ function createBackfill (app, options) {
|
|
|
247
277
|
try {
|
|
248
278
|
// Validate the source and decide the filter before walking 15k windows.
|
|
249
279
|
const contexts = await sourceContexts()
|
|
250
|
-
if (contexts == null) { warn(`could not read contexts from ${cfg.src.bucket} — is the source reachable?`); statusLine = 'backfill: source unreachable'; return }
|
|
280
|
+
if (contexts == null) { warn(`could not read contexts from ${cfg.src.bucket} — is the source reachable?`); statusLine = 'backfill: source unreachable'; return TRANSIENT }
|
|
251
281
|
if (!contexts.length) {
|
|
252
282
|
warn(`source bucket "${cfg.src.bucket}" (org "${cfg.src.org}") holds no data at all — check the org and bucket names`)
|
|
253
283
|
statusLine = `backfill: source ${cfg.src.org}/${cfg.src.bucket} is empty — check the names`
|
|
@@ -257,7 +287,7 @@ function createBackfill (app, options) {
|
|
|
257
287
|
|
|
258
288
|
if (state.earliest == null) {
|
|
259
289
|
const e = await earliestPoint()
|
|
260
|
-
if (e == null) { warn('could not read the oldest point from the source — is it reachable?'); statusLine = 'backfill: source unreachable'; return }
|
|
290
|
+
if (e == null) { warn('could not read the oldest point from the source — is it reachable?'); statusLine = 'backfill: source unreachable'; return TRANSIENT }
|
|
261
291
|
state.earliest = e
|
|
262
292
|
log(`oldest point in ${cfg.src.bucket}: ${iso(e)}`)
|
|
263
293
|
save()
|
|
@@ -294,22 +324,27 @@ function createBackfill (app, options) {
|
|
|
294
324
|
const key = iso(startMs)
|
|
295
325
|
if (state.done[key]) continue
|
|
296
326
|
|
|
297
|
-
// Live telemetry is data-critical; this is not. Stand down while it is
|
|
327
|
+
// Live telemetry is data-critical; this is not. Stand down while it is BEHIND —
|
|
328
|
+
// which means a backlog, not merely an upload in flight. See cfg.backlogFiles.
|
|
329
|
+
let waited = false
|
|
298
330
|
while (!stopped && cfg.pending) {
|
|
299
331
|
let depth = 0
|
|
300
332
|
try { depth = (await cfg.pending()).count || 0 } catch {}
|
|
301
|
-
if (
|
|
333
|
+
if (depth < cfg.backlogFiles) break
|
|
334
|
+
if (!waited) { waited = true; log(`standing down — live sync is behind (${depth} file(s)); telemetry has the link`) }
|
|
302
335
|
statusLine = `backfill: paused — live sync backlog (${depth} file(s))`
|
|
303
336
|
await sleep(cfg.backlogWaitMs)
|
|
304
337
|
}
|
|
338
|
+
if (waited && !stopped) log('live sync caught up — resuming')
|
|
305
339
|
if (stopped) break
|
|
306
340
|
|
|
307
341
|
const tally = { written: 0 }
|
|
308
342
|
const r = await doWindow(startMs, stopMs, tally)
|
|
309
343
|
if (r === 'stopped') break
|
|
310
344
|
if (r === 'fatal') { statusLine = 'backfill: stopped — write rejected, see the log'; return }
|
|
345
|
+
if (r === 'config') { statusLine = 'backfill: waiting — destination rejected the write, see the log'; return TRANSIENT }
|
|
311
346
|
if (r === 'retry') {
|
|
312
|
-
if (++errStreak >= cfg.maxErrorStreak) { warn(`${errStreak} consecutive failures —
|
|
347
|
+
if (++errStreak >= cfg.maxErrorStreak) { warn(`${errStreak} consecutive failures — pausing this run`); statusLine = 'backfill: paused after repeated errors'; return TRANSIENT }
|
|
313
348
|
continue
|
|
314
349
|
}
|
|
315
350
|
|
|
@@ -322,17 +357,18 @@ function createBackfill (app, options) {
|
|
|
322
357
|
const dstCount = await count(cfg.dst, cfg.dst.bucket, startMs, stopMs)
|
|
323
358
|
if (dstCount == null) {
|
|
324
359
|
warn(`could not verify ${key} — leaving it for the next run`)
|
|
325
|
-
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return }
|
|
360
|
+
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return TRANSIENT }
|
|
326
361
|
continue
|
|
327
362
|
}
|
|
328
363
|
if (dstCount < tally.written) {
|
|
329
364
|
warn(`${key} MISMATCH: wrote ${tally.written}, destination has ${dstCount} — not marking done`)
|
|
330
|
-
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return }
|
|
365
|
+
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return TRANSIENT }
|
|
331
366
|
continue
|
|
332
367
|
}
|
|
333
368
|
state.points += tally.written
|
|
334
369
|
}
|
|
335
370
|
errStreak = 0
|
|
371
|
+
retryDelay = cfg.retryRunMinMs // real progress: a later stall starts from the short delay again
|
|
336
372
|
state.done[key] = r === 'empty' ? 'empty' : 'ok'
|
|
337
373
|
didWork++
|
|
338
374
|
save()
|
|
@@ -363,20 +399,49 @@ function createBackfill (app, options) {
|
|
|
363
399
|
}
|
|
364
400
|
}
|
|
365
401
|
|
|
402
|
+
// Re-enter the walk after a transient stop. The walk itself is resumable — every
|
|
403
|
+
// completed window is in the manifest — so a fresh run simply skips them and carries on
|
|
404
|
+
// from where it left off.
|
|
405
|
+
function scheduleRerun () {
|
|
406
|
+
if (stopped) return
|
|
407
|
+
clearTimeout(retryTimer)
|
|
408
|
+
if (!retryDelay) retryDelay = cfg.retryRunMinMs
|
|
409
|
+
const delay = retryDelay
|
|
410
|
+
retryDelay = Math.min(retryDelay * 2, cfg.retryRunMaxMs)
|
|
411
|
+
log(`paused; retrying in ${Math.round(delay / 1000)}s`)
|
|
412
|
+
statusLine += `; retry in ${Math.round(delay / 1000)}s`
|
|
413
|
+
retryTimer = setTimeout(() => { if (!stopped) begin() }, delay)
|
|
414
|
+
if (retryTimer.unref) retryTimer.unref()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function begin () {
|
|
418
|
+
if (running) return runPromise
|
|
419
|
+
state = load()
|
|
420
|
+
if (state.complete) { statusLine = `backfill: complete — ${state.points} point(s)`; return null }
|
|
421
|
+
runPromise = run()
|
|
422
|
+
.then((r) => { if (r === TRANSIENT) scheduleRerun() })
|
|
423
|
+
.catch((e) => {
|
|
424
|
+
warn('run failed: ' + e.message)
|
|
425
|
+
statusLine = 'backfill: error — ' + e.message
|
|
426
|
+
scheduleRerun() // an unexpected throw is exactly the case worth retrying
|
|
427
|
+
})
|
|
428
|
+
return runPromise
|
|
429
|
+
}
|
|
430
|
+
|
|
366
431
|
function start () {
|
|
367
432
|
if (running) return runPromise
|
|
368
433
|
stopped = false
|
|
434
|
+
retryDelay = cfg.retryRunMinMs
|
|
369
435
|
state = load()
|
|
370
436
|
if (state.complete) { statusLine = `backfill: complete — ${state.points} point(s)`; return null }
|
|
371
437
|
if (!cfg.src.token || !cfg.src.bucket || !cfg.src.url) { statusLine = 'backfill: not configured (source)'; return null }
|
|
372
438
|
if (!cfg.dst.token || !cfg.dst.bucket) { statusLine = 'backfill: not configured (cloud token)'; return null }
|
|
373
439
|
log(`${cfg.src.url} ${cfg.src.org}/${cfg.src.bucket} -> ${cfg.dst.url} ${cfg.dst.org}/${cfg.dst.bucket}`)
|
|
374
440
|
statusLine = 'backfill: starting'
|
|
375
|
-
|
|
376
|
-
return runPromise
|
|
441
|
+
return begin()
|
|
377
442
|
}
|
|
378
443
|
|
|
379
|
-
function stop () { stopped = true }
|
|
444
|
+
function stop () { stopped = true; clearTimeout(retryTimer); retryTimer = null }
|
|
380
445
|
function status () { return statusLine }
|
|
381
446
|
|
|
382
447
|
return { start, stop, status, _state: () => state, _doWindow: doWindow, _wait: () => runPromise }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|