pmtiles-swarm 0.4.6 → 0.5.1

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,50 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.5.1
11
+ ### ✨ Features and improvements
12
+ - **An archive joined by magnet writes its own `.torrent` on a libtorrent node.** The machinery
13
+ was already there — `captureMetadata` writes the metainfo down, and completion sweeps retry it
14
+ for anything still missing one — but it asks the engine for the metainfo, and the libtorrent
15
+ engine had no way to give it. Only WebTorrent did, and WebTorrent only ever receives archives
16
+ that are already complete. So on the engine most people run, a magnet-joined archive could
17
+ never produce a `.torrent`, however long it ran.
18
+
19
+ That is one gap with a long tail: a node with no `.torrent` to publish serves a feed whose
20
+ enclosure URLs 404, so its subscribers join by magnet too, and a magnet carrying no trackers
21
+ has only the DHT to find its first peer with. Needs `pmtiles-torrent` 0.4.0, which added the
22
+ op; against an older sidecar the call is refused and caught, exactly as before.
23
+
24
+ ### 🐞 Bug fixes
25
+ - **A head-warm that was simply too early no longer waits out the full backoff.** An archive
26
+ joined by magnet has no metainfo until BEP 9 finishes, so a read asked for in the same second
27
+ the node started is refused before it reaches the swarm at all. That is a wait, not an
28
+ attempt: it is now retried on the next pass rather than in two minutes, and reported once
29
+ rather than every time.
30
+
31
+ ## 0.5.0
32
+ ### ✨ Features and improvements
33
+ - **The head of a newly joined archive is read without waiting to be asked.** A PMTiles archive
34
+ is useless until its 127-byte header has been read: it names where the root directory and the
35
+ JSON metadata live, and reading it raises both to a high piece priority — so the head of the
36
+ file arrives out of order instead of whenever a download happens to reach byte zero. That
37
+ machinery existed and was spec-correct, but only ran when something read the archive, and the
38
+ backfill that would have followed up began by requiring a summary to already exist. A freshly
39
+ joined archive has none, and the one thing that would have created one was the TileJSON route,
40
+ which is exactly what fails without a header. So an archive being mirrored stayed unservable
41
+ for hours while the few kilobytes that would have made it servable sat at position zero.
42
+
43
+ It now reads one archive's head at a time — several at once turn a queue of archives into a
44
+ queue of stalled reads competing for the same bandwidth — with the long metadata timeout
45
+ rather than the interactive one, backing off between attempts, because a young archive having
46
+ no peer that holds its first piece is ordinary rather than exceptional. It comes back for
47
+ vector layers separately, since a writer may put the JSON metadata after every tile and one
48
+ read routinely gets the header and not the metadata.
49
+
50
+ New under `tiles`: `prewarm` (default true), `prewarmIntervalSeconds` (30) and
51
+ `prewarmBackoffSeconds` (120). Turn it off on a node that distributes archives but never
52
+ serves tiles from them.
53
+
10
54
  ## 0.4.6
11
55
  ### ✨ Features and improvements
