dsh-usage-badge 0.1.5 → 0.1.6
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 +1 -1
- package/docs/design-notes.md +47 -4
- package/lib/cache-store.js +113 -14
- package/lib/client.js +132 -11
- package/lib/index.js +87 -21
- package/lib/sync.js +288 -57
- package/lib/webdav.js +67 -15
- package/package.json +1 -1
package/lib/sync.js
CHANGED
|
@@ -104,20 +104,27 @@ export const safeShardKey = (key) => {
|
|
|
104
104
|
/**
|
|
105
105
|
* One device's local rows grouped into month shards.
|
|
106
106
|
*
|
|
107
|
-
* Only `current` rows
|
|
108
|
-
* test can exercise the filter without pretending to be another device). Each row is copied out with
|
|
109
|
-
*
|
|
110
|
-
*
|
|
107
|
+
* Only this installation's own `current` rows are exported (`owns` selects them; it is a parameter so a
|
|
108
|
+
* test can exercise the filter without pretending to be another device). Each row is copied out with its
|
|
109
|
+
* `sessionId` attached, because a shard has to be able to rebuild a session record on the other side.
|
|
110
|
+
*
|
|
111
|
+
* `owns` is usually `'local'`, but it may be a **set** of names: after a rename, a device pulls its own
|
|
112
|
+
* previous directory and imports those rows under the old name, and they are still this installation's
|
|
113
|
+
* history. Leaving them out means the device counts them (they resolve like any other row) while never
|
|
114
|
+
* publishing them, so a fresh machine sees less than its owner does — 18766 against 10000 in the case this
|
|
115
|
+
* was found with. A name only gets here after being checked against this installation's `instanceId`, so
|
|
116
|
+
* another machine's rows are never claimed.
|
|
111
117
|
*
|
|
112
118
|
* @param {object[]} records - records as `store.scan()` returns them, each with a `rows` array.
|
|
113
|
-
* @param {{owns?:string}} [options]
|
|
119
|
+
* @param {{owns?:string|string[]}} [options]
|
|
114
120
|
* @returns {Map<string, object[]>} shard key → rows, sorted by date then session.
|
|
115
121
|
*/
|
|
116
122
|
export function chunkRows(records, { owns = LOCAL_SOURCE } = {}) {
|
|
123
|
+
const owners = new Set(Array.isArray(owns) ? owns : [owns])
|
|
117
124
|
const shards = new Map()
|
|
118
125
|
for (const record of records ?? []) {
|
|
119
126
|
for (const row of record?.rows ?? []) {
|
|
120
|
-
if (rowSource(row)
|
|
127
|
+
if (!owners.has(rowSource(row))) continue
|
|
121
128
|
if (rowKind(row) === FALLBACK) continue
|
|
122
129
|
const key = monthOf(row.date)
|
|
123
130
|
if (!/^\d{4}-\d{2}$/.test(key)) continue
|
|
@@ -160,14 +167,18 @@ export function chunkRows(records, { owns = LOCAL_SOURCE } = {}) {
|
|
|
160
167
|
* mean "device `<id>` published it". Carrying `local` across would make the receiver treat
|
|
161
168
|
* another machine's history as **its own** — it would then publish it onward under its own name,
|
|
162
169
|
* and a later fold here could delete rows it does not own.
|
|
170
|
+
*
|
|
171
|
+
* For the same reason every **other** name of this installation is rewritten too: a device that has
|
|
172
|
+
* been renamed re-publishes rows it once imported under its old name, and they are published *now* by
|
|
173
|
+
* this name. Leaving the old name in them would put a source in the receiver's store that no directory
|
|
174
|
+
* on the server answers for.
|
|
163
175
|
*/
|
|
164
176
|
export function encodeShard(rows, { device }) {
|
|
165
177
|
const id = safeDeviceId(device)
|
|
166
178
|
const header = JSON.stringify({ v: SHARD_VERSION, device: id })
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
].join('\n')
|
|
179
|
+
// Everything in a shard was published by the device whose name is on it: `chunkRows` only ever hands over
|
|
180
|
+
// this installation's own rows, so the source is stamped rather than inherited.
|
|
181
|
+
const body = [header, ...rows.map((row) => JSON.stringify({ ...row, source: id }))].join('\n')
|
|
171
182
|
const compressed = zstdCompressSync(Buffer.from(body, 'utf8'))
|
|
172
183
|
return { body: compressed, sha256: sha256(compressed), rows: rows.length }
|
|
173
184
|
}
|
|
@@ -245,12 +256,17 @@ export function chunkEntry({ device, key, body, rows, at }) {
|
|
|
245
256
|
}
|
|
246
257
|
}
|
|
247
258
|
|
|
248
|
-
/** A device's manifest: what this device has published. */
|
|
249
|
-
export function buildManifest({ device, label = '', chunks = {}, at = Date.now(), instanceId = '' }) {
|
|
259
|
+
/** A device's manifest: what this device has published, and what it has withdrawn. */
|
|
260
|
+
export function buildManifest({ device, label = '', chunks = {}, at = Date.now(), instanceId = '', retired = {} }) {
|
|
250
261
|
// `instanceId` is written so that the *other* installations can tell whether the name in this
|
|
251
262
|
// directory is theirs. Two machines that both call themselves `office` would otherwise each overwrite
|
|
252
263
|
// the other's month — the shard path is the same — and the rows would be gone with no warning at all.
|
|
253
|
-
|
|
264
|
+
//
|
|
265
|
+
// `retired` is the **tombstone**: months this device published and no longer holds any of. A month that
|
|
266
|
+
// simply disappears from `chunks` is not enough, because a reader that already imported it has no way to
|
|
267
|
+
// learn that it is gone — it would keep those rows forever and the two machines would disagree for good.
|
|
268
|
+
// An entry is dropped as soon as the month comes back, so a restored log un-retires it naturally.
|
|
269
|
+
return { v: MANIFEST_VERSION, device, label, generatedAt: at, instanceId, chunks, retired }
|
|
254
270
|
}
|
|
255
271
|
|
|
256
272
|
/** Parse a manifest, refusing a version this build does not write and a device id it cannot use. */
|
|
@@ -279,7 +295,14 @@ export function parseManifest(text) {
|
|
|
279
295
|
at: Number(entry.at) || 0,
|
|
280
296
|
}
|
|
281
297
|
}
|
|
282
|
-
|
|
298
|
+
// The tombstones travel with the manifest. Only the key and when it was withdrawn are needed; a reader
|
|
299
|
+
// compares them against what it holds, so an entry for a month it never imported costs nothing.
|
|
300
|
+
const retired = {}
|
|
301
|
+
for (const [key, entry] of Object.entries(raw.retired ?? {})) {
|
|
302
|
+
if (!/^\d{4}-\d{2}$/.test(key)) continue
|
|
303
|
+
retired[key] = { at: Number(entry?.at) || 0, sha256: String(entry?.sha256 ?? '') }
|
|
304
|
+
}
|
|
305
|
+
return { version: raw.v, device, label: String(raw.label ?? ''), generatedAt: Number(raw.generatedAt) || 0, instanceId: String(raw.instanceId ?? ''), chunks, retired }
|
|
283
306
|
}
|
|
284
307
|
|
|
285
308
|
/**
|
|
@@ -311,7 +334,11 @@ export function planUpload(shards, previous = {}, { device, label = '', at = Dat
|
|
|
311
334
|
chunks[key] = entry
|
|
312
335
|
pending.push({ key, body, entry, rows: rows.length })
|
|
313
336
|
}
|
|
314
|
-
|
|
337
|
+
// A month that is published again is no longer withdrawn, so its tombstone goes: otherwise a reader that
|
|
338
|
+
// imported the month before it was withdrawn would keep dropping it on every run.
|
|
339
|
+
const retired = { ...(previous?.retired ?? {}) }
|
|
340
|
+
for (const key of Object.keys(chunks)) delete retired[key]
|
|
341
|
+
return { pending, unchanged, manifest: buildManifest({ device: safeDevice, label, chunks, at, instanceId, retired }) }
|
|
315
342
|
}
|
|
316
343
|
|
|
317
344
|
/**
|
|
@@ -323,8 +350,9 @@ export function planUpload(shards, previous = {}, { device, label = '', at = Dat
|
|
|
323
350
|
* manifest still lists everything it has.
|
|
324
351
|
*
|
|
325
352
|
* @param {{manifests:object[], held?:object, now?:number, windowYears?:number, except?:string}} options
|
|
326
|
-
* `held` maps `<device>/<key>` to
|
|
327
|
-
* own device id: its own manifest lists what it
|
|
353
|
+
* `held` maps `<device>/<key>` to what this reader already applied — either the sha256 (what older
|
|
354
|
+
* builds wrote) or `{sha, at}`. `except` is the reader's own device id: its own manifest lists what it
|
|
355
|
+
* published, which is already in its store.
|
|
328
356
|
*/
|
|
329
357
|
export function planDownload({ manifests, held = {}, now = Date.now(), windowYears = 5, except = '' }) {
|
|
330
358
|
const cutoff = `${new Date(now).getFullYear() - Math.max(0, windowYears) + 1}-01`
|
|
@@ -334,7 +362,7 @@ export function planDownload({ manifests, held = {}, now = Date.now(), windowYea
|
|
|
334
362
|
for (const [key, entry] of Object.entries(manifest?.chunks ?? {})) {
|
|
335
363
|
if (!entry?.path || !entry?.sha256) continue
|
|
336
364
|
if (windowYears > 0 && key < cutoff) continue
|
|
337
|
-
if (held[`${manifest.device}/${key}`] === entry.sha256) continue
|
|
365
|
+
if (heldSha(held[`${manifest.device}/${key}`]) === entry.sha256) continue
|
|
338
366
|
wanted.push({ device: manifest.device, key, entry })
|
|
339
367
|
}
|
|
340
368
|
}
|
|
@@ -342,6 +370,21 @@ export function planDownload({ manifests, held = {}, now = Date.now(), windowYea
|
|
|
342
370
|
return wanted
|
|
343
371
|
}
|
|
344
372
|
|
|
373
|
+
/**
|
|
374
|
+
* What a `held` entry says was applied, whichever shape it is in.
|
|
375
|
+
*
|
|
376
|
+
* Older builds stored the bare sha; this one stores `{sha, at}` so that a shard **written earlier** than
|
|
377
|
+
* what is already held can be recognised as a rollback. A reader that cannot tell the difference would
|
|
378
|
+
* apply a restored old shard over newer rows — and with a replacing import that means deleting them.
|
|
379
|
+
*/
|
|
380
|
+
export function heldEntry(value) {
|
|
381
|
+
if (typeof value === 'string') return value ? { sha: value, at: 0 } : null
|
|
382
|
+
if (value && typeof value === 'object' && typeof value.sha === 'string') return { sha: value.sha, at: Number(value.at) || 0 }
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const heldSha = (value) => heldEntry(value)?.sha ?? null
|
|
387
|
+
|
|
345
388
|
/**
|
|
346
389
|
* Turn decoded shard rows into the records the store's import takes.
|
|
347
390
|
*
|
|
@@ -506,6 +549,28 @@ export function acquireLock(dir, { instanceId, log = () => {} } = {}) {
|
|
|
506
549
|
return { acquired: false, owner: null, reason: `the data directory cannot be created: ${error?.message ?? error}` }
|
|
507
550
|
}
|
|
508
551
|
const path = join(dir, LOCK_FILE)
|
|
552
|
+
const body = { pid: process.pid, startedAt: PROCESS_STARTED_AT, instanceId, hostname: hostname(), at: Date.now() }
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Create the file **only if it is not there**.
|
|
556
|
+
*
|
|
557
|
+
* `wx` is the whole point: the earlier read-then-write had a window in which two processes could both
|
|
558
|
+
* see no lock (or both see a stale one) and both believe they had taken it. Now the kernel decides — one
|
|
559
|
+
* `open` wins, and the loser gets `EEXIST`.
|
|
560
|
+
*/
|
|
561
|
+
const claim = () => {
|
|
562
|
+
try {
|
|
563
|
+
writeFileSync(path, JSON.stringify(body), { flag: 'wx' })
|
|
564
|
+
return { acquired: true, owner: body }
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error?.code !== 'EEXIST') return { acquired: false, owner: null, reason: `could not write ${LOCK_FILE}: ${error?.message ?? error}` }
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const first = claim()
|
|
572
|
+
if (first) return first
|
|
573
|
+
|
|
509
574
|
const existing = readLock(dir)
|
|
510
575
|
if (existing && !lockIsStale(existing)) {
|
|
511
576
|
return {
|
|
@@ -514,14 +579,23 @@ export function acquireLock(dir, { instanceId, log = () => {} } = {}) {
|
|
|
514
579
|
reason: `this data directory is open by another DSH instance (pid ${existing.pid} on ${existing.hostname || 'this host'})`,
|
|
515
580
|
}
|
|
516
581
|
}
|
|
582
|
+
// Stale: remove it and try once more. If another process gets there first, its `wx` wins and ours sees
|
|
583
|
+
// `EEXIST` again — which is exactly the answer we want, and the reason this is safe without a lock file
|
|
584
|
+
// to guard the lock file.
|
|
517
585
|
if (existing) log(`taking over a stale ${LOCK_FILE} left by pid ${existing.pid}`)
|
|
518
|
-
const body = { pid: process.pid, startedAt: PROCESS_STARTED_AT, instanceId, hostname: hostname(), at: Date.now() }
|
|
519
586
|
try {
|
|
520
|
-
|
|
521
|
-
} catch
|
|
522
|
-
|
|
587
|
+
unlinkSync(path)
|
|
588
|
+
} catch {
|
|
589
|
+
// Someone else removed it, or it was never there. Either way the next claim decides.
|
|
590
|
+
}
|
|
591
|
+
const second = claim()
|
|
592
|
+
if (second) return second
|
|
593
|
+
const now = readLock(dir)
|
|
594
|
+
return {
|
|
595
|
+
acquired: false,
|
|
596
|
+
owner: now,
|
|
597
|
+
reason: now ? `this data directory is open by another DSH instance (pid ${now.pid} on ${now.hostname || 'this host'})` : `could not take ${LOCK_FILE}: another process took it first`,
|
|
523
598
|
}
|
|
524
|
-
return { acquired: true, owner: body }
|
|
525
599
|
}
|
|
526
600
|
|
|
527
601
|
/** Release the lock, but only if this process is the one holding it. */
|
|
@@ -646,6 +720,17 @@ export class SyncEngine {
|
|
|
646
720
|
return released
|
|
647
721
|
}
|
|
648
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Ask a run in progress to stop at its next step boundary.
|
|
725
|
+
*
|
|
726
|
+
* Requests already on the wire cannot be recalled, but the remaining phases can be skipped. That matters
|
|
727
|
+
* when the host is unloading: without it a run keeps going after `aggregator.close()`, writing state for
|
|
728
|
+
* a process that is going away and touching a store whose statements have been finalized.
|
|
729
|
+
*/
|
|
730
|
+
abort() {
|
|
731
|
+
this.aborted = true
|
|
732
|
+
}
|
|
733
|
+
|
|
649
734
|
get deviceId() {
|
|
650
735
|
return this.config.deviceId
|
|
651
736
|
}
|
|
@@ -718,7 +803,13 @@ export class SyncEngine {
|
|
|
718
803
|
}
|
|
719
804
|
}
|
|
720
805
|
|
|
721
|
-
/**
|
|
806
|
+
/**
|
|
807
|
+
* Everything the remote knows: the directories that exist, and the manifests that could be read.
|
|
808
|
+
*
|
|
809
|
+
* Both are returned because they answer different questions. A directory whose manifest is missing or
|
|
810
|
+
* unreadable is not "nothing there" — it is a directory whose owner cannot be established, which is
|
|
811
|
+
* exactly when writing into it is most dangerous.
|
|
812
|
+
*/
|
|
722
813
|
async readManifests(client) {
|
|
723
814
|
const devices = await this.discover(client)
|
|
724
815
|
const manifests = []
|
|
@@ -726,7 +817,7 @@ export class SyncEngine {
|
|
|
726
817
|
const manifest = await this.readManifest(client, device)
|
|
727
818
|
if (manifest) manifests.push(manifest)
|
|
728
819
|
}
|
|
729
|
-
return manifests
|
|
820
|
+
return { manifests, devices }
|
|
730
821
|
}
|
|
731
822
|
|
|
732
823
|
/**
|
|
@@ -758,6 +849,9 @@ export class SyncEngine {
|
|
|
758
849
|
let fetched = 0
|
|
759
850
|
const failed = []
|
|
760
851
|
for (const item of wanted) {
|
|
852
|
+
// One shard at a time, and a shard is the natural boundary: stopping here leaves the store consistent
|
|
853
|
+
// and the untouched months to be fetched next run.
|
|
854
|
+
if (this.aborted) break
|
|
761
855
|
let body
|
|
762
856
|
try {
|
|
763
857
|
body = await client.get(item.entry.path)
|
|
@@ -782,16 +876,64 @@ export class SyncEngine {
|
|
|
782
876
|
failed.push({ path: item.entry.path, error: 'the shard does not match the hash its manifest advertises' })
|
|
783
877
|
continue
|
|
784
878
|
}
|
|
879
|
+
// A shard that does not decode into every row its manifest advertises is **not** remembered as
|
|
880
|
+
// applied. The good rows are still imported (that is idempotent), but advancing `held` would mark the
|
|
881
|
+
// month done forever while some of it was silently dropped — the row count and the manifest disagree,
|
|
882
|
+
// and the honest answer is to keep asking. This build never writes such a shard; an older one or a
|
|
883
|
+
// hand-made file can.
|
|
884
|
+
if (decoded.malformed > 0 || (item.entry.rows > 0 && decoded.rows.length !== item.entry.rows)) {
|
|
885
|
+
this.store?.intakeRows(decoded.rows)
|
|
886
|
+
failed.push({
|
|
887
|
+
path: item.entry.path,
|
|
888
|
+
error: `the shard holds ${decoded.rows.length} of the ${item.entry.rows || decoded.rows.length} rows its manifest names` + (decoded.malformed ? ` (${decoded.malformed} unreadable line(s))` : ''),
|
|
889
|
+
})
|
|
890
|
+
continue
|
|
891
|
+
}
|
|
892
|
+
// A shard written **earlier** than the one already applied is a rollback, not news: a restored
|
|
893
|
+
// backup or an older manifest would otherwise delete rows this reader has that the publisher no
|
|
894
|
+
// longer knows about. Skipped rather than applied, and said out loud.
|
|
895
|
+
const previous = heldEntry(this.state.held?.[`${item.device}/${item.key}`])
|
|
896
|
+
if (previous && item.entry.at && previous.at && previous.at > item.entry.at) {
|
|
897
|
+
this.log(`keeping ${item.device}/${item.key}: the server's copy is older than the one already applied`)
|
|
898
|
+
continue
|
|
899
|
+
}
|
|
785
900
|
if (this.store) {
|
|
786
|
-
this.
|
|
901
|
+
// `replace`: the month is this device's own statement about it, so rows it no longer lists go.
|
|
902
|
+
// Without that, a publisher that dropped a row keeps every reader counting it forever.
|
|
903
|
+
this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
|
|
787
904
|
// `held` is only advanced once the rows are in the store: if the import throws, the shard
|
|
788
905
|
// must be fetched again rather than remembered as applied.
|
|
789
|
-
this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: item.entry.sha256 }
|
|
906
|
+
this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: { sha: item.entry.sha256, at: Number(item.entry.at) || 0 } }
|
|
790
907
|
}
|
|
791
908
|
rows += decoded.rows.length
|
|
792
909
|
fetched++
|
|
793
910
|
}
|
|
794
|
-
|
|
911
|
+
|
|
912
|
+
// ── what the server says is gone ─────────────────────────────────────────
|
|
913
|
+
//
|
|
914
|
+
// A month that vanished from a manifest used to be invisible to a reader that had already imported
|
|
915
|
+
// it: `intakeRows` only adds, so the reader kept rows its publisher had dropped and the two machines
|
|
916
|
+
// disagreed for good. A device that withdraws a month now leaves a tombstone, and this is the other
|
|
917
|
+
// half — drop what came from that device for that month.
|
|
918
|
+
let dropped = 0
|
|
919
|
+
const droppedKeys = []
|
|
920
|
+
for (const manifest of manifests ?? []) {
|
|
921
|
+
const device = manifest?.device
|
|
922
|
+
if (!device || (this.pullOwnDirectory ? false : device === this.deviceId)) continue
|
|
923
|
+
for (const [key, entry] of Object.entries(manifest?.retired ?? {})) {
|
|
924
|
+
const context = `${device}/${key}`
|
|
925
|
+
// Only what this reader actually imported: a tombstone for a month it never held is nothing.
|
|
926
|
+
if (!this.state.held?.[context]) continue
|
|
927
|
+
const removed = this.store?.dropSourceMonth(device, key) ?? 0
|
|
928
|
+
const next = { ...(this.state.held ?? {}) }
|
|
929
|
+
delete next[context]
|
|
930
|
+
this.state.held = next
|
|
931
|
+
dropped += removed
|
|
932
|
+
droppedKeys.push(context)
|
|
933
|
+
this.log(`dropped ${removed} row(s) from ${context}: the device that published it no longer holds that month (withdrawn ${entry.at ? new Date(entry.at).toISOString().slice(0, 10) : 'at some point'})`)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return { fetched, rows, failed, dropped, droppedKeys }
|
|
795
937
|
}
|
|
796
938
|
|
|
797
939
|
/**
|
|
@@ -810,7 +952,38 @@ export class SyncEngine {
|
|
|
810
952
|
// The owner is recorded in the directory's own manifest. A directory with no recorded owner is one
|
|
811
953
|
// published by a build that did not write the field (or the first publish of anything), and adopting
|
|
812
954
|
// it is what makes an upgrade work; an owner that is a *different* installation is a real clash.
|
|
813
|
-
const
|
|
955
|
+
const { manifests: discovered, devices: directories } = await this.readManifests(client)
|
|
956
|
+
const remote = discovered.find((manifest) => manifest.device === this.deviceId)
|
|
957
|
+
// A directory that exists but whose manifest cannot be read is not "nothing there": nobody knows whose
|
|
958
|
+
// it is. Writing into it because the owner check had nothing to look at is the one shape of this bug
|
|
959
|
+
// the instanceId guard cannot see, so an **unreadable** manifest counts as occupied — unless this
|
|
960
|
+
// device has published here before, in which case it is this device's own torn manifest and repairing
|
|
961
|
+
// it is the whole point of the next publish.
|
|
962
|
+
const unknownHere = directories.includes(this.deviceId) && !remote && Object.keys(this.state.published ?? {}).length === 0
|
|
963
|
+
if (unknownHere) {
|
|
964
|
+
this.state.collision = { device: this.deviceId, owner: null, kind: 'unreadable', at: this.now() }
|
|
965
|
+
this.log(
|
|
966
|
+
`not publishing: usage-badge/${this.deviceId}/ exists but its manifest could not be read, so this device cannot tell whose it is. ` +
|
|
967
|
+
`Pick a device name that is not taken, or remove that directory on the server if it is yours.`,
|
|
968
|
+
)
|
|
969
|
+
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'unreadable' }
|
|
970
|
+
}
|
|
971
|
+
// Two directories whose names differ only in case land in **one** directory on a case-insensitive
|
|
972
|
+
// server (Windows/SMB-backed DAV), where the owner check by exact name would never fire. Refused rather
|
|
973
|
+
// than merged: on the target server (Linux Apache) they are genuinely two, and a name that differs only
|
|
974
|
+
// in case is never what someone means.
|
|
975
|
+
const caseTwin = discovered.find((manifest) => manifest.device !== this.deviceId && manifest.device.toLowerCase() === String(this.deviceId).toLowerCase())
|
|
976
|
+
if (caseTwin) {
|
|
977
|
+
this.state.collision = { device: this.deviceId, owner: caseTwin.instanceId || 'unknown', kind: 'case', at: this.now() }
|
|
978
|
+
this.log(`not publishing: usage-badge/${caseTwin.device}/ differs from "${this.deviceId}" only in case, which is one directory on a case-insensitive server`)
|
|
979
|
+
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'case' }
|
|
980
|
+
}
|
|
981
|
+
// **This installation**, not this name. A rename leaves the previous directory behind with its own
|
|
982
|
+
// manifest, and every reader treats every manifest it finds as authoritative — so a month withdrawn
|
|
983
|
+
// after a rename would come straight back through the old name. The `instanceId` is what identifies the
|
|
984
|
+
// installation across names; two installations never share one, and a manifest without one is not ours
|
|
985
|
+
// to speak for.
|
|
986
|
+
const mine = this.instanceId ? discovered.filter((manifest) => manifest.instanceId === this.instanceId) : []
|
|
814
987
|
if (remote && remote.instanceId && remote.instanceId !== this.instanceId) {
|
|
815
988
|
this.state.collision = { device: this.deviceId, owner: remote.instanceId, at: this.now() }
|
|
816
989
|
this.log(
|
|
@@ -830,11 +1003,12 @@ export class SyncEngine {
|
|
|
830
1003
|
}
|
|
831
1004
|
delete this.state.collision
|
|
832
1005
|
|
|
833
|
-
const rows = chunkRows(this.store.scan())
|
|
1006
|
+
const rows = chunkRows(this.store.scan(), { owns: [LOCAL_SOURCE, this.deviceId, ...mine.map((manifest) => manifest.device)] })
|
|
834
1007
|
// `state.published` is the manifest's **chunks map**, so it is wrapped back into a manifest shape
|
|
835
1008
|
// here. Passing the map itself would leave `previous.chunks` empty, and every run would re-upload
|
|
836
|
-
// every month while looking perfectly healthy.
|
|
837
|
-
|
|
1009
|
+
// every month while looking perfectly healthy. `retired` rides along for the same reason: it is the
|
|
1010
|
+
// record of withdrawals a reader needs in order to drop what it once imported.
|
|
1011
|
+
const { pending, unchanged, manifest } = planUpload(rows, { chunks: this.state.published ?? {}, retired: this.state.retired ?? {} }, {
|
|
838
1012
|
device: this.deviceId,
|
|
839
1013
|
label: this.config.deviceLabel ?? '',
|
|
840
1014
|
at: this.now(),
|
|
@@ -861,13 +1035,6 @@ export class SyncEngine {
|
|
|
861
1035
|
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'diverged' }
|
|
862
1036
|
}
|
|
863
1037
|
|
|
864
|
-
// A month this device published and no longer has any rows for: a session folder was deleted, or a
|
|
865
|
-
// log version that no longer covers that day superseded the rows. The manifest is republished from
|
|
866
|
-
// what is actually held, so **new** devices must stop being told about it — otherwise they import
|
|
867
|
-
// usage its own machine no longer has, which is precisely the "the numbers came back" surprise. The
|
|
868
|
-
// bytes stay on the server; only the claim that this device still holds them is withdrawn.
|
|
869
|
-
const retired = Object.keys(this.state.published ?? {}).filter((key) => !rows.has(key))
|
|
870
|
-
|
|
871
1038
|
// The collection comes **before** anything is written into it. `mod_dav_fs` refuses a write whose
|
|
872
1039
|
// parent does not exist (403/409, not the 404 an HTTP client might expect), so creating it on the
|
|
873
1040
|
// way past would fail the first publish of a device — and mkcol is idempotent anyway, so it costs
|
|
@@ -876,6 +1043,9 @@ export class SyncEngine {
|
|
|
876
1043
|
let bytes = 0
|
|
877
1044
|
const shards = []
|
|
878
1045
|
for (const item of pending) {
|
|
1046
|
+
// Same boundary as the pull: an upload already on the wire cannot be recalled, but the rest of the
|
|
1047
|
+
// list can wait for a run that will still have a store to write to.
|
|
1048
|
+
if (this.aborted) break
|
|
879
1049
|
// Upload under a temporary name and rename: a crash mid-upload leaves a name nobody reads,
|
|
880
1050
|
// instead of a shard that looks complete and is half a month.
|
|
881
1051
|
const temp = `${item.entry.path}.upload-${process.pid}-${Math.random().toString(16).slice(2, 8)}`
|
|
@@ -894,27 +1064,70 @@ export class SyncEngine {
|
|
|
894
1064
|
// **last** on purpose: a manifest naming a shard that has not landed yet would send every other
|
|
895
1065
|
// device after a 404 on their next run.
|
|
896
1066
|
await client.put(`${REMOTE_PREFIX}${this.deviceId}/${DEVICE_MARKER}`, Buffer.from(`${this.deviceId}\n`))
|
|
1067
|
+
// ── what every other name of this installation must stop claiming ──────────
|
|
1068
|
+
//
|
|
1069
|
+
// A rename leaves the previous directory behind, and **every reader treats every manifest it finds as
|
|
1070
|
+
// authoritative**. So a month that was corrected or dropped after a rename keeps feeding readers its old
|
|
1071
|
+
// rows through the old name: the fix is that this installation's own months live in exactly one
|
|
1072
|
+
// directory, and every other directory of ours is brought in line with what we actually hold.
|
|
1073
|
+
//
|
|
1074
|
+
// The comparison is by content hash, not by month: a month can stay while one of its rows goes away
|
|
1075
|
+
// (a log version that no longer covers that day), and that difference is invisible to a key-vs-key test.
|
|
1076
|
+
// A key we no longer publish at all is simply missing from the current chunks, which is the same test.
|
|
1077
|
+
//
|
|
1078
|
+
// The current name's manifest is the one `planUpload` built; the others are rebuilt through
|
|
1079
|
+
// `buildManifest` — **not** written back as parsed. A parsed manifest carries `version` while the file
|
|
1080
|
+
// format is `v`, so writing one back produces a manifest no reader can parse, which would hide that
|
|
1081
|
+
// directory's shards from everyone instead of correcting them.
|
|
1082
|
+
const targets = new Map([[this.deviceId, manifest]])
|
|
1083
|
+
for (const other of mine) {
|
|
1084
|
+
if (targets.has(other.device)) continue
|
|
1085
|
+
targets.set(other.device, buildManifest({ device: other.device, label: other.label, chunks: { ...other.chunks }, at: other.generatedAt || this.now(), instanceId: other.instanceId, retired: { ...(other.retired ?? {}) } }))
|
|
1086
|
+
}
|
|
1087
|
+
const withdrawals = []
|
|
1088
|
+
for (const [key, entry] of Object.entries(manifest.chunks)) {
|
|
1089
|
+
if (rows.has(key)) continue
|
|
1090
|
+
withdrawals.push({ key, device: this.deviceId, manifest, path: entry.path ?? shardPath(this.deviceId, key) })
|
|
1091
|
+
}
|
|
1092
|
+
for (const [device, target] of targets) {
|
|
1093
|
+
if (device === this.deviceId) continue
|
|
1094
|
+
for (const [key, entry] of Object.entries(target.chunks)) {
|
|
1095
|
+
const current = manifest.chunks[key]
|
|
1096
|
+
if (current && current.sha256 === entry.sha256) continue
|
|
1097
|
+
withdrawals.push({ key, device, manifest: target, path: entry.path ?? shardPath(device, key) })
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
// **Delete first, then write.** Each deletion is recorded, and a month whose shard could not be removed
|
|
1101
|
+
// stays in the manifest that lists it — so the claim and the bytes never disagree, and the failure is
|
|
1102
|
+
// retried on the next run instead of being half-recorded.
|
|
897
1103
|
let withdrawn = 0
|
|
898
1104
|
const withdrawnKeys = []
|
|
899
|
-
|
|
1105
|
+
const withdrawErrors = []
|
|
1106
|
+
for (const item of withdrawals) {
|
|
900
1107
|
try {
|
|
901
1108
|
// Gone from the manifest *and* from the server: leaving the bytes behind while dropping the claim
|
|
902
|
-
// would
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
delete manifest.chunks[key]
|
|
1109
|
+
// would leave a shard nobody lists. These shards are this installation's own, which is the one
|
|
1110
|
+
// direction where deleting is not the remote deciding anything.
|
|
1111
|
+
await client.delete(item.path)
|
|
1112
|
+
const gone = item.manifest.chunks[item.key]
|
|
1113
|
+
delete item.manifest.chunks[item.key]
|
|
1114
|
+
// A **tombstone**, so a device that already imported this month learns that it is gone. Without it
|
|
1115
|
+
// a reader keeps those rows forever — the publisher drops them, and the two machines disagree for
|
|
1116
|
+
// good, in the direction of "everyone else counts more than the machine that has the data".
|
|
1117
|
+
item.manifest.retired = { ...(item.manifest.retired ?? {}), [item.key]: { at: this.now(), sha256: String(gone?.sha256 ?? '') } }
|
|
907
1118
|
withdrawn++
|
|
908
|
-
withdrawnKeys.push(key)
|
|
909
|
-
this.log(`withdrew ${key}
|
|
1119
|
+
if (!withdrawnKeys.includes(item.key)) withdrawnKeys.push(item.key)
|
|
1120
|
+
this.log(`withdrew ${item.key} from ${item.device}/: this device publishes it under ${this.deviceId}/ or no longer holds it`)
|
|
910
1121
|
} catch (error) {
|
|
911
|
-
|
|
912
|
-
// would leave the claim in place while the shard is gone. Reported, and retried next run.
|
|
913
|
-
return { uploaded: pending.length, unchanged, bytes, shards, manifest: null, withdrawn: 0, refused: null, withdrawError: `could not withdraw ${key}: ${error?.message ?? error}` }
|
|
1122
|
+
withdrawErrors.push(`could not withdraw ${item.key} from ${item.device}/: ${error?.message ?? error}`)
|
|
914
1123
|
}
|
|
915
1124
|
}
|
|
916
|
-
|
|
917
|
-
|
|
1125
|
+
// Written even when a withdrawal failed: the manifests now say exactly what the server holds, and the
|
|
1126
|
+
// current one still names every shard that was uploaded above (so `uploaded` is not an exaggeration).
|
|
1127
|
+
for (const [device, target] of targets) {
|
|
1128
|
+
await client.put(manifestPath(device), Buffer.from(JSON.stringify(target, null, 2)), { contentType: 'application/json' })
|
|
1129
|
+
}
|
|
1130
|
+
return { uploaded: pending.length, unchanged, bytes, shards, manifest, withdrawn, withdrawnKeys, withdrawErrors }
|
|
918
1131
|
}
|
|
919
1132
|
|
|
920
1133
|
/**
|
|
@@ -1096,9 +1309,14 @@ export class SyncEngine {
|
|
|
1096
1309
|
} catch (error) {
|
|
1097
1310
|
return this.finish('failed', String(error?.message ?? error), { started })
|
|
1098
1311
|
}
|
|
1312
|
+
// The host can unload while this is in flight. Checked at every phase boundary rather than only at the
|
|
1313
|
+
// start, because the phases are where a run touches the store — and after `aggregator.close()` its
|
|
1314
|
+
// statements are finalized, so continuing is not harmless.
|
|
1315
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1099
1316
|
|
|
1100
1317
|
try {
|
|
1101
|
-
const manifests = await this.readManifests(client)
|
|
1318
|
+
const { manifests } = await this.readManifests(client)
|
|
1319
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1102
1320
|
// **Pull first, refuse only the push.** An installation whose device name is taken still wants the
|
|
1103
1321
|
// history back, and that is the case the feature exists for (reinstall, another machine): the name
|
|
1104
1322
|
// is what is in the way, not the data. So the clash is noted here — which is what tells `pull` to
|
|
@@ -1113,10 +1331,17 @@ export class SyncEngine {
|
|
|
1113
1331
|
this.pullOwnDirectory = Boolean(clash)
|
|
1114
1332
|
const pulled = await this.pull(client, manifests)
|
|
1115
1333
|
this.pullOwnDirectory = false
|
|
1334
|
+
if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started })
|
|
1116
1335
|
const pushed = await this.push(client)
|
|
1117
|
-
if (pushed.manifest)
|
|
1336
|
+
if (pushed.manifest) {
|
|
1337
|
+
this.state.published = pushed.manifest.chunks
|
|
1338
|
+
// The tombstones are state too: the next run has to keep carrying them, or a reader that has not
|
|
1339
|
+
// synced yet would never learn that a month is gone.
|
|
1340
|
+
this.state.retired = pushed.manifest.retired ?? {}
|
|
1341
|
+
}
|
|
1118
1342
|
// The price table last: it is the only hand-written thing here, and it should not be touched by
|
|
1119
1343
|
// a run that failed to move the usage it was about to move.
|
|
1344
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1120
1345
|
const pricing = await this.syncPricing(client)
|
|
1121
1346
|
const parts = [
|
|
1122
1347
|
`pulled ${pulled.fetched} shard(s) / ${pulled.rows} row(s)`,
|
|
@@ -1128,8 +1353,9 @@ export class SyncEngine {
|
|
|
1128
1353
|
// the pull worked and nothing was lost. Naming it is what lets the panel say what to do.
|
|
1129
1354
|
parts.push(`nothing published: device name "${this.deviceId}" is being written by another installation`)
|
|
1130
1355
|
}
|
|
1131
|
-
|
|
1356
|
+
for (const error of pushed.withdrawErrors ?? []) parts.push(error)
|
|
1132
1357
|
if (pushed.withdrawn) parts.push(`withdrew ${pushed.withdrawn} month(s) this device no longer holds`)
|
|
1358
|
+
if (pulled.dropped) parts.push(`dropped ${pulled.dropped} row(s) another device has withdrawn`)
|
|
1133
1359
|
if (pricing.action !== 'none') parts.push(`prices: ${pricing.status}`)
|
|
1134
1360
|
if (pulled.failed.length > 0) parts.push(`${pulled.failed.length} shard(s) failed`)
|
|
1135
1361
|
const message = parts.join(', ')
|
|
@@ -1137,7 +1363,8 @@ export class SyncEngine {
|
|
|
1137
1363
|
// Four outcomes, each meaning a different thing to the person reading the panel: `ok` moved
|
|
1138
1364
|
// everything, `partial` moved almost everything, `clash` deliberately published nothing and needs a
|
|
1139
1365
|
// decision from them, `failed` is a problem with the server or the network.
|
|
1140
|
-
const
|
|
1366
|
+
const incomplete = pulled.failed.length > 0 || (pushed.withdrawErrors?.length ?? 0) > 0
|
|
1367
|
+
const result = pushed.refused ? 'clash' : incomplete ? 'partial' : 'ok'
|
|
1141
1368
|
return this.finish(result, message, {
|
|
1142
1369
|
started,
|
|
1143
1370
|
extra: {
|
|
@@ -1149,6 +1376,7 @@ export class SyncEngine {
|
|
|
1149
1376
|
failed: pulled.failed,
|
|
1150
1377
|
refused: pushed.refused ?? null,
|
|
1151
1378
|
withdrawn: pushed.withdrawn ?? 0,
|
|
1379
|
+
dropped: pulled.dropped ?? 0,
|
|
1152
1380
|
},
|
|
1153
1381
|
})
|
|
1154
1382
|
} catch (error) {
|
|
@@ -1166,7 +1394,10 @@ export class SyncEngine {
|
|
|
1166
1394
|
* — which is exactly how a sync ends up re-importing every shard on every run.
|
|
1167
1395
|
*/
|
|
1168
1396
|
finish(result, message, { started, extra = {} } = {}) {
|
|
1169
|
-
|
|
1397
|
+
// `skipped` (sync turned off between scheduling and running) deliberately leaves the timestamp alone:
|
|
1398
|
+
// nothing was synced, and stamping it would show "上次同步:刚刚" for a run that never happened. `locked`
|
|
1399
|
+
// returns before this, for the same reason.
|
|
1400
|
+
if (result !== 'skipped') this.state.lastSyncAt = this.now()
|
|
1170
1401
|
this.state.lastResult = result
|
|
1171
1402
|
this.state.message = message
|
|
1172
1403
|
try {
|