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 +46 -0
- package/docs/running-as-a-service.md +24 -6
- package/package.json +1 -1
- package/src/library.js +103 -20
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
|
|
221
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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}:
|
|
1871
|
-
'
|
|
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 ${
|
|
1925
|
-
'
|
|
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(
|
|
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(
|
|
2104
|
+
await this.#readd(settled);
|
|
2105
|
+
handed.push(settled);
|
|
2023
2106
|
tally.restored++;
|
|
2024
2107
|
} catch (error) {
|
|
2025
2108
|
tally.failed++;
|