sailkick-boat 0.17.0 → 0.17.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
@@ -201,9 +201,30 @@ backfill runs: **revoke it afterwards**, live sync is unaffected.
201
201
 
202
202
  Safe to re-run. Points are keyed by (measurement, tagset, nanosecond timestamp), so an
203
203
  identical point overwrites rather than duplicating — an interrupted migration is simply
204
- run again. Everything in the bucket is copied, including AIS contexts; hand-edit
205
- `backfill.selfOnly: true` to restrict it to your own vessel if cloud series cardinality
206
- becomes a problem.
204
+ run again.
205
+
206
+ **Only this boat's data is copied**, and that is not configurable. The cloud's history
207
+ queries assume your bucket holds one vessel, so uploading an archive's AIS would put
208
+ other ships into your own SOG and heading charts. If the source holds several contexts
209
+ the plugin copies yours and logs which it skipped. If it holds exactly **one** context it
210
+ is copied whatever identity string it uses — a bucket with one vessel cannot be an AIS
211
+ collection, and this is what lets an archive recorded under an older Signal K UUID (or an
212
+ MMSI URN) still migrate. Hand-edit `backfill.context` to force a specific one.
213
+
214
+ A run that copies **zero** points is reported as a problem, not as success: that almost
215
+ always means the org or bucket is wrong rather than that the archive is empty.
216
+
217
+ **It starts below what live sync already covers.** The destination's own oldest point is
218
+ the moment cloud sync began, so the walk begins there rather than at *now*. Without that,
219
+ a source archive that is still being written — a `signalk-to-influxdb-v2` bucket still
220
+ recording — makes the first windows re-upload today's data. The timestamps are correct,
221
+ but it is data the cloud already has, and a lot of wasted uplink.
222
+
223
+ **Dense archives are subdivided.** A window is read whole and converted in memory, and a
224
+ busy boat can produce millions of points an hour (54M/day was measured on a real boat —
225
+ about 400 MB of CSV per hour). When a window holds more than `maxRowsPerChunk` points it
226
+ is halved until it fits, down to a one-minute floor. The count is already known before
227
+ the read, so this costs nothing extra.
207
228
 
208
229
  **True wind comes from your instruments.** If the boat publishes
209
230
  `environment.wind.speedTrue` / `directionTrue`, those are stored verbatim — a wind
