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 +24 -3
- package/index.js +4 -1
- package/lib/backfill/index.js +101 -10
- package/package.json +1 -1
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.
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
package/lib/backfill/index.js
CHANGED
|
@@ -29,7 +29,13 @@ const HOUR_MS = 3600000
|
|
|
29
29
|
const DEFAULTS = {
|
|
30
30
|
windowMs: HOUR_MS,
|
|
31
31
|
batchSize: 10000,
|
|
32
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}
|
|
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.
|
|
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": {
|