pmtiles-swarm 0.41.1 → 0.41.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/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.41.2
11
+ ### ✨ Features and improvements
12
+
13
+ ### 🐞 Bug fixes
14
+ - **An archive that finished downloading during an unclean stop no longer comes back at 0% for ever.**
15
+ A downloaded archive is written under a marker — `planet.pmtiles.incomplete` — and renamed the
16
+ instant it is whole. The rename and the catalog entry recording it are two steps, so a stop between
17
+ them leaves an archive that is finished on disk and unfinished in the record.
18
+
19
+ That disagreement was permanent, and worse than it looks. Restore re-added the entry with the marker
20
+ attached, so the engine opened a filename nothing was at any more, found no data, and began
21
+ downloading an archive the node already held. The sweep that would have noticed takes the engine's
22
+ word over the disk's whenever the engine has one — and the engine's word was now 0%. Rechecking did
23
+ not help either, since it hashes the marked name, which is the wrong file to look at. No number of
24
+ restarts recovered it.
25
+
26
+ Restore now checks the disk before handing over anything recorded as unfinished: marked file gone
27
+ and the real one whole means the rename happened and the record of it did not, so the entry is
28
+ corrected and the archive handed over as the complete thing it is. A genuine partial download is
29
+ left exactly as it was — claiming otherwise would offer peers an archive this node cannot serve.
30
+
31
+ This is why the archives that arrive from a feed or a URL were the ones that sat at 0%: an archive
32
+ built here is recorded complete the moment it is registered, having just been read end to end, so it
33
+ never carries a marker to disagree about.
34
+
10
35
  ## 0.41.1
11
36
  ### ✨ Features and improvements
12
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.41.1",
3
+ "version": "0.41.2",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/library.js CHANGED
@@ -1819,6 +1819,72 @@ export class Library {
1819
1819
  return queued;
1820
1820
  }
1821
1821
 
1822
+ /**
1823
+ * Reconciles what the catalog says about an archive with what is on disk.
1824
+ *
1825
+ * An archive downloaded here is written under a marker — `planet.pmtiles`
1826
+ * becomes `planet.pmtiles.incomplete` — and renamed the instant it is whole.
1827
+ * The rename and the catalog entry that records it are two steps, so a stop
1828
+ * between them leaves an archive that is finished on disk and unfinished in
1829
+ * the record.
1830
+ *
1831
+ * That disagreement does not settle itself, and it is worse than it looks.
1832
+ * Restore re-adds an entry it believes incomplete with the marker attached,
1833
+ * so the engine opens `planet.pmtiles.incomplete` — a name nothing is at any
1834
+ * more — finds no data, and begins downloading a 128 GiB archive this node
1835
+ * already holds. The sweep that would notice takes the engine's word over
1836
+ * the disk's whenever the engine has one, and the engine's word is now 0%.
1837
+ * Rechecking does not help either: it hashes the marked name, which is the
1838
+ * wrong file to be looking at. Nothing recovers, through any number of
1839
+ * restarts.
1840
+ *
1841
+ * Only archives that were downloaded can be in this state — one built here
1842
+ * is recorded complete the moment it is registered, having just been read
1843
+ * end to end — which is why the archives that come from a feed or a URL are
1844
+ * the ones that sit at 0%.
1845
+ * @param {object} entry - The catalog entry restore is about to hand over.
1846
+ * @returns {Promise<object>} - That entry, or the corrected one.
1847
+ */
1848
+ async #reconcileMarker(entry) {
1849
+ if (entry.complete !== false || !entry.savePath || !entry.name) {
1850
+ return entry;
1851
+ }
1852
+
1853
+ const marked = onDiskPath(entry, this.#config);
1854
+ const real = path.join(entry.savePath, entry.name);
1855
+ // Markers turned off, so there is no second name to disagree with.
1856
+ if (!marked || marked === real) return entry;
1857
+
1858
+ // Still under the marker, which is what an unfinished download should look
1859
+ // like. Nothing to correct, whatever its progress.
1860
+ const partial = await fs.stat(marked).then(
1861
+ () => true,
1862
+ () => false,
1863
+ );
1864
+ if (partial) return entry;
1865
+
1866
+ // The marked name is gone and the real one is whole: the rename happened
1867
+ // and the record of it did not.
1868
+ const whole = await alreadyComplete({
1869
+ savePath: entry.savePath,
1870
+ name: entry.name,
1871
+ size: entry.size,
1872
+ });
1873
+ if (!whole) return entry;
1874
+
1875
+ console.warn(
1876
+ `[complete] ${entry.name} is whole under its own name but was recorded ` +
1877
+ 'as unfinished — a stop between the rename and the record. Handing it ' +
1878
+ 'to the engine as the complete archive it is.',
1879
+ );
1880
+ return (
1881
+ (await this.#catalog.put({
1882
+ infoHash: entry.infoHash,
1883
+ complete: true,
1884
+ })) ?? { ...entry, complete: true }
1885
+ );
1886
+ }
1887
+
1822
1888
  /**
1823
1889
  * Checks that what restore just claimed is actually being seeded.
1824
1890
  *
@@ -1960,8 +2026,9 @@ export class Library {
1960
2026
  }, 15_000);
1961
2027
  progress.unref?.();
1962
2028
 
2029
+ const handed = [];
1963
2030
  try {
1964
- await this.#restoreEach(entries, tally);
2031
+ await this.#restoreEach(entries, tally, handed);
1965
2032
  } finally {
1966
2033
  clearInterval(progress);
1967
2034
  }
@@ -1971,7 +2038,7 @@ export class Library {
1971
2038
  // reports on a library half restored. Never allowed to fail the restore —
1972
2039
  // this is a report about seeding, and a node that could not produce it is
1973
2040
  // still a node that restored what it could.
1974
- await this.#verifySeeding(entries).catch((error) =>
2041
+ await this.#verifySeeding(handed).catch((error) =>
1975
2042
  console.warn(
1976
2043
  `[seeding] could not check what is being seeded: ${error.message}`,
1977
2044
  ),
@@ -1989,7 +2056,7 @@ export class Library {
1989
2056
  * @param {{restored: number, failed: number}} tally - Mutated as it goes.
1990
2057
  * @returns {Promise<{restored: number, failed: number}>} - That tally.
1991
2058
  */
1992
- async #restoreEach(entries, tally) {
2059
+ async #restoreEach(entries, tally, handed) {
1993
2060
  for (const entry of entries) {
1994
2061
  // An engine that cannot open its port will fail every one of these, each
1995
2062
  // after its own timeout. Stopping at the first is the difference between
@@ -2024,11 +2091,18 @@ export class Library {
2024
2091
  }
2025
2092
  }
2026
2093
 
2094
+ // Before the add, because the add is what acts on the disagreement:
2095
+ // an entry wrongly recorded as unfinished is handed to the engine
2096
+ // under a filename nothing is at, and the engine starts downloading
2097
+ // an archive that is already here.
2098
+ const settled = await this.#reconcileMarker(entry);
2099
+
2027
2100
  // Through the same path as every other re-add, so an archive stored
2028
2101
  // with no trackers is repaired here too. Restoring used to build its
2029
2102
  // own add and skip that, which is why an archive that could not find a
2030
2103
  // peer stayed unable to find one across every restart.
2031
- await this.#readd(entry);
2104
+ await this.#readd(settled);
2105
+ handed.push(settled);
2032
2106
  tally.restored++;
2033
2107
  } catch (error) {
2034
2108
  tally.failed++;