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 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.24.4",
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
- entry = await library.addRemoteArchive(body.url, options);
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
- /** How long to wait before resuming a download that stopped. */
330
- fetchRetrySeconds: 5,
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) return 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(root, INCOMING, crypto.randomBytes(8).toString('hex'))
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
- if (staging)
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
 
@@ -230,7 +230,31 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
230
230
  let lastReport = 0;
231
231
  let lastError;
232
232
 
233
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
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
- if (attempt === attempts) break;
253
- await delay(retryDelayMs, signal);
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
- `(attempt ${attempt}/${attempts}): ${error.message}`,
365
+ `(${consumed}/${attempts} consecutive without progress): ` +
366
+ error.message,
330
367
  );
331
- if (attempt === attempts) break;
332
- await delay(retryDelayMs, signal);
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 ${attempts} attempts: ` +
338
- `${lastError?.message ?? 'unknown error'}`,
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