package/index.js CHANGED
@@ -373,7 +373,10 @@ module.exports = function (app) {
373
373
  backfill = createBackfill(app, {
374
374
  src,
375
375
  dst,
376
- selfOnly: bf.selfOnly === true,
376
+ // Never a config option: the plugin must not upload data that is not this
377
+ // boat's, and the cloud's history queries depend on that holding.
378
+ selfContext: app.selfContext || ('vessels.' + (app.selfId || 'self')),
379
+ context: String(bf.context || '').trim() || null, // hand-edit escape hatch
377
380
  startBound: bf.startBound,
378
381
  stateFile: path.join((app.getDataDirPath && app.getDataDirPath()) || '.', 'backfill.json'),
379
382
  pending: sync ? sync.pending : null
@@ -29,7 +29,13 @@ const HOUR_MS = 3600000
29
29
  const DEFAULTS = {
30
30
  windowMs: HOUR_MS,
31
31
  batchSize: 10000,
32
- idleMs: 2000, // breathing room between windows so a slow link isn't monopolised
32
+ // A window is read whole and converted in memory. A busy archive can hold millions of
33
+ // points per hour (54M/day was measured on a real boat = ~2.25M/hour ~ 400 MB of CSV),
34
+ // which would exhaust a Raspberry Pi. When a window is denser than this it is halved
35
+ // until it fits — the count is already known before the read, so this costs nothing.
36
+ maxRowsPerChunk: 100000,
37
+ minWindowMs: 60000, // never subdivide below a minute
38
+ idleMs: 250, // pending() is the real backpressure; this is just politeness
33
39
  backlogWaitMs: 15000, // how long to stand down when live sync has a backlog
34
40
  maxErrorStreak: 5,
35
41
  queryTimeoutMs: 120000
@@ -59,9 +65,9 @@ function createBackfill (app, options) {
59
65
  function load () {
60
66
  try {
61
67
  const j = JSON.parse(fs.readFileSync(cfg.stateFile, 'utf8'))
62
- if (j && typeof j === 'object') return { done: j.done || {}, earliest: j.earliest || null, points: j.points || 0, complete: !!j.complete }
68
+ if (j && typeof j === 'object') return { done: j.done || {}, earliest: j.earliest || null, ceiling: j.ceiling || null, points: j.points || 0, complete: !!j.complete }
63
69
  } catch {}
64
- return { done: {}, earliest: null, points: 0, complete: false }
70
+ return { done: {}, earliest: null, ceiling: null, points: 0, complete: false }
65
71
  }
66
72
  function save () {
67
73
  try {
@@ -106,12 +112,12 @@ function createBackfill (app, options) {
106
112
  return 0
107
113
  }
108
114
 
109
- async function earliestPoint () {
115
+ async function earliestPoint (conn = cfg.src, bucket = cfg.src.bucket) {
110
116
  // `first()` reduces each series before anything is merged, then _time is isolated
111
117
  // BEFORE group(). Both matter on a real bucket: grouping the raw stream fails with
112
118
  // "schema collision: cannot group boolean and integer types together" the moment the
113
119
  // database holds more than one field type, which any real boat's does.
114
- const r = await flux(cfg.src, `from(bucket:"${cfg.src.bucket}")|>range(start:0)|>first()|>keep(columns:["_time"])|>group()|>min(column:"_time")`)
120
+ const r = await flux(conn, `from(bucket:"${bucket}")|>range(start:0)|>first()|>keep(columns:["_time"])|>group()|>min(column:"_time")`)
115
121
  if (!r.ok) return null
116
122
  for (const line of r.text.split('\n')) {
117
123
  if (!line || line.startsWith('#') || line.includes('_time')) continue
@@ -128,18 +134,70 @@ function createBackfill (app, options) {
128
134
  return null
129
135
  }
130
136
 
137
+ // Distinct contexts in the source bucket. Returns null on any failure — never an
138
+ // empty list, so an unreachable database is not mistaken for an empty archive.
139
+ async function sourceContexts () {
140
+ const r = await flux(cfg.src, `import "influxdata/influxdb/schema"\nschema.tagValues(bucket:"${cfg.src.bucket}", tag:"context")`)
141
+ if (!r.ok) return null
142
+ const out = []
143
+ for (const line of r.text.split('\n')) {
144
+ if (!line || line.startsWith('#')) continue
145
+ const cells = line.trim().split(',')
146
+ const v = cells[cells.length - 1]
147
+ if (v && v !== '_value') out.push(v)
148
+ }
149
+ return out
150
+ }
151
+
152
+ // The plugin must never upload data that is not this boat's. Live sync guarantees
153
+ // that by subscribing to vessels.self; the backfill is the only thing that could
154
+ // break it, and the cloud's history queries depend on it holding. So this is decided
155
+ // in code, not exposed as an option someone can get wrong.
156
+ //
157
+ // The wrinkle: an imported archive may carry a DIFFERENT context than the boat's
158
+ // current identity — a UUID from a since-reinstalled Signal K, or an MMSI URN. A
159
+ // strict match would then copy nothing, silently. Hence the single-context rule: a
160
+ // bucket holding exactly one context contains one vessel by definition and cannot be
161
+ // an AIS collection, so it is copied whatever its identity string says.
162
+ function contextFilterFor (contexts) {
163
+ const selfCtx = cfg.selfContext
164
+ if (cfg.context) {
165
+ log(`copying only context ${cfg.context} (explicit override)`)
166
+ return `|>filter(fn:(r)=>r.context=="${cfg.context}")`
167
+ }
168
+ if (contexts.length <= 1) {
169
+ log(`source holds a single context (${contexts[0] || 'none'}) — copying all of it`)
170
+ return ''
171
+ }
172
+ const others = contexts.filter((c) => c !== selfCtx)
173
+ warn(`source holds ${contexts.length} contexts — copying only this boat (${selfCtx}) and skipping ${others.length} other(s), e.g. ${others.slice(0, 3).join(', ')}`)
174
+ return `|>filter(fn:(r)=>r.self=="true" or r.context=="${selfCtx}")`
175
+ }
176
+
131
177
  // --- one window ---------------------------------------------------------------
132
- // Returns 'done' | 'empty' | 'retry' | 'stopped'.
178
+ // Returns 'done' | 'empty' | 'retry' | 'fatal' | 'stopped'. Subdivides itself when a
179
+ // window holds more points than can be held in memory at once.
133
180
  async function doWindow (startMs, stopMs) {
134
181
  const srcCount = await count(cfg.src, cfg.src.bucket, startMs, stopMs)
135
182
  if (srcCount == null) return 'retry'
136
183
  if (srcCount === 0) return 'empty'
137
184
 
138
- const filter = cfg.selfOnly ? '|>filter(fn:(r)=>r.self=="true")' : ''
185
+ if (srcCount > cfg.maxRowsPerChunk && (stopMs - startMs) > cfg.minWindowMs) {
186
+ // Too dense to read whole. Halve it — newest half first, keeping the run's
187
+ // newest-first order. Both halves must succeed for the caller to mark the window
188
+ // done, so a failure part-way is simply retried next time.
189
+ const mid = startMs + Math.floor((stopMs - startMs) / 2)
190
+ log(`${iso(startMs)} holds ${srcCount} points — splitting`)
191
+ const newer = await doWindow(mid, stopMs)
192
+ if (newer !== 'done' && newer !== 'empty') return newer
193
+ return doWindow(startMs, mid)
194
+ }
195
+
196
+ const filter = cfg._filter || ''
139
197
  const r = await flux(cfg.src, `from(bucket:"${cfg.src.bucket}")|>range(start:${iso(startMs)},stop:${iso(stopMs)})${filter}|>drop(columns:["_start","_stop"])`)
140
198
  if (!r.ok) { warn(`read ${iso(startMs)} failed — ${r.message}`); return 'retry' }
141
199
 
142
- const { lines, skipped } = csvToLineProtocol(r.text, { selfOnly: cfg.selfOnly })
200
+ const { lines, skipped } = csvToLineProtocol(r.text)
143
201
  if (skipped) log(`${iso(startMs)}: skipped ${skipped} unconvertible row(s)`)
144
202
  if (!lines.length) return 'empty'
145
203
 
@@ -171,6 +229,16 @@ function createBackfill (app, options) {
171
229
  async function run () {
172
230
  running = true
173
231
  try {
232
+ // Validate the source and decide the filter before walking 15k windows.
233
+ const contexts = await sourceContexts()
234
+ if (contexts == null) { warn(`could not read contexts from ${cfg.src.bucket} — is the source reachable?`); statusLine = 'backfill: source unreachable'; return }
235
+ if (!contexts.length) {
236
+ warn(`source bucket "${cfg.src.bucket}" (org "${cfg.src.org}") holds no data at all — check the org and bucket names`)
237
+ statusLine = `backfill: source ${cfg.src.org}/${cfg.src.bucket} is empty — check the names`
238
+ return
239
+ }
240
+ cfg._filter = contextFilterFor(contexts)
241
+
174
242
  if (state.earliest == null) {
175
243
  const e = await earliestPoint()
176
244
  if (e == null) { warn('could not read the oldest point from the source — is it reachable?'); statusLine = 'backfill: source unreachable'; return }
@@ -186,7 +254,20 @@ function createBackfill (app, options) {
186
254
  const rawFloor = cfg.startBound ? Math.max(state.earliest, Date.parse(cfg.startBound)) : state.earliest
187
255
  const floor = hourFloor(rawFloor)
188
256
 
189
- let cursor = hourFloor(Date.now()) // newest-first: recent history lands first
257
+ // Start below where live sync has already delivered, not at "now". A still-live
258
+ // source archive otherwise makes the first windows re-upload today's data —
259
+ // correct timestamps, but data the cloud already has, and a lot of wasted uplink.
260
+ // The destination's own oldest point IS the moment cloud sync began, and the
261
+ // read+write token can see it.
262
+ if (state.ceiling == null) {
263
+ const dstOldest = await earliestPoint(cfg.dst, cfg.dst.bucket)
264
+ state.ceiling = dstOldest != null ? hourFloor(dstOldest) : hourFloor(Date.now())
265
+ log(dstOldest != null
266
+ ? `live sync covers everything from ${iso(state.ceiling)} — backfilling only what is older`
267
+ : 'destination is empty — backfilling everything up to now')
268
+ save()
269
+ }
270
+ let cursor = state.ceiling // newest-first: recent history lands first
190
271
  let errStreak = 0
191
272
  let didWork = 0
192
273
 
@@ -223,6 +304,16 @@ function createBackfill (app, options) {
223
304
  }
224
305
 
225
306
  if (!stopped && cursor < floor) {
307
+ if (state.points === 0) {
308
+ // A walk that finishes having copied nothing is far more likely to be a wrong
309
+ // org/bucket, or a filter that matched no rows, than a genuinely empty
310
+ // archive. Marking it complete would dress a silent no-op as success and stop
311
+ // it ever retrying.
312
+ save()
313
+ warn(`walked ${Object.keys(state.done).length} window(s) and copied ZERO points — check that org "${cfg.src.org}" / bucket "${cfg.src.bucket}" is right, and that its data belongs to this boat. NOT marking complete.`)
314
+ statusLine = `backfill: finished with 0 points — check ${cfg.src.org}/${cfg.src.bucket}`
315
+ return
316
+ }
226
317
  state.complete = true
227
318
  save()
228
319
  statusLine = `backfill: complete — ${state.points} point(s) from ${iso(floor).slice(0, 10)}`
@@ -242,7 +333,7 @@ function createBackfill (app, options) {
242
333
  if (state.complete) { statusLine = `backfill: complete — ${state.points} point(s)`; return null }
243
334
  if (!cfg.src.token || !cfg.src.bucket || !cfg.src.url) { statusLine = 'backfill: not configured (source)'; return null }
244
335
  if (!cfg.dst.token || !cfg.dst.bucket) { statusLine = 'backfill: not configured (cloud token)'; return null }
245
- log(`${cfg.src.url} ${cfg.src.org}/${cfg.src.bucket} -> ${cfg.dst.url} ${cfg.dst.org}/${cfg.dst.bucket}${cfg.selfOnly ? ' [self only]' : ''}`)
336
+ log(`${cfg.src.url} ${cfg.src.org}/${cfg.src.bucket} -> ${cfg.dst.url} ${cfg.dst.org}/${cfg.dst.bucket}`)
246
337
  statusLine = 'backfill: starting'
247
338
  runPromise = run().catch((e) => { warn('run failed: ' + e.message); statusLine = 'backfill: error — ' + e.message })
248
339
  return runPromise
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
5
5
  "main": "index.js",
6
6
  "scripts": {