pmtiles-swarm 0.24.3 → 0.25.0
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 +50 -0
- package/package.json +1 -1
- package/src/config.js +15 -2
- package/src/feed.js +21 -1
- package/src/library.js +33 -5
- package/src/torrent-create.js +55 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,56 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.25.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A stopped download is kept, and adding the same URL again resumes it.** Staging directories
|
|
13
|
+
were named at random, so a partial transfer became unreachable the moment the add returned:
|
|
14
|
+
nothing knew where it was, and re-adding the URL opened a fresh directory beside it and started
|
|
15
|
+
from zero. They are now named from the URL, and a fetch that runs out of attempts leaves its
|
|
16
|
+
bytes in place rather than deleting them — so the second add finds the first one's work and
|
|
17
|
+
continues with a Range request. Cancelling still removes them: somebody said stop, and leaving
|
|
18
|
+
hundreds of gigabytes behind after that is the waste the deletion was written to avoid.
|
|
19
|
+
|
|
20
|
+
Note the disk consequence. A download abandoned for good now keeps its partial file until the
|
|
21
|
+
directory is removed by hand; the give-up message and a log line both name the path.
|
|
22
|
+
|
|
23
|
+
### 🐞 Bug fixes
|
|
24
|
+
- **Ten network blips ended an 800 GB download, whatever it had achieved.** Two faults, and the
|
|
25
|
+
attempt count was neither of them.
|
|
26
|
+
|
|
27
|
+
The budget counted every failure rather than consecutive failures that transferred nothing, so it
|
|
28
|
+
described the whole download instead of the trouble it was in. Observed in the field: 226 GB
|
|
29
|
+
across six separate stalls, then the remaining four spent inside one bad minute, because a
|
|
30
|
+
quarter of a terabyte of progress counted for nothing. An attempt that moves bytes has reached
|
|
31
|
+
the source and got data out of it, so whatever it hits next is new trouble — progress now clears
|
|
32
|
+
the count, against a high-water mark so a short attempt after a long one is not mistaken for it.
|
|
33
|
+
A ceiling on total attempts keeps that from becoming an unbounded loop.
|
|
34
|
+
|
|
35
|
+
And the wait between attempts was flat, so ten of them covered about forty-five seconds — shorter
|
|
36
|
+
than most of the interruptions they exist to survive. It now grows with each consecutive failure
|
|
37
|
+
and the base moves from 5 seconds to 30, which spans something over twenty minutes rather than
|
|
38
|
+
under one.
|
|
39
|
+
|
|
40
|
+
## 0.24.4
|
|
41
|
+
### 🐞 Bug fixes
|
|
42
|
+
- **The feed advertised a .torrent nobody could fetch.** Every item named
|
|
43
|
+
`/api/torrents/<infohash>/file`, and that address is unreachable to exactly the audience a feed
|
|
44
|
+
is written for: the API is not on the public listener, so it answers 404 there, and on the
|
|
45
|
+
console listener it answers 401. Our own subscriptions got one or the other, and so did any
|
|
46
|
+
ordinary torrent client pointed at the same URL.
|
|
47
|
+
|
|
48
|
+
It failed quietly, which is why it lasted. `subscriptions.js` falls back to the magnet and logs a
|
|
49
|
+
line about it, so a mirror still joined and still downloaded. What the fallback costs is not
|
|
50
|
+
visible from there: BEP 9 carries only the info dict, and a v2 torrent's piece layers live
|
|
51
|
+
outside it, so an archive joined by magnet can never obtain them. On a hybrid torrent the mirror
|
|
52
|
+
then holds metadata it cannot verify pieces against and republishes a .torrent that claims v2
|
|
53
|
+
while omitting the hashes — 413 KB against the origin's 1,074 KB, the difference being precisely
|
|
54
|
+
20,636 pieces x 32 bytes. Anything mirroring from that mirror inherits it.
|
|
55
|
+
|
|
56
|
+
Items now name `/archives/<infohash>/archive.torrent`, which is public, unauthenticated, and the
|
|
57
|
+
same URL the TileJSON has always given for the same archive. Those two disagreeing was the bug;
|
|
58
|
+
a test now pins that they agree and that what they name answers 200 on the public surface.
|
|
59
|
+
|
|
10
60
|
## 0.24.3
|
|
11
61
|
### 🐞 Bug fixes
|
|
12
62
|
- **Takes pmtiles-torrent 0.4.6.** Two things a node reading pieces on demand wanted. The sidecar
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
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/config.js
CHANGED
|
@@ -326,8 +326,21 @@ const DEFAULTS = {
|
|
|
326
326
|
* docs/internals.md — "Resuming a partial download".
|
|
327
327
|
*/
|
|
328
328
|
fetchAttempts: 10,
|
|
329
|
-
/**
|
|
330
|
-
|
|
329
|
+
/**
|
|
330
|
+
* Base wait before resuming a download that stopped, in seconds.
|
|
331
|
+
*
|
|
332
|
+
* Multiplied by the number of consecutive failures, so the wait grows while
|
|
333
|
+
* the trouble lasts: at 30 it waits 30s, then 60, then 90, and a run of ten
|
|
334
|
+
* spans something over twenty minutes. A flat five seconds made ten attempts
|
|
335
|
+
* worth about forty-five seconds in total, which is shorter than most of the
|
|
336
|
+
* interruptions this exists to survive — a download that had transferred
|
|
337
|
+
* 226 GB was ended by a single bad minute.
|
|
338
|
+
*
|
|
339
|
+
* Counted per consecutive failure rather than per attempt: anything that
|
|
340
|
+
* moves bytes clears the count, because a transfer that got data out of the
|
|
341
|
+
* source has proved the route and whatever it hit next is new trouble.
|
|
342
|
+
*/
|
|
343
|
+
fetchRetrySeconds: 30,
|
|
331
344
|
/** How often to check seeding limits, in seconds. Zero disables it. */
|
|
332
345
|
seedingCheckIntervalSeconds: 3600,
|
|
333
346
|
/**
|
package/src/feed.js
CHANGED
|
@@ -84,7 +84,27 @@ ${items}
|
|
|
84
84
|
* @returns {string} - The item XML.
|
|
85
85
|
*/
|
|
86
86
|
function renderItem(entry, baseUrl) {
|
|
87
|
-
|
|
87
|
+
// The public route, which is the only one a subscriber can use.
|
|
88
|
+
//
|
|
89
|
+
// This named /api/torrents/<hash>/file, and that address is unreachable to
|
|
90
|
+
// everyone the feed is written for: the API is not on the public listener at
|
|
91
|
+
// all, so it answers 404 there, and on the console listener it answers 401.
|
|
92
|
+
// A feed is a public document whose whole purpose is to be followed by
|
|
93
|
+
// somebody else, and every consumer of it -- our own subscriptions, and any
|
|
94
|
+
// ordinary torrent client pointed at the same URL -- got one of those two.
|
|
95
|
+
//
|
|
96
|
+
// Nothing broke loudly, because subscriptions.js falls back to the magnet and
|
|
97
|
+
// logs a line about it. What that fallback costs is invisible from here and
|
|
98
|
+
// considerable: BEP 9 carries only the info dict, and a v2 torrent's piece
|
|
99
|
+
// layers live outside it, so an archive joined by magnet can never obtain
|
|
100
|
+
// them. On a hybrid torrent the mirror then holds metadata it cannot verify
|
|
101
|
+
// pieces against, and republishes a .torrent that claims v2 and omits the
|
|
102
|
+
// hashes -- measured at 413 KB against the origin's 1,074 KB, the difference
|
|
103
|
+
// being exactly 20,636 pieces x 32 bytes.
|
|
104
|
+
//
|
|
105
|
+
// Kept identical to what the TileJSON advertises for the same archive, in
|
|
106
|
+
// api.js. Those two disagreeing is what this was.
|
|
107
|
+
const torrentUrl = `${baseUrl}/archives/${entry.infoHash}/archive.torrent`;
|
|
88
108
|
const map = entry.pmtiles;
|
|
89
109
|
|
|
90
110
|
const mapFields = map
|
package/src/library.js
CHANGED
|
@@ -606,8 +606,22 @@ export class Library {
|
|
|
606
606
|
// Downloaded into a directory of its own, then moved once the infohash
|
|
607
607
|
// exists to be filed under. See docs/internals.md — "Staging, for an
|
|
608
608
|
// archive fetched from a URL".
|
|
609
|
+
// Named from the URL rather than at random, so a download that stopped can
|
|
610
|
+
// be picked up again.
|
|
611
|
+
//
|
|
612
|
+
// A random name made every add its own directory, which meant a partial
|
|
613
|
+
// transfer was unreachable the moment the add returned: nothing knew where
|
|
614
|
+
// it was, and re-adding the same URL opened a fresh directory beside it and
|
|
615
|
+
// started from zero. For a planet archive that is hundreds of gigabytes
|
|
616
|
+
// thrown away because a network went out for a few minutes. Derived from
|
|
617
|
+
// the URL, the second add finds the first one's bytes and downloadTo
|
|
618
|
+
// resumes with a Range request.
|
|
609
619
|
const staging = retain
|
|
610
|
-
? path.join(
|
|
620
|
+
? path.join(
|
|
621
|
+
root,
|
|
622
|
+
INCOMING,
|
|
623
|
+
crypto.createHash('sha256').update(url).digest('hex').slice(0, 16),
|
|
624
|
+
)
|
|
611
625
|
: undefined;
|
|
612
626
|
|
|
613
627
|
// The origin is a valid web seed for exactly these bytes, so it is used as
|
|
@@ -658,12 +672,26 @@ export class Library {
|
|
|
658
672
|
},
|
|
659
673
|
});
|
|
660
674
|
} catch (error) {
|
|
661
|
-
// A cancelled or failed fetch leaves a partial file in a directory
|
|
662
|
-
// nothing will ever look in again. Left alone it is invisible waste —
|
|
663
|
-
// and for a planet archive, invisible waste measured in gigabytes.
|
|
664
675
|
this.#running.delete(url);
|
|
665
|
-
|
|
676
|
+
// Cancelling is a decision to stop wanting this; running out of attempts
|
|
677
|
+
// is not. The two used to be cleaned up identically, so a download that
|
|
678
|
+
// survived six stalls and reached 226 GB had all of it deleted by the
|
|
679
|
+
// seventh -- and the resume that the staging directory exists to make
|
|
680
|
+
// possible had nothing left to resume from.
|
|
681
|
+
//
|
|
682
|
+
// A cancelled fetch is still removed. Somebody said stop, and leaving
|
|
683
|
+
// gigabytes behind after that is the invisible waste this was written to
|
|
684
|
+
// avoid in the first place.
|
|
685
|
+
const cancelled = controller.signal.aborted;
|
|
686
|
+
if (staging && cancelled) {
|
|
666
687
|
await fs.rm(staging, { recursive: true, force: true }).catch(() => {});
|
|
688
|
+
} else if (staging) {
|
|
689
|
+
console.warn(
|
|
690
|
+
`[fetch] keeping the partial download in ${staging}; adding ${url} ` +
|
|
691
|
+
'again resumes it, and discarding it is a matter of removing that ' +
|
|
692
|
+
'directory',
|
|
693
|
+
);
|
|
694
|
+
}
|
|
667
695
|
throw error;
|
|
668
696
|
}
|
|
669
697
|
|
package/src/torrent-create.js
CHANGED
|
@@ -230,7 +230,31 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
|
|
|
230
230
|
let lastReport = 0;
|
|
231
231
|
let lastError;
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
// The budget counts *consecutive* failures that moved nothing, not failures.
|
|
234
|
+
//
|
|
235
|
+
// Counting every failure made the budget a property of the whole download
|
|
236
|
+
// rather than of the trouble it is in, and for a large archive those are not
|
|
237
|
+
// the same thing at all. A 700 GiB transfer over a domestic line drops
|
|
238
|
+
// occasionally; observed in the field, a download reached 226 GB across six
|
|
239
|
+
// separate stalls and then exhausted the remaining four on a single bad
|
|
240
|
+
// minute, because nothing about a quarter of a terabyte of progress counted
|
|
241
|
+
// for anything. An attempt that transferred bytes proves the source and the
|
|
242
|
+
// route are alive, so the trouble it hit is over and the next one is new.
|
|
243
|
+
//
|
|
244
|
+
// The high-water mark rather than the last attempt's figure: an attempt can
|
|
245
|
+
// fail having written less than a previous one already had on disk, and that
|
|
246
|
+
// is not progress.
|
|
247
|
+
let consumed = 0;
|
|
248
|
+
let best = await bytesOnDisk(target);
|
|
249
|
+
// Reset does mean an unlucky download can go round more times than the
|
|
250
|
+
// budget names, which is the point, so there is a ceiling as well: a source
|
|
251
|
+
// dribbling a few bytes before dropping every time would otherwise retry
|
|
252
|
+
// for ever.
|
|
253
|
+
const ceiling = attempts * 10;
|
|
254
|
+
let taken = 0;
|
|
255
|
+
|
|
256
|
+
while (consumed < attempts && taken < ceiling) {
|
|
257
|
+
taken += 1;
|
|
234
258
|
const from = await bytesOnDisk(target);
|
|
235
259
|
// A file already at full length is one a previous attempt finished, and
|
|
236
260
|
// that the caller died before renaming. Re-fetching it buys nothing.
|
|
@@ -249,8 +273,11 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
|
|
|
249
273
|
// A cancelled download is a decision, not a failure to retry past.
|
|
250
274
|
if (signal?.aborted) throw error;
|
|
251
275
|
lastError = error;
|
|
252
|
-
|
|
253
|
-
|
|
276
|
+
// Nothing was transferred -- the request never opened -- so this one
|
|
277
|
+
// always counts.
|
|
278
|
+
consumed += 1;
|
|
279
|
+
if (consumed >= attempts) break;
|
|
280
|
+
await delay(retryDelayMs * consumed, signal);
|
|
254
281
|
continue;
|
|
255
282
|
}
|
|
256
283
|
|
|
@@ -324,18 +351,38 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
|
|
|
324
351
|
if (signal?.aborted) throw error;
|
|
325
352
|
lastError = error;
|
|
326
353
|
const reached = await bytesOnDisk(target);
|
|
354
|
+
// Progress clears the slate. Anything that moved bytes reached the
|
|
355
|
+
// source and got data out of it, so whatever it then ran into is a new
|
|
356
|
+
// problem rather than a continuation of the last one.
|
|
357
|
+
if (reached > best) {
|
|
358
|
+
best = reached;
|
|
359
|
+
consumed = 0;
|
|
360
|
+
} else {
|
|
361
|
+
consumed += 1;
|
|
362
|
+
}
|
|
327
363
|
console.warn(
|
|
328
364
|
`[fetch] ${url} stopped at ${reached} bytes ` +
|
|
329
|
-
`(
|
|
365
|
+
`(${consumed}/${attempts} consecutive without progress): ` +
|
|
366
|
+
error.message,
|
|
330
367
|
);
|
|
331
|
-
if (
|
|
332
|
-
|
|
368
|
+
if (consumed >= attempts) break;
|
|
369
|
+
// Growing with each consecutive failure, so a budget spans an outage
|
|
370
|
+
// rather than a moment: at the default of 30s this waits 30, 60, 90 …
|
|
371
|
+
// and ten of them cover something over twenty minutes. A flat delay made
|
|
372
|
+
// ten attempts worth about forty-five seconds, which is shorter than
|
|
373
|
+
// most of the interruptions it exists to survive.
|
|
374
|
+
await delay(retryDelayMs * consumed, signal);
|
|
333
375
|
}
|
|
334
376
|
}
|
|
335
377
|
|
|
378
|
+
// Says what was reached as well as what failed. What matters when this lands
|
|
379
|
+
// is whether there is anything worth resuming, and the byte count is the
|
|
380
|
+
// whole of that answer -- the partial file is kept, so re-adding the same URL
|
|
381
|
+
// continues from here rather than starting again.
|
|
336
382
|
throw new Error(
|
|
337
|
-
`could not finish downloading ${url} after ${
|
|
338
|
-
|
|
383
|
+
`could not finish downloading ${url} after ${consumed} consecutive ` +
|
|
384
|
+
`attempts without progress (${best} bytes transferred, kept for a ` +
|
|
385
|
+
`resume): ${lastError?.message ?? 'unknown error'}`,
|
|
339
386
|
);
|
|
340
387
|
}
|
|
341
388
|
|