pmtiles-swarm 0.24.4 → 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 CHANGED
@@ -7,6 +7,36 @@
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
+
10
40
  ## 0.24.4
11
41
  ### 🐞 Bug fixes
12
42
  - **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.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
- /** 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
@@ -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(root, INCOMING, crypto.randomBytes(8).toString('hex'))
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
- if (staging)
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
 
@@ -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