pmtiles-swarm 0.41.0 → 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,52 @@
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
+
35
+ ## 0.41.1
36
+ ### ✨ Features and improvements
37
+
38
+ ### 🐞 Bug fixes
39
+ - **`KillMode=mixed` in the documented unit, and it should have been there from the start.** systemd's
40
+ default is `control-group`, which sends `SIGTERM` to every process in the unit at the same instant —
41
+ the node and the Python sidecar together. The node's shutdown then asks a sidecar that is already
42
+ dying to write its resume data, into a pipe that is closing, and the answer never comes.
43
+
44
+ Resume data is how an archive comes back knowing what it holds. Without it, archives return at 0%
45
+ after a restart and only a recheck discovers they were complete all along. `mixed` signals the main
46
+ process only; the node stops the sidecar itself and waits for the write. Nothing is left running —
47
+ anything still alive at `TimeoutStopSec` is killed, sidecar included. **Existing installs need this
48
+ added by hand**, followed by `systemctl daemon-reload`.
49
+ - **The check added in 0.40.0 asked its first question only of archives recorded as complete**, and so
50
+ missed the case that prompted it. An archive interrupted mid-download comes back recorded as
51
+ incomplete, so a restore that failed to hand it over left it absent from the engine and unreported
52
+ by the very check meant to notice — which is what a row reading 0% with no state at all is. Whether
53
+ the engine is holding an archive is now asked of everything restore handed over; whether the
54
+ `seedOnly` claim held is still asked only of the archives that make one.
55
+
10
56
  ## 0.41.0
11
57
  ### ✨ Features and improvements
12
58
  - **An archive's details now open directly under the row you clicked**, instead of below the whole
@@ -180,6 +180,11 @@ RestartSec=5
180
180
  # lock and cancels downloads in flight. Worst case is about 20 seconds.
181
181
  TimeoutStopSec=45
182
182
 
183
+ # The node stops the sidecar itself, and needs it alive to do so. The default
184
+ # signals both at once, which kills the sidecar before it can write its resume
185
+ # data — see "The two lines that matter".
186
+ KillMode=mixed
187
+
183
188
  # A seeding node holds a socket per peer, and the tile reader holds file
184
189
  # descriptors of its own.
185
190
  LimitNOFILE=65535
@@ -215,12 +220,23 @@ down and **exits 0**, expecting to be brought back.
215
220
  `Restart=on-failure` ignores an exit 0, so the first use of that button would
216
221
  stop the node and leave the unit reporting success.
217
222
 
223
+ **`KillMode=mixed`, not the default.** The default is `control-group`, which
224
+ sends `SIGTERM` to _every_ process in the unit at the same instant — the node
225
+ and the Python sidecar together. The node's shutdown then asks a sidecar that is
226
+ already dying to write its resume data, into a pipe that is closing, and the
227
+ answer never comes. Resume data is how an archive comes back knowing what it
228
+ holds; without it, archives return at 0% after a restart and have to be
229
+ rechecked to find out they were complete all along.
230
+
231
+ `mixed` sends the signal to the main process only. The node stops the sidecar
232
+ itself, in order, and waits for the resume data to be written. Nothing is left
233
+ running: anything still alive when `TimeoutStopSec` expires is killed, sidecar
234
+ included.
235
+
218
236
  **No `ExecStop=`.** systemd already sends `SIGTERM`, and the node handles it
219
237
  from the moment it starts. `ExecStop=/bin/kill -15 $MAINPID` is redundant, and
220
- becomes wrong if the unit ever uses `KillMode=process` — it would stop the node
221
- while the Python sidecar kept running and kept the data directory locked.
222
-
223
- Leave `KillMode` at its default, so the sidecar goes with its parent.
238
+ becomes wrong under `KillMode=process` — that one leaves the rest of the unit
239
+ running indefinitely, so the sidecar would keep the data directory locked.
224
240
 
225
241
  ## Where it writes
226
242
 
@@ -587,8 +603,10 @@ and lives as long as it does, so a new sidecar sits on disk doing nothing until
587
603
  the service is restarted. Most of what changes between releases is in there.
588
604
 
589
605
  Nothing under `/etc/pmtiles-swarm` is touched, and restarting does not re-check
590
- the archives: a clean stop writes resume data, and `TimeoutStopSec` above
591
- leaves room for it.
606
+ the archives: a clean stop writes resume data, and `TimeoutStopSec` above leaves
607
+ room for it. That depends on `KillMode=mixed` — without it the sidecar is
608
+ signalled at the same moment as the node and dies before it can write anything,
609
+ which is what a library that comes back at 0% after every restart looks like.
592
610
 
593
611
  Confirm both halves moved, since the sidecar has its own version:
594
612
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.41.0",
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
  *