12
56
  - **A feed's categories are a list, and are called categories.** The console offered a single
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
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
@@ -444,6 +444,29 @@ const DEFAULTS = {
444
444
  * like a hang rather than like "not yet".
445
445
  */
446
446
  headerTimeoutMs: 12000,
447
+ /**
448
+ * Read the head of a newly joined archive without waiting to be asked.
449
+ *
450
+ * A PMTiles archive is useless until its header has been read: it names
451
+ * where the root directory and the JSON metadata are, and reading it also
452
+ * raises those to a high piece priority, so the head of the file arrives
453
+ * out of order rather than whenever a download happens to reach it. Left
454
+ * to the first request, an archive being mirrored is unservable for hours
455
+ * while the bytes that would make it servable sit at position zero.
456
+ *
457
+ * Off makes sense on a node that only distributes and never serves tiles.
458
+ */
459
+ prewarm: true,
460
+ /** How often to look for an archive whose head has not been read. */
461
+ prewarmIntervalSeconds: 30,
462
+ /**
463
+ * How long to leave an archive alone after a failed read.
464
+ *
465
+ * Failure here is ordinary rather than exceptional — a brand-new archive
466
+ * may have no peer holding its first piece yet — so this is a retry
467
+ * interval, not an error budget.
468
+ */
469
+ prewarmBackoffSeconds: 120,
447
470
  /**
448
471
  * What a missing tile answers with: true for 404, false for 204.
449
472
  *
@@ -324,16 +324,32 @@ export class LibtorrentEngine {
324
324
  return this.#call('peers', { infoHash });
325
325
  }
326
326
 
327
+ /**
328
+ * The metainfo of a torrent, so a magnet can stop being a magnet.
329
+ * @param {string} infoHash - The archive.
330
+ * @returns {Promise<Uint8Array|null>} - The .torrent bytes, or null.
331
+ */
332
+ async metadata(infoHash) {
333
+ // The same thing a torrent client's "export .torrent" offers, and for the
334
+ // same reason: once BEP 9 has delivered the info dictionary, this node
335
+ // holds everything a .torrent contains — whether or not a byte of the
336
+ // archive itself has arrived.
337
+ //
338
+ // Without it a node that joined by magnet has no .torrent to publish, so
339
+ // every subscriber following its feed also joins by magnet; and a magnet
340
+ // that carries no trackers has only the DHT to find its first peer with,
341
+ // which is minutes of waiting per archive rather than none. libtorrent
342
+ // could always do this. Nothing had asked it to.
343
+ const result = await this.#call('metadata', { infoHash });
344
+ if (!result?.torrentFile) return null;
345
+ return new Uint8Array(Buffer.from(result.torrentFile, 'base64'));
346
+ }
347
+
327
348
  /**
328
349
  * Creates a torrent from a local file.
329
- *
330
- * Defaults to hybrid v1+v2, which is the capability that justifies this
331
- * engine: v2 gives per-file merkle trees with 16 KiB leaf blocks, so a peer
332
- * can verify a small block without holding the whole hash list, while the v1
333
- * half keeps every existing client working.
334
- * @param {string} filePath - Path to the archive.
350
+ * @param {string} filePath - The file to hash.
335
351
  * @param {object} [options] - Piece length, trackers, web seeds, format.
336
- * @returns {Promise<object>} - The created torrent, torrentFile as bytes.
352
+ * @returns {Promise<object>} - The torrent file and what it describes.
337
353
  */
338
354
  async createTorrent(filePath, options = {}) {
339
355
  const result = await this.#call(
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ import { closeServer, installSignalHandlers, runStoppers } from './shutdown.js';
19
19
  import { ScheduledSourceManager } from './sources.js';
20
20
  import { SubscriptionManager } from './subscriptions.js';
21
21
  import { TileStore } from './tiles.js';
22
+ import { HeadWarmer } from './prewarm.js';
22
23
  import { WarmRunner } from './warm.js';
23
24
  import { WatchManager } from './watch.js';
24
25
 
@@ -216,6 +217,12 @@ PMTILES_SWARM_PUBLIC_URL
216
217
  library.attachTiles(tiles);
217
218
  const warm = new WarmRunner(tiles);
218
219
 
220
+ // Reads the head of anything joined but not yet understood — the header,
221
+ // then the root directory and metadata it points at. Without this an archive
222
+ // being mirrored is unservable until the download happens to reach byte
223
+ // zero, and the request that would have read it times out long before.
224
+ const headWarmer = new HeadWarmer(tiles, catalog, config);
225
+
219
226
  // Restarting one subsystem, rather than the process, for the settings that
220
227
  // only that subsystem reads. Each stops and starts from the live config, so
221
228
  // nothing here has to know what changed — only what to rebuild.
@@ -337,6 +344,11 @@ PMTILES_SWARM_PUBLIC_URL
337
344
  }
338
345
  hooks.start();
339
346
  completion.start();
347
+ // The header is 127 bytes and names where the root directory and the JSON
348
+ // metadata live (PMTiles v3 spec, fields at offsets 8 and 24). Reading it
349
+ // raises both to a high piece priority, so an archive becomes servable in
350
+ // seconds rather than at whatever hour the download reaches byte zero.
351
+ headWarmer.start();
340
352
 
341
353
  // Watch the sources archives were built from. A changed source does not
342
354
  // invalidate its torrent, but it does mean any web seed pointing there will
@@ -375,6 +387,7 @@ PMTILES_SWARM_PUBLIC_URL
375
387
  completion.stop();
376
388
  subscriptions.stop();
377
389
  warm.stop();
390
+ headWarmer.stop();
378
391
  }, ms: 1000 },
