sailkick-boat 0.21.1 → 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 +9 -0
- package/lib/backfill/index.js +62 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -316,6 +316,15 @@ past a second the spool was never empty for an instant and the backfill stood do
|
|
|
316
316
|
ever — silently, since that path logs nothing. It now yields at a real backlog, which an
|
|
317
317
|
outage produces within a minute, and logs both the stand-down and the resume.
|
|
318
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
|
+
|
|
319
328
|
It needs a **cloud read+write token**, not the write token from signup. Every hour it
|
|
320
329
|
uploads is verified by counting the destination, and a write-only token cannot read. A
|
|
321
330
|
`204` means InfluxDB accepted the bytes, not that every point landed — without the count
|
package/lib/backfill/index.js
CHANGED
|
@@ -50,9 +50,21 @@ const DEFAULTS = {
|
|
|
50
50
|
// comparison: an outage puts hundreds of files on disk within a minute.
|
|
51
51
|
backlogFiles: 5,
|
|
52
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,
|
|
53
60
|
queryTimeoutMs: 120000
|
|
54
61
|
}
|
|
55
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
|
+
|
|
56
68
|
const iso = (ms) => new Date(ms).toISOString()
|
|
57
69
|
const hourFloor = (ms) => Math.floor(ms / HOUR_MS) * HOUR_MS
|
|
58
70
|
const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref() })
|
|
@@ -72,6 +84,8 @@ function createBackfill (app, options) {
|
|
|
72
84
|
let stopped = false
|
|
73
85
|
let statusLine = 'backfill: off'
|
|
74
86
|
let runPromise = null
|
|
87
|
+
let retryTimer = null
|
|
88
|
+
let retryDelay = 0 // set from cfg on first use; grows while a transient condition holds
|
|
75
89
|
|
|
76
90
|
// --- persisted manifest -------------------------------------------------------
|
|
77
91
|
function load () {
|
|
@@ -234,7 +248,15 @@ function createBackfill (app, options) {
|
|
|
234
248
|
const body = lines.slice(i, i + cfg.batchSize).join('\n') + '\n'
|
|
235
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)
|
|
236
250
|
if (!res.ok) {
|
|
237
|
-
//
|
|
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.
|
|
238
260
|
if (!res.retryable) {
|
|
239
261
|
// Name the destination: after a bucket rename this is the first thing to check.
|
|
240
262
|
warn(`write to ${cfg.dst.bucket} REJECTED (HTTP ${res.status}) — ${res.body ? String(res.body).slice(0, 160) : 'no detail'}`)
|
|
@@ -255,7 +277,7 @@ function createBackfill (app, options) {
|
|
|
255
277
|
try {
|
|
256
278
|
// Validate the source and decide the filter before walking 15k windows.
|
|
257
279
|
const contexts = await sourceContexts()
|
|
258
|
-
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 }
|
|
259
281
|
if (!contexts.length) {
|
|
260
282
|
warn(`source bucket "${cfg.src.bucket}" (org "${cfg.src.org}") holds no data at all — check the org and bucket names`)
|
|
261
283
|
statusLine = `backfill: source ${cfg.src.org}/${cfg.src.bucket} is empty — check the names`
|
|
@@ -265,7 +287,7 @@ function createBackfill (app, options) {
|
|
|
265
287
|
|
|
266
288
|
if (state.earliest == null) {
|
|
267
289
|
const e = await earliestPoint()
|
|
268
|
-
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 }
|
|
269
291
|
state.earliest = e
|
|
270
292
|
log(`oldest point in ${cfg.src.bucket}: ${iso(e)}`)
|
|
271
293
|
save()
|
|
@@ -320,8 +342,9 @@ function createBackfill (app, options) {
|
|
|
320
342
|
const r = await doWindow(startMs, stopMs, tally)
|
|
321
343
|
if (r === 'stopped') break
|
|
322
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 }
|
|
323
346
|
if (r === 'retry') {
|
|
324
|
-
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 }
|
|
325
348
|
continue
|
|
326
349
|
}
|
|
327
350
|
|
|
@@ -334,17 +357,18 @@ function createBackfill (app, options) {
|
|
|
334
357
|
const dstCount = await count(cfg.dst, cfg.dst.bucket, startMs, stopMs)
|
|
335
358
|
if (dstCount == null) {
|
|
336
359
|
warn(`could not verify ${key} — leaving it for the next run`)
|
|
337
|
-
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return }
|
|
360
|
+
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return TRANSIENT }
|
|
338
361
|
continue
|
|
339
362
|
}
|
|
340
363
|
if (dstCount < tally.written) {
|
|
341
364
|
warn(`${key} MISMATCH: wrote ${tally.written}, destination has ${dstCount} — not marking done`)
|
|
342
|
-
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return }
|
|
365
|
+
if (++errStreak >= cfg.maxErrorStreak) { statusLine = 'backfill: paused after repeated errors'; return TRANSIENT }
|
|
343
366
|
continue
|
|
344
367
|
}
|
|
345
368
|
state.points += tally.written
|
|
346
369
|
}
|
|
347
370
|
errStreak = 0
|
|
371
|
+
retryDelay = cfg.retryRunMinMs // real progress: a later stall starts from the short delay again
|
|
348
372
|
state.done[key] = r === 'empty' ? 'empty' : 'ok'
|
|
349
373
|
didWork++
|
|
350
374
|
save()
|
|
@@ -375,20 +399,49 @@ function createBackfill (app, options) {
|
|
|
375
399
|
}
|
|
376
400
|
}
|
|
377
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
|
+
|
|
378
431
|
function start () {
|
|
379
432
|
if (running) return runPromise
|
|
380
433
|
stopped = false
|
|
434
|
+
retryDelay = cfg.retryRunMinMs
|
|
381
435
|
state = load()
|
|
382
436
|
if (state.complete) { statusLine = `backfill: complete — ${state.points} point(s)`; return null }
|
|
383
437
|
if (!cfg.src.token || !cfg.src.bucket || !cfg.src.url) { statusLine = 'backfill: not configured (source)'; return null }
|
|
384
438
|
if (!cfg.dst.token || !cfg.dst.bucket) { statusLine = 'backfill: not configured (cloud token)'; return null }
|
|
385
439
|
log(`${cfg.src.url} ${cfg.src.org}/${cfg.src.bucket} -> ${cfg.dst.url} ${cfg.dst.org}/${cfg.dst.bucket}`)
|
|
386
440
|
statusLine = 'backfill: starting'
|
|
387
|
-
|
|
388
|
-
return runPromise
|
|
441
|
+
return begin()
|
|
389
442
|
}
|
|
390
443
|
|
|
391
|
-
function stop () { stopped = true }
|
|
444
|
+
function stop () { stopped = true; clearTimeout(retryTimer); retryTimer = null }
|
|
392
445
|
function status () { return statusLine }
|
|
393
446
|
|
|
394
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": {
|