[TEST] try to improve sync
This commit is contained in:
parent
be439e5421
commit
0f3fbaa418
8 changed files with 374 additions and 145 deletions
|
|
@ -1,8 +1,9 @@
|
|||
'use strict'
|
||||
|
||||
// The pushing side of a LAN sync: per unit, send a manifest, learn what the
|
||||
// receiver has, upload the rest, delete the surplus. Exported as a factory;
|
||||
// src/lansync.js hands in its own helpers and state.
|
||||
// The pushing side of a LAN sync: per unit, stream a manifest while it is
|
||||
// still being hashed, learn what the receiver has, upload the rest, delete
|
||||
// the surplus. Exported as a factory; src/lansync.js hands in its own
|
||||
// helpers and state.
|
||||
|
||||
const http = require('http')
|
||||
const os = require('os')
|
||||
|
|
@ -12,10 +13,22 @@ const path = require('path')
|
|||
// Files sent at once: one at a time wastes LAN round trips on small files.
|
||||
const UPLOAD_CONCURRENCY = 4
|
||||
|
||||
// Two lost heartbeats in a row: the receiver is gone (40 s of silence).
|
||||
const MAX_MISSED_PINGS = 2
|
||||
|
||||
// One file, one PUT. A file that has not finished on its own after this long
|
||||
// (stalled receiver, lost socket while the OS still reports it open, a file
|
||||
// that changed size after the manifest) fails the whole sync instead of
|
||||
// hanging it forever. Env override for tests.
|
||||
const UPLOAD_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_UPLOAD_TIMEOUT) || 10 * 60 * 1000
|
||||
|
||||
// Keeps the receiver alive while this side hashes (minutes of silence); env
|
||||
// override for tests.
|
||||
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
|
||||
|
||||
// One ping round trip; env override for tests.
|
||||
const PING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_PING_TIMEOUT) || 10000
|
||||
|
||||
function createPusher(api) {
|
||||
const {
|
||||
CATEGORIES,
|
||||
|
|
@ -26,7 +39,7 @@ function createPusher(api) {
|
|||
httpJson,
|
||||
filesPath,
|
||||
ownedRoot,
|
||||
buildManifest,
|
||||
buildManifestStream,
|
||||
listDirs,
|
||||
DIRS,
|
||||
toPortablePath,
|
||||
|
|
@ -35,8 +48,10 @@ function createPusher(api) {
|
|||
prefixes
|
||||
} = api
|
||||
|
||||
// Manifest in, upload list out; NDJSON avoids one giant string.
|
||||
function planUnit(device, token, unit, entries, force, meta) {
|
||||
// Manifest in, upload list out; NDJSON avoids one giant string. The body
|
||||
// is written by `produce` WHILE the sender hashes, so the receiver can
|
||||
// compare entries as they arrive (both sides hash at the same time).
|
||||
function planUnit(device, token, unit, force, meta, produce) {
|
||||
// announced up front, so the receiver's popup can say "Apps: 2/5"
|
||||
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -62,23 +77,47 @@ function createPusher(api) {
|
|||
})
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
let index = 0
|
||||
const pump = () => {
|
||||
while (index < entries.length) {
|
||||
if (!req.write(`${JSON.stringify(entries[index++])}\n`)) {
|
||||
req.once('drain', pump)
|
||||
return
|
||||
}
|
||||
let dead = null
|
||||
req.on('error', err => {
|
||||
dead = err
|
||||
reject(err)
|
||||
})
|
||||
// ordered writes with backpressure: the receiver reads continuously,
|
||||
// but a slow socket must not buffer the whole manifest in memory
|
||||
const write = obj => new Promise((resolveWrite, rejectWrite) => {
|
||||
if (dead) return rejectWrite(dead)
|
||||
const onDrain = () => {
|
||||
cleanup()
|
||||
resolveWrite()
|
||||
}
|
||||
req.end()
|
||||
}
|
||||
pump()
|
||||
const onErr = () => {
|
||||
cleanup()
|
||||
rejectWrite(dead || new Error('plan-write-failed'))
|
||||
}
|
||||
const cleanup = () => {
|
||||
req.off('drain', onDrain)
|
||||
req.off('error', onErr)
|
||||
}
|
||||
if (req.write(`${JSON.stringify(obj)}\n`)) {
|
||||
cleanup()
|
||||
return resolveWrite()
|
||||
}
|
||||
req.once('drain', onDrain)
|
||||
req.once('error', onErr)
|
||||
})
|
||||
produce(write).then(() => req.end(), err => {
|
||||
try { req.destroy() } catch {}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function putFile(device, token, unitKey, rel, abs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const settle = (fn, value) => {
|
||||
clearTimeout(timer)
|
||||
fn(value)
|
||||
}
|
||||
// unit and path travel as headers: constant per request
|
||||
const req = http.request(
|
||||
{
|
||||
|
|
@ -96,12 +135,26 @@ function createPusher(api) {
|
|||
},
|
||||
res => {
|
||||
res.resume()
|
||||
res.on('end', () => resolve({ status: res.statusCode }))
|
||||
res.on('end', () => settle(resolve, { status: res.statusCode }))
|
||||
res.on('aborted', () => settle(reject, new Error('response-aborted')))
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
// The timer spans the WHOLE transfer, response included: a stalled
|
||||
// receiver or a silently lost socket must fail the sync instead of
|
||||
// hanging the popup on the last file. Generous - a file would have to
|
||||
// take over ten minutes at 20 MB/s to hit it.
|
||||
const timer = setTimeout(() => {
|
||||
req.destroy(new Error('upload-timeout'))
|
||||
settle(reject, new Error('upload-timeout'))
|
||||
}, UPLOAD_TIMEOUT_MS)
|
||||
if (timer.unref) timer.unref()
|
||||
req.on('error', err => settle(reject, err))
|
||||
const stream = fs.createReadStream(abs)
|
||||
stream.on('error', reject)
|
||||
stream.on('error', err => settle(reject, err))
|
||||
// pipe ends the request when the file ends; a body shorter than the
|
||||
// declared content-length (file changed mid-sync) wedges the receiver
|
||||
// - its own idle watchdog breaks that, this side's timer breaks every
|
||||
// other silent case
|
||||
stream.pipe(req)
|
||||
})
|
||||
}
|
||||
|
|
@ -265,8 +318,9 @@ function createPusher(api) {
|
|||
// units are known before anything is read: announce them with the verify
|
||||
const units = buildUnits(cats)
|
||||
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
||||
const totals = { apps: 0, wines: 0, prefixes: 0 }
|
||||
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
|
||||
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
||||
if (cats.has('settings')) totals.settings = 1
|
||||
// every progress event is kept, so callers can report without subscribing
|
||||
const events = []
|
||||
const progress = data => {
|
||||
|
|
@ -274,32 +328,6 @@ function createPusher(api) {
|
|||
if (onProgress) onProgress(data)
|
||||
}
|
||||
|
||||
let verified
|
||||
try {
|
||||
// longer than the receiver's incoming-request timeout (popup wait)
|
||||
verified = await httpJson(device, 'POST', '/plwl/verify', {
|
||||
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
|
||||
timeout: 90000
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(String((err && err.message) || err))
|
||||
}
|
||||
if (!verified.data || !verified.data.ok) {
|
||||
const error = (verified.data && verified.data.error) || `http-${verified.status}`
|
||||
// a rotated code drops the pairing; a plain 'rejected' keeps it
|
||||
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
||||
return fail(error)
|
||||
}
|
||||
const token = verified.data.token
|
||||
// paired now: the next sync needs no typing
|
||||
rememberDevice(device.id || deviceId, code, device.name)
|
||||
|
||||
// hashing precedes any byte: keep the receiver's popup alive meanwhile
|
||||
const heartbeat = setInterval(() => {
|
||||
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
|
||||
}, HEARTBEAT_MS)
|
||||
if (heartbeat.unref) heartbeat.unref()
|
||||
|
||||
const summary = newSummary(units.length)
|
||||
const finished = { apps: 0, wines: 0, prefixes: 0 }
|
||||
// planned bytes so far, so the popup can show "1.2 GB / 3.4 GB"
|
||||
|
|
@ -341,13 +369,58 @@ function createPusher(api) {
|
|||
// snapshot per file, forward a few times per second (phase changes always)
|
||||
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data)
|
||||
const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
|
||||
// consent may wait on a human: say so before anything else happens
|
||||
emit('wait', null, null, null, true)
|
||||
|
||||
let verified
|
||||
try {
|
||||
// longer than the receiver's incoming-request timeout (popup wait)
|
||||
verified = await httpJson(device, 'POST', '/plwl/verify', {
|
||||
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
|
||||
timeout: 90000
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(String((err && err.message) || err))
|
||||
}
|
||||
if (!verified.data || !verified.data.ok) {
|
||||
const error = (verified.data && verified.data.error) || `http-${verified.status}`
|
||||
// a rotated code drops the pairing; a plain 'rejected' keeps it
|
||||
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
||||
return fail(error)
|
||||
}
|
||||
const token = verified.data.token
|
||||
// paired now: the next sync needs no typing
|
||||
rememberDevice(device.id || deviceId, code, device.name)
|
||||
|
||||
// The heartbeat keeps the receiver's popup alive while this side hashes
|
||||
// (minutes of silence). Consecutive ping failures mean the peer dropped
|
||||
// (crash, sleep, lost network): without this a dead receiver hangs the
|
||||
// sync until the next request notices, which may be never.
|
||||
let missedPings = 0
|
||||
const abort = { dead: false, reject: null }
|
||||
const abortPromise = new Promise((resolve, reject) => { abort.reject = reject })
|
||||
abortPromise.catch(() => {}) // only awaited via Promise.race below
|
||||
const heartbeat = setInterval(() => {
|
||||
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: PING_TIMEOUT_MS })
|
||||
.then(() => { missedPings = 0 })
|
||||
.catch(() => {
|
||||
missedPings++
|
||||
if (missedPings >= MAX_MISSED_PINGS && !abort.dead) {
|
||||
abort.dead = true
|
||||
abort.reject(new Error('receiver-lost'))
|
||||
}
|
||||
})
|
||||
}, HEARTBEAT_MS)
|
||||
if (heartbeat.unref) heartbeat.unref()
|
||||
|
||||
const end = async () => {
|
||||
try {
|
||||
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const runUnits = async () => {
|
||||
for (const unit of units) {
|
||||
if (abort.dead) throw new Error('receiver-lost')
|
||||
const roots = unit.roots.filter(ownedRoot)
|
||||
if (!roots.length) continue
|
||||
const kind = bucketOf(unit.kind)
|
||||
|
|
@ -357,14 +430,36 @@ function createPusher(api) {
|
|||
barBytes = 0
|
||||
barTotal = 0
|
||||
emit('scan', unit, null, null, true)
|
||||
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
|
||||
barBytes = hashedBytes
|
||||
barTotal = totalBytes
|
||||
// no file count until the plan returns
|
||||
emit('hash', unit, null, null)
|
||||
// The manifest streams WHILE this side hashes its tail: the receiver
|
||||
// compares entries as they arrive, so both devices hash at the same
|
||||
// time. Once this side is done hashing, the popup keeps a full bar
|
||||
// and the waiting text until the plan answers (a cached sender waits
|
||||
// for a receiver that still has to hash).
|
||||
const entries = []
|
||||
let writes = Promise.resolve()
|
||||
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
|
||||
await buildManifestStream(roots, !!force, {
|
||||
// the receiver sizes its compare bar from this, before any hashing
|
||||
onTotal: totalBytes => {
|
||||
writes = writes.then(() => write({ unitBytes: totalBytes }))
|
||||
},
|
||||
onEntry: entry => {
|
||||
entries.push(entry)
|
||||
writes = writes.then(() => write(entry))
|
||||
},
|
||||
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
|
||||
barBytes = hashedBytes
|
||||
barTotal = totalBytes
|
||||
emit('hash', unit, null, null)
|
||||
}
|
||||
})
|
||||
await writes
|
||||
// this side is done hashing; the receiver may still be comparing
|
||||
barTotal = barTotal || 1
|
||||
barBytes = barTotal
|
||||
emit('wait', unit, null, null, true)
|
||||
})
|
||||
emit('plan', unit, null, null, true)
|
||||
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force, { totals })
|
||||
if (abort.dead) throw new Error('receiver-lost')
|
||||
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
||||
const upload = new Set(plan.data.upload || [])
|
||||
const same = new Set(plan.data.same || [])
|
||||
|
|
@ -383,32 +478,32 @@ function createPusher(api) {
|
|||
pending.push(entry)
|
||||
plannedBytes += entry.size || 0
|
||||
}
|
||||
// a few files in flight at once: small files cost round trips
|
||||
// the bar counts only bytes that move, and starts empty for the copy
|
||||
// a few files in flight at once: small files cost round trips; the
|
||||
// bar counts only bytes that move and starts empty for the copy
|
||||
if (pending.length) {
|
||||
barBytes = 0
|
||||
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||
emit('copy', unit, allDone, allTotal + pending.length, true)
|
||||
}
|
||||
let failed = null
|
||||
let done = 0
|
||||
const send = async entry => {
|
||||
if (failed) return
|
||||
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path))
|
||||
if (res.status !== 200) {
|
||||
failed = new Error(`upload-failed-${res.status}`)
|
||||
return
|
||||
let failed = null
|
||||
let done = 0
|
||||
const send = async entry => {
|
||||
if (failed || abort.dead) return
|
||||
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path))
|
||||
if (res.status !== 200) {
|
||||
failed = new Error(`upload-failed-${res.status}`)
|
||||
return
|
||||
}
|
||||
done++
|
||||
summary.uploaded++
|
||||
summary.bytes += entry.size || 0
|
||||
barBytes += entry.size || 0
|
||||
emit('copy', unit, allDone + done, allTotal + pending.length)
|
||||
}
|
||||
done++
|
||||
summary.uploaded++
|
||||
summary.bytes += entry.size || 0
|
||||
barBytes += entry.size || 0
|
||||
emit('copy', unit, allDone + done, allTotal + pending.length)
|
||||
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
||||
else for (const entry of pending) await send(entry)
|
||||
if (failed) throw failed
|
||||
}
|
||||
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
||||
else for (const entry of pending) await send(entry)
|
||||
if (failed) throw failed
|
||||
emit('copy', unit, allDone + done, allTotal + pending.length, true)
|
||||
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
|
||||
// the totals count a unit's files only once they are on the wire
|
||||
allDone += pending.length
|
||||
allTotal += pending.length
|
||||
|
|
@ -431,9 +526,16 @@ function createPusher(api) {
|
|||
if (deleted) summary[key].deleted += deleted
|
||||
}
|
||||
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
||||
// a sync with nothing to send still ends with a full bar
|
||||
barTotal = barTotal || 1
|
||||
barBytes = barTotal
|
||||
emit('done', null, allDone, allTotal, true)
|
||||
await end()
|
||||
}
|
||||
try {
|
||||
await Promise.race([runUnits(), abortPromise])
|
||||
} catch (err) {
|
||||
abort.dead = true
|
||||
await end()
|
||||
return fail(String((err && err.message) || err), summary, events)
|
||||
} finally {
|
||||
|
|
@ -442,7 +544,9 @@ function createPusher(api) {
|
|||
return syncResult(true, null, summary, events)
|
||||
}
|
||||
|
||||
return { syncToDevice, buildUnits, buildRegistryPayload, CATEGORIES }
|
||||
// putFile is exposed for the stall tests (it is the piece that must never
|
||||
// leave a request half-open)
|
||||
return { syncToDevice, buildUnits, buildRegistryPayload, putFile, CATEGORIES }
|
||||
}
|
||||
|
||||
module.exports = createPusher
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue