dsh-usage-badge 0.1.5 → 0.1.7

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/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 this device folded 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
109
- * its `sessionId` attached, because a shard has to be able to rebuild a session record on the other
110
- * side.
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) !== owns) continue
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
- const body = [
168
- header,
169
- ...rows.map((row) => JSON.stringify({ ...row, source: rowSource(row) === LOCAL_SOURCE ? id : rowSource(row) })),
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
- return { v: MANIFEST_VERSION, device, label, generatedAt: at, instanceId, chunks }
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
- return { version: raw.v, device, label: String(raw.label ?? ''), generatedAt: Number(raw.generatedAt) || 0, instanceId: String(raw.instanceId ?? ''), chunks }
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
- return { pending, unchanged, manifest: buildManifest({ device: safeDevice, label, chunks, at, instanceId }) }
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 the sha256 this reader already applied. `except` is the reader's
327
- * own device id: its own manifest lists what it published, which is already in its store.
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
- writeFileSync(path, JSON.stringify(body))
521
- } catch (error) {
522
- return { acquired: false, owner: null, reason: `could not write ${LOCK_FILE}: ${error?.message ?? error}` }
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. */
@@ -553,6 +627,10 @@ export const defaultSyncConfig = () => ({
553
627
  deviceLabel: hostname(),
554
628
  instanceId: '',
555
629
  windowYears: 5,
630
+ // The two automatic triggers, both on by default: a backup nobody remembers to run is not a backup. Both
631
+ // are switchable, because a sync that holds up quitting is worse than one that happens a minute later.
632
+ onStart: true,
633
+ onExit: true,
556
634
  })
557
635
 
558
636
  /** Read `sync.json`, or a default document when it is missing or unreadable. */
@@ -646,6 +724,17 @@ export class SyncEngine {
646
724
  return released
647
725
  }
648
726
 
727
+ /**
728
+ * Ask a run in progress to stop at its next step boundary.
729
+ *
730
+ * Requests already on the wire cannot be recalled, but the remaining phases can be skipped. That matters
731
+ * when the host is unloading: without it a run keeps going after `aggregator.close()`, writing state for
732
+ * a process that is going away and touching a store whose statements have been finalized.
733
+ */
734
+ abort() {
735
+ this.aborted = true
736
+ }
737
+
649
738
  get deviceId() {
650
739
  return this.config.deviceId
651
740
  }
@@ -718,7 +807,13 @@ export class SyncEngine {
718
807
  }
719
808
  }
720
809
 
721
- /** Everything the remote knows, as manifests. */
810
+ /**
811
+ * Everything the remote knows: the directories that exist, and the manifests that could be read.
812
+ *
813
+ * Both are returned because they answer different questions. A directory whose manifest is missing or
814
+ * unreadable is not "nothing there" — it is a directory whose owner cannot be established, which is
815
+ * exactly when writing into it is most dangerous.
816
+ */
722
817
  async readManifests(client) {
723
818
  const devices = await this.discover(client)
724
819
  const manifests = []
@@ -726,7 +821,7 @@ export class SyncEngine {
726
821
  const manifest = await this.readManifest(client, device)
727
822
  if (manifest) manifests.push(manifest)
728
823
  }
729
- return manifests
824
+ return { manifests, devices }
730
825
  }
731
826
 
