sailkick-boat 0.21.1 → 0.21.3
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 +18 -0
- package/index.js +8 -0
- package/lib/backfill/index.js +62 -9
- package/lib/proxy/index.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -242,6 +242,15 @@ so the boat's own app opens with no password, fully offline. Everything else in
|
|
|
242
242
|
config passes through untouched. (Hand-edit `proxy.openAccess: false` to keep the cloud
|
|
243
243
|
login gate — there is no toggle, since the gate cannot complete over the mirror anyway.)
|
|
244
244
|
|
|
245
|
+
It also fills in **`boat.perfKey`**, which the cloud only sends to a logged-in session.
|
|
246
|
+
Without it the app has no identity and the **performance data cloud** — the recorded
|
|
247
|
+
(TWA, STW) samples behind the polar plot, baked to `/perf/<perfKey>/estimate.json` — never
|
|
248
|
+
loads, even though the bake itself is cached and reachable. The key is derived rather than
|
|
249
|
+
configured: the app server takes a boat's Influx bucket and its perf directory from the
|
|
250
|
+
same identity, so the bucket minus its `_raw` suffix *is* the perf key, for a UUID account
|
|
251
|
+
and a grandfathered slug one alike. An unpaired boat has no bucket, so `boat` is left
|
|
252
|
+
exactly as the cloud sent it.
|
|
253
|
+
|
|
245
254
|
## Routes, polars and settings — stored on the boat
|
|
246
255
|
The app reads and writes these through `/api/profile/*`. On the cloud that router is
|
|
247
256
|
session-gated, and the mirror can never satisfy it: the caching GET path forwards no
|
|
@@ -316,6 +325,15 @@ past a second the spool was never empty for an instant and the backfill stood do
|
|
|
316
325
|
ever — silently, since that path logs nothing. It now yields at a real backlog, which an
|
|
317
326
|
outage produces within a minute, and logs both the stand-down and the resume.
|
|
318
327
|
|
|
328
|
+
**It restarts itself.** A run stops after a streak of failed writes, so a boat that has
|
|
329
|
+
gone offline never marks a window falsely done — but it then schedules a fresh walk (1 min,
|
|
330
|
+
backing off to 30 min, reset by any successful window) instead of waiting for a human. It
|
|
331
|
+
used to end with "resumes on restart", and nothing restarted it: one boat sat idle for 8.6
|
|
332
|
+
hours at 96% complete after seven link drops in 13 minutes, while live sync rode out the
|
|
333
|
+
same drops with a single retry. A rejected destination (a renamed bucket, a bad token) is
|
|
334
|
+
retried the same way, since those are settings and settings get corrected. Genuinely
|
|
335
|
+
malformed data still stops for good — retrying cannot fix it.
|
|
336
|
+
|
|
319
337
|
It needs a **cloud read+write token**, not the write token from signup. Every hour it
|
|
320
338
|
uploads is verified by counting the destination, and a write-only token cannot read. A
|
|
321
339
|
`204` means InfluxDB accepted the bytes, not that every point landed — without the count
|
package/index.js
CHANGED
|
@@ -266,9 +266,17 @@ module.exports = function (app) {
|
|
|
266
266
|
if (upstream.ignored) {
|
|
267
267
|
;(app.error || console.error)(`[sailkick-boat] ignoring proxy.sailkickUrl "${upstream.ignored}" left over from an older config — mirroring ${upstream.url}. Set proxy.selfHosted:true to keep your own server.`)
|
|
268
268
|
}
|
|
269
|
+
// Boat identity for the app, derived — never guessed. The cloud only fills
|
|
270
|
+
// config.boat for a logged-in session and the mirror forwards no cookie, so the app
|
|
271
|
+
// ran with no identity and public/engine/polar-cloud.js could not find its data
|
|
272
|
+
// cloud (it keys on boat.perfKey). The app server defaults `bucket` and `perfKey`
|
|
273
|
+
// from the same identity (server/auth/registry.js), so the bucket minus its _raw
|
|
274
|
+
// suffix IS the perf key — for a UUID account and a grandfathered slug one alike.
|
|
275
|
+
const perfKey = b && b.bucket ? String(b.bucket).replace(/_raw$/, '') : null
|
|
269
276
|
const pOpts = {
|
|
270
277
|
sailkickUrl: upstream.url,
|
|
271
278
|
storeDir: store,
|
|
279
|
+
boat: perfKey ? { perfKey, slug: (b && b.slug) || perfKey } : null,
|
|
272
280
|
proxyPort: (proxyPort = p.proxyPort == null ? 8080 : p.proxyPort),
|
|
273
281
|
localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
|
|
274
282
|
localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
|
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/lib/proxy/index.js
CHANGED
|
@@ -93,9 +93,11 @@ function createProxy (app, options) {
|
|
|
93
93
|
history: options.history || null,
|
|
94
94
|
aisTargets: options.aisTargets || null,
|
|
95
95
|
profile: options.profile || null,
|
|
96
|
+
boat: options.boat || null, // { perfKey, slug } — patched into /api/config, see serveConfig
|
|
96
97
|
openAccess: options.openAccess !== false
|
|
97
98
|
}
|
|
98
99
|
log(`mirroring ${cfg.upstream}; local SignalK ${cfg.localSignalk}; store ${cfg.storeDir}`)
|
|
100
|
+
if (cfg.boat && cfg.boat.perfKey) log(`boat identity for the app: perfKey=${cfg.boat.perfKey} (performance data cloud at /perf/${cfg.boat.perfKey}/)`)
|
|
99
101
|
|
|
100
102
|
// Cache-manifest poller: auto-refresh a dataset lazily when the cloud
|
|
101
103
|
// announces a new bake. Tiles are otherwise pinned (no time-based expiry).
|
|
@@ -293,6 +295,26 @@ function createProxy (app, options) {
|
|
|
293
295
|
if (j && typeof j === 'object') {
|
|
294
296
|
j.auth = { ...(j.auth || {}), required: false } // single-tenant boat: no cloud login
|
|
295
297
|
if (cfg.history && cfg.history.available()) j.historyAvailable = true // served locally (InfluxDB or ring)
|
|
298
|
+
// The cloud fills `boat` only for a logged-in session, and the mirror forwards no
|
|
299
|
+
// cookie — so it always arrived as null and the app had no identity. Harmless for
|
|
300
|
+
// most of the UI, but public/engine/polar-cloud.js keys the performance data cloud
|
|
301
|
+
// on boat.perfKey and throws 'no boat identity' without it, so the polar cloud was
|
|
302
|
+
// simply absent on the boat while the bake sat cached and reachable.
|
|
303
|
+
//
|
|
304
|
+
// perfKey is not guessed: server/auth/registry.js defaults `bucket` and `perfKey`
|
|
305
|
+
// from the same identity, so the bucket minus its _raw suffix IS the perf key —
|
|
306
|
+
// for UUID accounts and for grandfathered slug ones alike.
|
|
307
|
+
if (cfg.boat && cfg.boat.perfKey) {
|
|
308
|
+
j.boat = {
|
|
309
|
+
...(j.boat || {}),
|
|
310
|
+
perfKey: cfg.boat.perfKey,
|
|
311
|
+
slug: cfg.boat.slug || cfg.boat.perfKey,
|
|
312
|
+
name: (j.boat && j.boat.name) || cfg.boat.slug || cfg.boat.perfKey,
|
|
313
|
+
// NEVER readOnly: that flag exists for public visitor sessions, and it hides
|
|
314
|
+
// the editing affordances. The owner at the chart table has full control.
|
|
315
|
+
readOnly: false
|
|
316
|
+
}
|
|
317
|
+
}
|
|
296
318
|
out = Buffer.from(JSON.stringify(j))
|
|
297
319
|
ct = 'application/json'
|
|
298
320
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.3",
|
|
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": {
|