pmtiles-swarm 0.24.4 → 0.26.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 +53 -0
- package/package.json +1 -1
- package/src/api.js +34 -1
- package/src/config.js +15 -2
- package/src/library.js +54 -6
- package/src/torrent-create.js +55 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.26.0
|
|
11
|
+
### 🐞 Bug fixes
|
|
12
|
+
- **The add dialog stayed on screen for the length of a download.** `POST /api/torrents` awaited
|
|
13
|
+
the entire transfer before answering, so for a URL the response arrived hours after the request
|
|
14
|
+
— and the console, which closes the dialog when the response lands, sat there over an archive
|
|
15
|
+
visibly appearing behind it.
|
|
16
|
+
|
|
17
|
+
The console was always written for the other arrangement: it says "fetching — watch the log" as
|
|
18
|
+
it closes, polls `/api/adds` for progress, and offers `DELETE /api/adds` to cancel. Only the
|
|
19
|
+
route was missing. It now answers **202** as soon as the URL has been checked — it answers, it is
|
|
20
|
+
an archive of a publishable kind, it is not a credential about to be broadcast — and lets the
|
|
21
|
+
transfer run behind it. Everything a person can correct is still reported in the dialog, because
|
|
22
|
+
all of it is found before the first byte moves.
|
|
23
|
+
|
|
24
|
+
Both shortcuts inside `addRemoteArchive` had to be taught the same signal. A URL already in the
|
|
25
|
+
catalog, or one already being fetched by somebody else, returns without ever reaching the checks
|
|
26
|
+
— so a response waiting on them would have waited for something that had already happened, or
|
|
27
|
+
for the whole of a download another caller had started.
|
|
28
|
+
|
|
29
|
+
`url` bodies now answer 202 with an acknowledgement rather than 201 with the finished entry.
|
|
30
|
+
Paths, magnets and `.torrent` URLs are unchanged: they were always fast, and still answer 201
|
|
31
|
+
with the entry.
|
|
32
|
+
|
|
33
|
+
## 0.25.0
|
|
34
|
+
### ✨ Features and improvements
|
|
35
|
+
- **A stopped download is kept, and adding the same URL again resumes it.** Staging directories
|
|
36
|
+
were named at random, so a partial transfer became unreachable the moment the add returned:
|
|
37
|
+
nothing knew where it was, and re-adding the URL opened a fresh directory beside it and started
|
|
38
|
+
from zero. They are now named from the URL, and a fetch that runs out of attempts leaves its
|
|
39
|
+
bytes in place rather than deleting them — so the second add finds the first one's work and
|
|
40
|
+
continues with a Range request. Cancelling still removes them: somebody said stop, and leaving
|
|
41
|
+
hundreds of gigabytes behind after that is the waste the deletion was written to avoid.
|
|
42
|
+
|
|
43
|
+
Note the disk consequence. A download abandoned for good now keeps its partial file until the
|
|
44
|
+
directory is removed by hand; the give-up message and a log line both name the path.
|
|
45
|
+
|
|
46
|
+
### 🐞 Bug fixes
|
|
47
|
+
- **Ten network blips ended an 800 GB download, whatever it had achieved.** Two faults, and the
|
|
48
|
+
attempt count was neither of them.
|
|
49
|
+
|
|
50
|
+
The budget counted every failure rather than consecutive failures that transferred nothing, so it
|
|
51
|
+
described the whole download instead of the trouble it was in. Observed in the field: 226 GB
|
|
52
|
+
across six separate stalls, then the remaining four spent inside one bad minute, because a
|
|
53
|
+
quarter of a terabyte of progress counted for nothing. An attempt that moves bytes has reached
|
|
54
|
+
the source and got data out of it, so whatever it hits next is new trouble — progress now clears
|
|
55
|
+
the count, against a high-water mark so a short attempt after a long one is not mistaken for it.
|
|
56
|
+
A ceiling on total attempts keeps that from becoming an unbounded loop.
|
|
57
|
+
|
|
58
|
+
And the wait between attempts was flat, so ten of them covered about forty-five seconds — shorter
|
|
59
|
+
than most of the interruptions they exist to survive. It now grows with each consecutive failure
|
|
60
|
+
and the base moves from 5 seconds to 30, which spans something over twenty minutes rather than
|
|
61
|
+
under one.
|
|
62
|
+
|
|
10
63
|
## 0.24.4
|
|
11
64
|
### 🐞 Bug fixes
|
|
12
65
|
- **The feed advertised a .torrent nobody could fetch.** Every item named
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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/api.js
CHANGED
|
@@ -1387,7 +1387,40 @@ export function createApp({
|
|
|
1387
1387
|
if (body.path) {
|
|
1388
1388
|
entry = await library.addLocalArchive(body.path, options);
|
|
1389
1389
|
} else if (body.url) {
|
|
1390
|
-
|
|
1390
|
+
// Answered as soon as the URL has been checked, not when the transfer
|
|
1391
|
+
// finishes. Awaiting the whole thing held the response open for the
|
|
1392
|
+
// length of the download -- hours for a planet archive -- so the
|
|
1393
|
+
// console's add dialog stayed on screen throughout, over an archive
|
|
1394
|
+
// that was visibly appearing behind it.
|
|
1395
|
+
//
|
|
1396
|
+
// Progress has its own route already: runningAdds() feeds /api/adds,
|
|
1397
|
+
// the console polls it, and DELETE /api/adds cancels one. This is the
|
|
1398
|
+
// piece that was missing rather than a new mechanism.
|
|
1399
|
+
const validated = Promise.withResolvers();
|
|
1400
|
+
const running = library
|
|
1401
|
+
.addRemoteArchive(body.url, {
|
|
1402
|
+
...options,
|
|
1403
|
+
onValidated: validated.resolve,
|
|
1404
|
+
})
|
|
1405
|
+
// A failure after validation has nowhere to be reported: the
|
|
1406
|
+
// response has gone. It is logged where the rest of the fetch is,
|
|
1407
|
+
// and swallowed here so it cannot take the process down as an
|
|
1408
|
+
// unhandled rejection.
|
|
1409
|
+
.catch((error) => {
|
|
1410
|
+
validated.reject(error);
|
|
1411
|
+
console.error(`[fetch] ${body.url}: ${error.message}`);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// Whichever comes first: the checks passing, or the whole attempt
|
|
1415
|
+
// failing. A URL that does not answer, or is not an archive, still
|
|
1416
|
+
// reports itself in the dialog where somebody can correct it.
|
|
1417
|
+
await validated.promise;
|
|
1418
|
+
void running;
|
|
1419
|
+
return res.status(202).json({
|
|
1420
|
+
accepted: true,
|
|
1421
|
+
url: body.url,
|
|
1422
|
+
message: 'fetching; progress is reported by /api/adds',
|
|
1423
|
+
});
|
|
1391
1424
|
} else if (body.magnet) {
|
|
1392
1425
|
entry = await library.addExistingTorrent(
|
|
1393
1426
|
{ magnet: body.magnet },
|
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/library.js
CHANGED
|
@@ -541,8 +541,15 @@ export class Library {
|
|
|
541
541
|
* @returns {Promise<object>} - The catalog entry.
|
|
542
542
|
*/
|
|
543
543
|
async addRemoteArchive(url, options = {}) {
|
|
544
|
+
// Both shortcuts below have to fire onValidated before returning, and for
|
|
545
|
+
// the same reason: a caller waiting on it to answer a request would
|
|
546
|
+
// otherwise wait for something that has already happened, or -- worse --
|
|
547
|
+
// for the whole of a download somebody else started.
|
|
544
548
|
const existing = this.#catalog.findBySource(url);
|
|
545
|
-
if (existing)
|
|
549
|
+
if (existing) {
|
|
550
|
+
options.onValidated?.({ url, kind: existing.kind, held: true });
|
|
551
|
+
return existing;
|
|
552
|
+
}
|
|
546
553
|
|
|
547
554
|
// A second request for a URL already being fetched joins the first; the
|
|
548
555
|
// catalog cannot answer this, since an entry exists only once the download
|
|
@@ -550,6 +557,7 @@ export class Library {
|
|
|
550
557
|
const inFlight = this.#inFlight.get(url);
|
|
551
558
|
if (inFlight) {
|
|
552
559
|
console.log(`[fetch] ${url} is already being fetched; joining that one`);
|
|
560
|
+
options.onValidated?.({ url, joined: true });
|
|
553
561
|
return inFlight;
|
|
554
562
|
}
|
|
555
563
|
|
|
@@ -590,6 +598,18 @@ export class Library {
|
|
|
590
598
|
allowUnknown: options.allowUnknown ?? this.#config.allowUnknownArchives,
|
|
591
599
|
});
|
|
592
600
|
|
|
601
|
+
// Everything a caller can do something about has now been checked: the URL
|
|
602
|
+
// answers, it is an archive of a kind this will publish, and it is not a
|
|
603
|
+
// credential being broadcast to a swarm. What remains is the transfer,
|
|
604
|
+
// which for a planet archive is hours.
|
|
605
|
+
//
|
|
606
|
+
// A caller that wants to stop waiting there says so with onValidated. That
|
|
607
|
+
// is the difference between a dialog that closes on a bad URL with the
|
|
608
|
+
// reason in it, and one that sits open for the length of the download --
|
|
609
|
+
// and the console was always written for the former, since it reports
|
|
610
|
+
// progress through runningAdds() and says "watch the log" as it closes.
|
|
611
|
+
options.onValidated?.({ url, kind: identified.kind });
|
|
612
|
+
|
|
593
613
|
const summary =
|
|
594
614
|
identified.kind === 'pmtiles'
|
|
595
615
|
? await probePMTiles(url).catch(() => undefined)
|
|
@@ -606,8 +626,22 @@ export class Library {
|
|
|
606
626
|
// Downloaded into a directory of its own, then moved once the infohash
|
|
607
627
|
// exists to be filed under. See docs/internals.md — "Staging, for an
|
|
608
628
|
// archive fetched from a URL".
|
|
629
|
+
// Named from the URL rather than at random, so a download that stopped can
|
|
630
|
+
// be picked up again.
|
|
631
|
+
//
|
|
632
|
+
// A random name made every add its own directory, which meant a partial
|
|
633
|
+
// transfer was unreachable the moment the add returned: nothing knew where
|
|
634
|
+
// it was, and re-adding the same URL opened a fresh directory beside it and
|
|
635
|
+
// started from zero. For a planet archive that is hundreds of gigabytes
|
|
636
|
+
// thrown away because a network went out for a few minutes. Derived from
|
|
637
|
+
// the URL, the second add finds the first one's bytes and downloadTo
|
|
638
|
+
// resumes with a Range request.
|
|
609
639
|
const staging = retain
|
|
610
|
-
? path.join(
|
|
640
|
+
? path.join(
|
|
641
|
+
root,
|
|
642
|
+
INCOMING,
|
|
643
|
+
crypto.createHash('sha256').update(url).digest('hex').slice(0, 16),
|
|
644
|
+
)
|
|
611
645
|
: undefined;
|
|
612
646
|
|
|
613
647
|
// The origin is a valid web seed for exactly these bytes, so it is used as
|
|
@@ -658,12 +692,26 @@ export class Library {
|
|
|
658
692
|
},
|
|
659
693
|
});
|
|
660
694
|
} 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
695
|
this.#running.delete(url);
|
|
665
|
-
|
|
696
|
+
// Cancelling is a decision to stop wanting this; running out of attempts
|
|
697
|
+
// is not. The two used to be cleaned up identically, so a download that
|
|
698
|
+
// survived six stalls and reached 226 GB had all of it deleted by the
|
|
699
|
+
// seventh -- and the resume that the staging directory exists to make
|
|
700
|
+
// possible had nothing left to resume from.
|
|
701
|
+
//
|
|
702
|
+
// A cancelled fetch is still removed. Somebody said stop, and leaving
|
|
703
|
+
// gigabytes behind after that is the invisible waste this was written to
|
|
704
|
+
// avoid in the first place.
|
|
705
|
+
const cancelled = controller.signal.aborted;
|
|
706
|
+
if (staging && cancelled) {
|
|
666
707
|
await fs.rm(staging, { recursive: true, force: true }).catch(() => {});
|
|
708
|
+
} else if (staging) {
|
|
709
|
+
console.warn(
|
|
710
|
+
`[fetch] keeping the partial download in ${staging}; adding ${url} ` +
|
|
711
|
+
'again resumes it, and discarding it is a matter of removing that ' +
|
|
712
|
+
'directory',
|
|
713
|
+
);
|
|
714
|
+
}
|
|
667
715
|
throw error;
|
|
668
716
|
}
|
|
669
717
|
|
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
|
|