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 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
@@ -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
- // 4xx is fatal: bad credentials or malformed data, and retrying cannot fix it.
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 — stopping this run, it resumes on restart`); statusLine = 'backfill: paused after repeated errors'; return }
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
- runPromise = run().catch((e) => { warn('run failed: ' + e.message); statusLine = 'backfill: error — ' + e.message })
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.1",
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": {