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 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
@@ -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
- // 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.
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 behind.
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 (!depth) break
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 — 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 }
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
- runPromise = run().catch((e) => { warn('run failed: ' + e.message); statusLine = 'backfill: error — ' + e.message })
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.0",
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": {