379
392
  { label: 'watchers', stop: () => watch.stop() },
380
393
  {
package/src/prewarm.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Reading the head of a newly joined archive, before anybody asks for it.
3
+ *
4
+ * A PMTiles archive is useless until its header has been read: the header is
5
+ * the first 127 bytes and names where the root directory and the JSON metadata
6
+ * live, and without it there is no TileJSON, no vector layers, and a preview
7
+ * that renders black. Reading it also *prioritises* what it found — the source
8
+ * hints the root directory as critical and the metadata as high — so the head
9
+ * of the file arrives out of order rather than whenever the download reaches
10
+ * it.
11
+ *
12
+ * None of that happened on its own. The read is on the interactive path, so it
13
+ * ran when somebody opened the archive; and the backfill that follows it up
14
+ * required a summary to already exist, which is precisely what a freshly joined
15
+ * archive does not have. So the first request paid for the header, and if it
16
+ * timed out first — which against a 72 GiB mirror with no web seed it does —
17
+ * nothing ever tried again.
18
+ *
19
+ * A mirror gets there eventually by downloading everything. The point of doing
20
+ * it deliberately is that "eventually" is hours, and the archive is servable in
21
+ * the first few seconds if the right few kilobytes are asked for first.
22
+ */
23
+
24
+ /** How long to leave an archive alone after a failed attempt. */
25
+ const DEFAULT_BACKOFF_MS = 120000;
26
+
27
+ /**
28
+ * Whether a failure means "not yet" rather than "not working".
29
+ *
30
+ * An archive joined from a magnet has no metainfo until BEP 9 has finished, and
31
+ * until then the engine cannot say where a byte range even falls — so a read
32
+ * asked for in the same second the node started is refused before it reaches
33
+ * the swarm at all. That is a wait, not an attempt, and treating it as one cost
34
+ * the full backoff for something that resolves in seconds.
35
+ *
36
+ * Matched on the message because that is what the engine gives us; both the
37
+ * sidecar and the WebTorrent engine word it the same way.
38
+ * @param {Error} error - What the read threw.
39
+ * @returns {boolean} - True when it is worth trying again shortly.
40
+ */
41
+ function tooEarly(error) {
42
+ return /metadata has not arrived|not held here/i.test(error?.message ?? '');
43
+ }
44
+
45
+ export class HeadWarmer {
46
+ #tiles;
47
+ #catalog;
48
+ #config;
49
+ #now;
50
+ #timer;
51
+ #tried = new Map();
52
+ #waiting = new Set();
53
+ #running = false;
54
+
55
+ /**
56
+ * @param {object} tiles - The tile store, for `summarize`.
57
+ * @param {object} catalog - Where the summary is written.
58
+ * @param {object} config - Resolved configuration.
59
+ * @param {Function} [now] - Clock, for testing.
60
+ */
61
+ constructor(tiles, catalog, config, now = () => Date.now()) {
62
+ this.#tiles = tiles;
63
+ this.#catalog = catalog;
64
+ this.#config = config;
65
+ this.#now = now;
66
+ }
67
+
68
+ /** @returns {boolean} - Whether this node can and should warm anything. */
69
+ get enabled() {
70
+ return (
71
+ this.#config.tiles?.prewarm !== false &&
72
+ typeof this.#tiles?.summarize === 'function'
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Whether an archive is worth reading the head of right now.
78
+ *
79
+ * Deliberately narrow: an archive that has been summarised is done, unless it
80
+ * is vector and its layers are still missing — that section sits at the far
81
+ * end of the file and routinely arrives later than the header.
82
+ * @param {object} entry - A catalog entry.
83
+ * @returns {boolean} - True to attempt a read.
84
+ */
85
+ due(entry) {
86
+ // Only PMTiles has a head worth reading. A .osm.pbf from a feed is not an
87
+ // archive this can say anything about.
88
+ if (entry.kind && entry.kind !== 'pmtiles') return false;
89
+
90
+ const summary = entry.pmtiles;
91
+ if (summary && (summary.format !== 'pbf' || summary.vectorLayers)) {
92
+ return false;
93
+ }
94
+
95
+ const last = this.#tried.get(entry.infoHash) ?? 0;
96
+ const backoff = (this.#config.tiles?.prewarmBackoffSeconds ?? 120) * 1000;
97
+ return this.#now() - last >= (backoff || DEFAULT_BACKOFF_MS);
98
+ }
99
+
100
+ /**
101
+ * Reads the head of one archive that needs it.
102
+ *
103
+ * One per pass on purpose. Each read is a byte range fetched out of a swarm
104
+ * that may have no peer holding it yet, and starting several at once turns a
105
+ * queue of archives into a queue of stalled reads competing for the same
106
+ * bandwidth — the same mistake as prefetching leaf directories eagerly, which
107
+ * measured four times slower than not bothering.
108
+ * @returns {Promise<object|null>} - The entry warmed, or null.
109
+ */
110
+ async sweep() {
111
+ if (!this.enabled || this.#running) return null;
112
+
113
+ const entry = this.#catalog.list().find((candidate) => this.due(candidate));
114
+ if (!entry) return null;
115
+
116
+ this.#running = true;
117
+ try {
118
+ // The long timeout, not the interactive one: this is a byte range from
119
+ // an archive nobody has asked for a piece of yet.
120
+ const summary = await this.#tiles.summarize(entry.infoHash, {
121
+ timeoutMs: this.#config.tiles?.metadataTimeoutMs ?? 120000,
122
+ });
123
+
124
+ this.#tried.set(entry.infoHash, this.#now());
125
+ this.#waiting.delete(entry.infoHash);
126
+
127
+ const stored = await this.#catalog.put({
128
+ infoHash: entry.infoHash,
129
+ pmtiles: { ...entry.pmtiles, ...summary },
130
+ });
131
+ console.log(
132
+ `[warm] read the head of ${entry.name}` +
133
+ (summary.vectorLayers
134
+ ? ` (${summary.vectorLayers.length} vector layers)`
135
+ : ''),
136
+ );
137
+ return stored;
138
+ } catch (error) {
139
+ if (tooEarly(error)) {
140
+ // Not stamped, so the next pass tries again in seconds rather than in
141
+ // minutes — and said once, because a node that has just started will
142
+ // answer this way until the metainfo lands.
143
+ if (!this.#waiting.has(entry.infoHash)) {
144
+ this.#waiting.add(entry.infoHash);
145
+ console.log(
146
+ `[warm] ${entry.name}: waiting for the torrent metadata before ` +
147
+ 'reading its head',
148
+ );
149
+ }
150
+ return null;
151
+ }
152
+
153
+ // A real attempt: it reached the swarm and found nothing. Ordinary while
154
+ // an archive is young, since the piece holding the header may not exist
155
+ // anywhere reachable yet. Worth saying, because it is the answer to "why
156
+ // is my preview blank", and the backoff keeps it from becoming noise.
157
+ this.#tried.set(entry.infoHash, this.#now());
158
+ this.#waiting.delete(entry.infoHash);
159
+ console.warn(`[warm] ${entry.name}: ${error.message}`);
160
+ return null;
161
+ } finally {
162
+ this.#running = false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Starts warming, and keeps at it.
168
+ * @returns {void}
169
+ */
170
+ start() {
171
+ if (!this.enabled) return;
172
+ const seconds = this.#config.tiles?.prewarmIntervalSeconds ?? 30;
173
+ if (seconds <= 0) return;
174
+
175
+ const run = () =>
176
+ this.sweep().catch((error) =>
177
+ console.error(`[warm] sweep failed: ${error.message}`),
178
+ );
179
+ run();
180
+ this.#timer = setInterval(run, seconds * 1000);
181
+ this.#timer.unref?.();
182
+ }
183
+
184
+ /** Stops warming. @returns {void} */
185
+ stop() {
186
+ if (this.#timer) clearInterval(this.#timer);
187
+ this.#timer = undefined;
188
+ }
189
+ }