@@ -1840,10 +1906,7 @@ export class Library {
1840
1906
  * @returns {Promise<void>} - Resolves once every claim has been checked.
1841
1907
  */
1842
1908
  async #verifySeeding(entries) {
1843
- const claimed = entries.filter(
1844
- (entry) => entry.complete !== false && entry.mode !== 'cache',
1845
- );
1846
- if (claimed.length === 0) return;
1909
+ if (entries.length === 0) return;
1847
1910
 
1848
1911
  // One listing rather than a status call each: this runs over the whole
1849
1912
  // library on every start, and a round trip per archive is a cost paid by
@@ -1854,25 +1917,37 @@ export class Library {
1854
1917
  }
1855
1918
 
1856
1919
  let wrong = 0;
1857
- for (const entry of claimed) {
1920
+ for (const entry of entries) {
1858
1921
  const status = held.get(entry.infoHash);
1859
-
1860
- // Checking is the engine doing the right thing already, and progress
1861
- // during it is the fraction hashed rather than the fraction held.
1862
- if (status && (status.progress >= 1 || status.state === 'checking')) {
1863
- continue;
1864
- }
1865
- wrong += 1;
1866
1922
  const label = `[seeding] ${entry.name}`;
1867
1923
 
1924
+ // Held at all is a different question from held whole, and it is asked
1925
+ // of everything restore handed over. Asking it only of complete archives
1926
+ // was the first version of this check, and it missed the case that
1927
+ // prompted it: an archive interrupted mid-download comes back recorded
1928
+ // as incomplete, so a restore that silently failed to hand it over left
1929
+ // it absent from the engine and unreported by the very check meant to
1930
+ // notice. Absent is absent — it is neither seeding nor downloading.
1868
1931
  if (!status) {
1932
+ wrong += 1;
1869
1933
  console.error(
1870
- `${label}: recorded as complete, but the engine is not holding it ` +
1871
- 'at all restore counted it and nothing is seeding it.',
1934
+ `${label}: restore handed this to the engine and the engine is not ` +
1935
+ 'holding it. It is neither seeding nor downloading, and nothing ' +
1936
+ 'will start it before the next restart.',
1872
1937
  );
1873
1938
  continue;
1874
1939
  }
1875
1940
 
1941
+ // Everything below is about the `seedOnly` claim, and only a complete
1942
+ // archive makes one. An incomplete one is supposed to read as a partial
1943
+ // download, so its progress says nothing about whether anything is wrong.
1944
+ if (entry.complete === false || entry.mode === 'cache') continue;
1945
+
1946
+ // Checking is the engine doing the right thing already, and progress
1947
+ // during it is the fraction hashed rather than the fraction held.
1948
+ if (status.progress >= 1 || status.state === 'checking') continue;
1949
+ wrong += 1;
1950
+
1876
1951
  const file = entry.savePath
1877
1952
  ? path.join(entry.savePath, entry.name)
1878
1953
  : null;
@@ -1921,8 +1996,8 @@ export class Library {
1921
1996
 
1922
1997
  if (wrong > 0) {
1923
1998
  console.error(
1924
- `[seeding] ${wrong} of ${claimed.length} archives recorded as ` +
1925
- 'complete are not being seeded. The lines above say which and why.',
1999
+ `[seeding] ${wrong} of ${entries.length} restored archives are not ` +
2000
+ 'in the state the catalog describes. The lines above say which and why.',
1926
2001
  );
1927
2002
  }
1928
2003
  }
@@ -1951,8 +2026,9 @@ export class Library {
1951
2026
  }, 15_000);
1952
2027
  progress.unref?.();
1953
2028
 
2029
+ const handed = [];
1954
2030
  try {
1955
- await this.#restoreEach(entries, tally);
2031
+ await this.#restoreEach(entries, tally, handed);
1956
2032
  } finally {
1957
2033
  clearInterval(progress);
1958
2034
  }
@@ -1962,7 +2038,7 @@ export class Library {
1962
2038
  // reports on a library half restored. Never allowed to fail the restore —
1963
2039
  // this is a report about seeding, and a node that could not produce it is
1964
2040
  // still a node that restored what it could.
1965
- await this.#verifySeeding(entries).catch((error) =>
2041
+ await this.#verifySeeding(handed).catch((error) =>
1966
2042
  console.warn(
1967
2043
  `[seeding] could not check what is being seeded: ${error.message}`,
1968
2044
  ),
@@ -1980,7 +2056,7 @@ export class Library {
1980
2056
  * @param {{restored: number, failed: number}} tally - Mutated as it goes.
1981
2057
  * @returns {Promise<{restored: number, failed: number}>} - That tally.
1982
2058
  */
1983
- async #restoreEach(entries, tally) {
2059
+ async #restoreEach(entries, tally, handed) {
1984
2060
  for (const entry of entries) {
1985
2061
  // An engine that cannot open its port will fail every one of these, each
1986
2062
  // after its own timeout. Stopping at the first is the difference between
@@ -2015,11 +2091,18 @@ export class Library {
2015
2091
  }
2016
2092
  }
2017
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
+
2018
2100
  // Through the same path as every other re-add, so an archive stored
2019
2101
  // with no trackers is repaired here too. Restoring used to build its
2020
2102
  // own add and skip that, which is why an archive that could not find a
2021
2103
  // peer stayed unable to find one across every restart.
2022
- await this.#readd(entry);
2104
+ await this.#readd(settled);
2105
+ handed.push(settled);
2023
2106
  tally.restored++;
2024
2107
  } catch (error) {
2025
2108
  tally.failed++;