732
827
  /**
@@ -758,6 +853,9 @@ export class SyncEngine {
758
853
  let fetched = 0
759
854
  const failed = []
760
855
  for (const item of wanted) {
856
+ // One shard at a time, and a shard is the natural boundary: stopping here leaves the store consistent
857
+ // and the untouched months to be fetched next run.
858
+ if (this.aborted) break
761
859
  let body
762
860
  try {
763
861
  body = await client.get(item.entry.path)
@@ -782,16 +880,64 @@ export class SyncEngine {
782
880
  failed.push({ path: item.entry.path, error: 'the shard does not match the hash its manifest advertises' })
783
881
  continue
784
882
  }
883
+ // A shard that does not decode into every row its manifest advertises is **not** remembered as
884
+ // applied. The good rows are still imported (that is idempotent), but advancing `held` would mark the
885
+ // month done forever while some of it was silently dropped — the row count and the manifest disagree,
886
+ // and the honest answer is to keep asking. This build never writes such a shard; an older one or a
887
+ // hand-made file can.
888
+ if (decoded.malformed > 0 || (item.entry.rows > 0 && decoded.rows.length !== item.entry.rows)) {
889
+ this.store?.intakeRows(decoded.rows)
890
+ failed.push({
891
+ path: item.entry.path,
892
+ 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))` : ''),
893
+ })
894
+ continue
895
+ }
896
+ // A shard written **earlier** than the one already applied is a rollback, not news: a restored
897
+ // backup or an older manifest would otherwise delete rows this reader has that the publisher no
898
+ // longer knows about. Skipped rather than applied, and said out loud.
899
+ const previous = heldEntry(this.state.held?.[`${item.device}/${item.key}`])
900
+ if (previous && item.entry.at && previous.at && previous.at > item.entry.at) {
901
+ this.log(`keeping ${item.device}/${item.key}: the server's copy is older than the one already applied`)
902
+ continue
903
+ }
785
904
  if (this.store) {
786
- this.store.intakeRows(decoded.rows)
905
+ // `replace`: the month is this device's own statement about it, so rows it no longer lists go.
906
+ // Without that, a publisher that dropped a row keeps every reader counting it forever.
907
+ this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
787
908
  // `held` is only advanced once the rows are in the store: if the import throws, the shard
788
909
  // must be fetched again rather than remembered as applied.
789
- this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: item.entry.sha256 }
910
+ this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: { sha: item.entry.sha256, at: Number(item.entry.at) || 0 } }
790
911
  }
791
912
  rows += decoded.rows.length
792
913
  fetched++
793
914
  }
794
- return { fetched, rows, failed }
915
+
916
+ // ── what the server says is gone ─────────────────────────────────────────
917
+ //
918
+ // A month that vanished from a manifest used to be invisible to a reader that had already imported
919
+ // it: `intakeRows` only adds, so the reader kept rows its publisher had dropped and the two machines
920
+ // disagreed for good. A device that withdraws a month now leaves a tombstone, and this is the other
921
+ // half — drop what came from that device for that month.
922
+ let dropped = 0
923
+ const droppedKeys = []
924
+ for (const manifest of manifests ?? []) {
925
+ const device = manifest?.device
926
+ if (!device || (this.pullOwnDirectory ? false : device === this.deviceId)) continue
927
+ for (const [key, entry] of Object.entries(manifest?.retired ?? {})) {
928
+ const context = `${device}/${key}`
929
+ // Only what this reader actually imported: a tombstone for a month it never held is nothing.
930
+ if (!this.state.held?.[context]) continue
931
+ const removed = this.store?.dropSourceMonth(device, key) ?? 0
932
+ const next = { ...(this.state.held ?? {}) }
933
+ delete next[context]
934
+ this.state.held = next
935
+ dropped += removed
936
+ droppedKeys.push(context)
937
+ 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'})`)
938
+ }
939
+ }
940
+ return { fetched, rows, failed, dropped, droppedKeys }
795
941
  }
796
942
 
797
943
  /**
@@ -810,7 +956,38 @@ export class SyncEngine {
810
956
  // The owner is recorded in the directory's own manifest. A directory with no recorded owner is one
811
957
  // published by a build that did not write the field (or the first publish of anything), and adopting
812
958
  // it is what makes an upgrade work; an owner that is a *different* installation is a real clash.
813
- const remote = (await this.readManifests(client)).find((manifest) => manifest.device === this.deviceId)
959
+ const { manifests: discovered, devices: directories } = await this.readManifests(client)
960
+ const remote = discovered.find((manifest) => manifest.device === this.deviceId)
961
+ // A directory that exists but whose manifest cannot be read is not "nothing there": nobody knows whose
962
+ // it is. Writing into it because the owner check had nothing to look at is the one shape of this bug
963
+ // the instanceId guard cannot see, so an **unreadable** manifest counts as occupied — unless this
964
+ // device has published here before, in which case it is this device's own torn manifest and repairing
965
+ // it is the whole point of the next publish.
966
+ const unknownHere = directories.includes(this.deviceId) && !remote && Object.keys(this.state.published ?? {}).length === 0
967
+ if (unknownHere) {
968
+ this.state.collision = { device: this.deviceId, owner: null, kind: 'unreadable', at: this.now() }
969
+ this.log(
970
+ `not publishing: usage-badge/${this.deviceId}/ exists but its manifest could not be read, so this device cannot tell whose it is. ` +
971
+ `Pick a device name that is not taken, or remove that directory on the server if it is yours.`,
972
+ )
973
+ return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'unreadable' }
974
+ }
975
+ // Two directories whose names differ only in case land in **one** directory on a case-insensitive
976
+ // server (Windows/SMB-backed DAV), where the owner check by exact name would never fire. Refused rather
977
+ // than merged: on the target server (Linux Apache) they are genuinely two, and a name that differs only
978
+ // in case is never what someone means.
979
+ const caseTwin = discovered.find((manifest) => manifest.device !== this.deviceId && manifest.device.toLowerCase() === String(this.deviceId).toLowerCase())
980
+ if (caseTwin) {
981
+ this.state.collision = { device: this.deviceId, owner: caseTwin.instanceId || 'unknown', kind: 'case', at: this.now() }
982
+ this.log(`not publishing: usage-badge/${caseTwin.device}/ differs from "${this.deviceId}" only in case, which is one directory on a case-insensitive server`)
983
+ return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'case' }
984
+ }
985
+ // **This installation**, not this name. A rename leaves the previous directory behind with its own
986
+ // manifest, and every reader treats every manifest it finds as authoritative — so a month withdrawn
987
+ // after a rename would come straight back through the old name. The `instanceId` is what identifies the
988
+ // installation across names; two installations never share one, and a manifest without one is not ours
989
+ // to speak for.
990
+ const mine = this.instanceId ? discovered.filter((manifest) => manifest.instanceId === this.instanceId) : []
814
991
  if (remote && remote.instanceId && remote.instanceId !== this.instanceId) {
815
992
  this.state.collision = { device: this.deviceId, owner: remote.instanceId, at: this.now() }
816
993
  this.log(
@@ -830,11 +1007,12 @@ export class SyncEngine {
830
1007
  }
831
1008
  delete this.state.collision
832
1009
 
833
- const rows = chunkRows(this.store.scan())
1010
+ const rows = chunkRows(this.store.scan(), { owns: [LOCAL_SOURCE, this.deviceId, ...mine.map((manifest) => manifest.device)] })
834
1011
  // `state.published` is the manifest's **chunks map**, so it is wrapped back into a manifest shape
835
1012
  // here. Passing the map itself would leave `previous.chunks` empty, and every run would re-upload
836
- // every month while looking perfectly healthy.
837
- const { pending, unchanged, manifest } = planUpload(rows, { chunks: this.state.published ?? {} }, {
1013
+ // every month while looking perfectly healthy. `retired` rides along for the same reason: it is the
1014
+ // record of withdrawals a reader needs in order to drop what it once imported.
1015
+ const { pending, unchanged, manifest } = planUpload(rows, { chunks: this.state.published ?? {}, retired: this.state.retired ?? {} }, {
838
1016
  device: this.deviceId,
839
1017
  label: this.config.deviceLabel ?? '',
840
1018
  at: this.now(),
@@ -861,13 +1039,6 @@ export class SyncEngine {
861
1039
  return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'diverged' }
862
1040
  }
863
1041
 
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
1042
  // The collection comes **before** anything is written into it. `mod_dav_fs` refuses a write whose
872
1043
  // parent does not exist (403/409, not the 404 an HTTP client might expect), so creating it on the
873
1044
  // way past would fail the first publish of a device — and mkcol is idempotent anyway, so it costs
@@ -876,6 +1047,9 @@ export class SyncEngine {
876
1047
  let bytes = 0
877
1048
  const shards = []
878
1049
  for (const item of pending) {
1050
+ // Same boundary as the pull: an upload already on the wire cannot be recalled, but the rest of the
1051
+ // list can wait for a run that will still have a store to write to.
1052
+ if (this.aborted) break
879
1053
  // Upload under a temporary name and rename: a crash mid-upload leaves a name nobody reads,
880
1054
  // instead of a shard that looks complete and is half a month.
881
1055
  const temp = `${item.entry.path}.upload-${process.pid}-${Math.random().toString(16).slice(2, 8)}`
@@ -894,27 +1068,70 @@ export class SyncEngine {
894
1068
  // **last** on purpose: a manifest naming a shard that has not landed yet would send every other
895
1069
  // device after a 404 on their next run.
896
1070
  await client.put(`${REMOTE_PREFIX}${this.deviceId}/${DEVICE_MARKER}`, Buffer.from(`${this.deviceId}\n`))
1071
+ // ── what every other name of this installation must stop claiming ──────────
1072
+ //
1073
+ // A rename leaves the previous directory behind, and **every reader treats every manifest it finds as
1074
+ // authoritative**. So a month that was corrected or dropped after a rename keeps feeding readers its old
1075
+ // rows through the old name: the fix is that this installation's own months live in exactly one
1076
+ // directory, and every other directory of ours is brought in line with what we actually hold.
1077
+ //
1078
+ // The comparison is by content hash, not by month: a month can stay while one of its rows goes away
1079
+ // (a log version that no longer covers that day), and that difference is invisible to a key-vs-key test.
1080
+ // A key we no longer publish at all is simply missing from the current chunks, which is the same test.
1081
+ //
1082
+ // The current name's manifest is the one `planUpload` built; the others are rebuilt through
1083
+ // `buildManifest` — **not** written back as parsed. A parsed manifest carries `version` while the file
1084
+ // format is `v`, so writing one back produces a manifest no reader can parse, which would hide that
1085
+ // directory's shards from everyone instead of correcting them.
1086
+ const targets = new Map([[this.deviceId, manifest]])
1087
+ for (const other of mine) {
1088
+ if (targets.has(other.device)) continue
1089
+ 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 ?? {}) } }))
1090
+ }
1091
+ const withdrawals = []
1092
+ for (const [key, entry] of Object.entries(manifest.chunks)) {
1093
+ if (rows.has(key)) continue
1094
+ withdrawals.push({ key, device: this.deviceId, manifest, path: entry.path ?? shardPath(this.deviceId, key) })
1095
+ }
1096
+ for (const [device, target] of targets) {
1097
+ if (device === this.deviceId) continue
1098
+ for (const [key, entry] of Object.entries(target.chunks)) {
1099
+ const current = manifest.chunks[key]
1100
+ if (current && current.sha256 === entry.sha256) continue
1101
+ withdrawals.push({ key, device, manifest: target, path: entry.path ?? shardPath(device, key) })
1102
+ }
1103
+ }
1104
+ // **Delete first, then write.** Each deletion is recorded, and a month whose shard could not be removed
1105
+ // stays in the manifest that lists it — so the claim and the bytes never disagree, and the failure is
1106
+ // retried on the next run instead of being half-recorded.
897
1107
  let withdrawn = 0
898
1108
  const withdrawnKeys = []
899
- for (const key of retired) {
1109
+ const withdrawErrors = []
1110
+ for (const item of withdrawals) {
900
1111
  try {
901
1112
  // Gone from the manifest *and* from the server: leaving the bytes behind while dropping the claim
902
- // would be tidier for a manual rescue and worse for everything else — the next reader that finds
903
- // the manifest is authoritative would see a shard nobody lists. The shard is *this device's own*,
904
- // which is the one direction where deleting is not the remote deciding anything.
905
- await client.delete(shardPath(this.deviceId, key))
906
- delete manifest.chunks[key]
1113
+ // would leave a shard nobody lists. These shards are this installation's own, which is the one
1114
+ // direction where deleting is not the remote deciding anything.
1115
+ await client.delete(item.path)
1116
+ const gone = item.manifest.chunks[item.key]
1117
+ delete item.manifest.chunks[item.key]
1118
+ // A **tombstone**, so a device that already imported this month learns that it is gone. Without it
1119
+ // a reader keeps those rows forever — the publisher drops them, and the two machines disagree for
1120
+ // good, in the direction of "everyone else counts more than the machine that has the data".
1121
+ item.manifest.retired = { ...(item.manifest.retired ?? {}), [item.key]: { at: this.now(), sha256: String(gone?.sha256 ?? '') } }
907
1122
  withdrawn++
908
- withdrawnKeys.push(key)
909
- this.log(`withdrew ${key}: this device no longer holds any rows for it`)
1123
+ if (!withdrawnKeys.includes(item.key)) withdrawnKeys.push(item.key)
1124
+ this.log(`withdrew ${item.key} from ${item.device}/: this device publishes it under ${this.deviceId}/ or no longer holds it`)
910
1125
  } catch (error) {
911
- // The month could not be withdrawn, so the manifest is not written at all: half-recording it
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}` }
1126
+ withdrawErrors.push(`could not withdraw ${item.key} from ${item.device}/: ${error?.message ?? error}`)
914
1127
  }
915
1128
  }
916
- await client.put(manifestPath(this.deviceId), Buffer.from(JSON.stringify(manifest, null, 2)), { contentType: 'application/json' })
917
- return { uploaded: pending.length, unchanged, bytes, shards, manifest, withdrawn, withdrawnKeys }
1129
+ // Written even when a withdrawal failed: the manifests now say exactly what the server holds, and the
1130
+ // current one still names every shard that was uploaded above (so `uploaded` is not an exaggeration).
1131
+ for (const [device, target] of targets) {
1132
+ await client.put(manifestPath(device), Buffer.from(JSON.stringify(target, null, 2)), { contentType: 'application/json' })
1133
+ }
1134
+ return { uploaded: pending.length, unchanged, bytes, shards, manifest, withdrawn, withdrawnKeys, withdrawErrors }
918
1135
  }
919
1136
 
920
1137
  /**
@@ -1096,9 +1313,14 @@ export class SyncEngine {
1096
1313
  } catch (error) {
1097
1314
  return this.finish('failed', String(error?.message ?? error), { started })
1098
1315
  }
1316
+ // The host can unload while this is in flight. Checked at every phase boundary rather than only at the
1317
+ // start, because the phases are where a run touches the store — and after `aggregator.close()` its
1318
+ // statements are finalized, so continuing is not harmless.
1319
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1099
1320
 
1100
1321
  try {
1101
- const manifests = await this.readManifests(client)
1322
+ const { manifests } = await this.readManifests(client)
1323
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1102
1324
  // **Pull first, refuse only the push.** An installation whose device name is taken still wants the
1103
1325
  // history back, and that is the case the feature exists for (reinstall, another machine): the name
1104
1326
  // is what is in the way, not the data. So the clash is noted here — which is what tells `pull` to
@@ -1113,10 +1335,17 @@ export class SyncEngine {
1113
1335
  this.pullOwnDirectory = Boolean(clash)
1114
1336
  const pulled = await this.pull(client, manifests)
1115
1337
  this.pullOwnDirectory = false
1338
+ if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started })
1116
1339
  const pushed = await this.push(client)
1117
- if (pushed.manifest) this.state.published = pushed.manifest.chunks
1340
+ if (pushed.manifest) {
1341
+ this.state.published = pushed.manifest.chunks
1342
+ // The tombstones are state too: the next run has to keep carrying them, or a reader that has not
1343
+ // synced yet would never learn that a month is gone.
1344
+ this.state.retired = pushed.manifest.retired ?? {}
1345
+ }
1118
1346
  // The price table last: it is the only hand-written thing here, and it should not be touched by
1119
1347
  // a run that failed to move the usage it was about to move.
1348
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1120
1349
  const pricing = await this.syncPricing(client)
1121
1350
  const parts = [
1122
1351
  `pulled ${pulled.fetched} shard(s) / ${pulled.rows} row(s)`,
@@ -1128,8 +1357,9 @@ export class SyncEngine {
1128
1357
  // the pull worked and nothing was lost. Naming it is what lets the panel say what to do.
1129
1358
  parts.push(`nothing published: device name "${this.deviceId}" is being written by another installation`)
1130
1359
  }
1131
- if (pushed.withdrawError) parts.push(pushed.withdrawError)
1360
+ for (const error of pushed.withdrawErrors ?? []) parts.push(error)
1132
1361
  if (pushed.withdrawn) parts.push(`withdrew ${pushed.withdrawn} month(s) this device no longer holds`)
1362
+ if (pulled.dropped) parts.push(`dropped ${pulled.dropped} row(s) another device has withdrawn`)
1133
1363
  if (pricing.action !== 'none') parts.push(`prices: ${pricing.status}`)
1134
1364
  if (pulled.failed.length > 0) parts.push(`${pulled.failed.length} shard(s) failed`)
1135
1365
  const message = parts.join(', ')
@@ -1137,7 +1367,8 @@ export class SyncEngine {
1137
1367
  // Four outcomes, each meaning a different thing to the person reading the panel: `ok` moved
1138
1368
  // everything, `partial` moved almost everything, `clash` deliberately published nothing and needs a
1139
1369
  // decision from them, `failed` is a problem with the server or the network.
1140
- const result = pushed.refused ? 'clash' : pulled.failed.length > 0 || pushed.withdrawError ? 'partial' : 'ok'
1370
+ const incomplete = pulled.failed.length > 0 || (pushed.withdrawErrors?.length ?? 0) > 0
1371
+ const result = pushed.refused ? 'clash' : incomplete ? 'partial' : 'ok'
1141
1372
  return this.finish(result, message, {
1142
1373
  started,
1143
1374
  extra: {
@@ -1149,6 +1380,7 @@ export class SyncEngine {
1149
1380
  failed: pulled.failed,
1150
1381
  refused: pushed.refused ?? null,
1151
1382
  withdrawn: pushed.withdrawn ?? 0,
1383
+ dropped: pulled.dropped ?? 0,
1152
1384
  },
1153
1385
  })
1154
1386
  } catch (error) {
@@ -1166,7 +1398,10 @@ export class SyncEngine {
1166
1398
  * — which is exactly how a sync ends up re-importing every shard on every run.
1167
1399
  */
1168
1400
  finish(result, message, { started, extra = {} } = {}) {
1169
- this.state.lastSyncAt = this.now()
1401
+ // `skipped` (sync turned off between scheduling and running) deliberately leaves the timestamp alone:
1402
+ // nothing was synced, and stamping it would show "上次同步:刚刚" for a run that never happened. `locked`
1403
+ // returns before this, for the same reason.
1404
+ if (result !== 'skipped') this.state.lastSyncAt = this.now()
1170
1405
  this.state.lastResult = result
1171
1406
  this.state.message = message
1172
1407
  try {