pmtiles-swarm 0.23.0 → 0.24.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
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## master
4
4
  ### ✨ Features and improvements
5
+ - _...Add new stuff here..._
6
+
7
+ ### 🐞 Bug fixes
8
+ - _...Add new stuff here..._
9
+
10
+ ## 0.24.0
11
+ ### ✨ Features and improvements
12
+ - **A mirror now inherits the archive summary from the feed it follows.** `renderItem` has always
13
+ published `pmtiles:format`, the zoom range, the bounds and the tile count, and `parseFeed` has
14
+ always thrown them away — so the fact that a feed is more useful than a generic torrent feed was
15
+ true of the XML and of nothing else. It matters more than it sounds: an archive is servable when
16
+ `entry.pmtiles` exists, so a fresh mirror served nothing at all until it had read the header out
17
+ of the swarm itself. On an 80 GiB planet archive whose only seed is busy that is hours of a node
18
+ that looks joined, downloads steadily, and answers every tile request with 400. The summary now
19
+ comes across with the item and the archive is servable the moment it is added. It is marked
20
+ `source: 'feed'`, because a summary taken on trust is not the same fact as one read off the
21
+ header, and the head warmer still replaces it with the latter as soon as it can read one.
5
22
  - **A watched folder can ask for its stable name to be a hard link.**
6
23
  `latestLinkType: "hard"` reverses the order the two kinds are attempted in,
7
24
  for a folder whose archives are read *through* that name rather than followed
@@ -84,6 +101,27 @@
84
101
  never checked.
85
102
 
86
103
  ### 🐞 Bug fixes
104
+ - **Head warming now says whether it is running.** Every way it could decline was a bare `return`:
105
+ `tiles.prewarm` false, `prewarmIntervalSeconds` at zero, a node with no tile store to read with,
106
+ and — the one that actually bites — a pass that finds no archive eligible to warm. All four
107
+ produced an identical empty log, so "warming is switched off" and "warming is working and has
108
+ nothing to do" could not be told apart, and on a node whose mirrors were stuck at 400 the only
109
+ way to tell was to read the source. It now names the reason at startup, or says how often it will
110
+ look; and after ten idle passes it says either that everything has been summarised or which
111
+ archives it is skipping for want of a recognised kind. That last case is what an archive joined by
112
+ magnet looks like before its metainfo arrives: `guessKind` cannot tell it is PMTiles, `due`
113
+ refuses it, and nothing said so.
114
+ - **One archive whose metainfo never arrived stopped every other archive being warmed.** A sweep
115
+ warms a single archive, chosen as the first one due, and an archive joined by magnet that has no
116
+ metainfo yet is answered "not yet" and deliberately left unstamped so the next pass retries in
117
+ seconds rather than after the full backoff. The two together meant an archive stuck that way
118
+ stayed due at no cost, was chosen again on every pass, and its neighbours were never attempted at
119
+ all — for as long as it was stuck, which where no peer ever answers is indefinitely. A node with
120
+ two mirrors could therefore warm neither, having genuinely tried only one. The fast retry is now
121
+ bounded: after five consecutive passes the wait has plainly stopped being nearly over, and it is
122
+ charged as an attempt like any other so the backoff spreads them out and lets its neighbours
123
+ through. It says so when it makes that switch, which is otherwise the quietest moment in the
124
+ process — the point where an archive goes from "about to work" to "may never work".
87
125
  - **A mutable magnet had nowhere to announce.** `mutableMagnet()` was never passed trackers, so the
88
126
  BEP 46 form carried `xt`, `xs`, `dn`, `s` and `ws` and no `tr=` at all. The infohash added in
89
127
  0.21.0 was therefore unusable from a browser, which has neither DHT nor peer exchange to fall
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.23.0",
3
+ "version": "0.24.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/feed.js CHANGED
@@ -165,8 +165,62 @@ export function formatBytes(bytes) {
165
165
  * @property {string} [category] - Item category.
166
166
  * @property {string} [mtime] - The archive's mtime on the node that built it.
167
167
  * @property {string} [mutableMagnet] - BEP 46 magnet, when the publisher has an identity for it.
168
+ * @property {object} [pmtiles] - Coverage summary, when the feed carries one.
168
169
  */
169
170
 
171
+ /**
172
+ * Reads the `pmtiles:*` summary out of a feed item, when it has one.
173
+ *
174
+ * renderItem publishes format, zoom range, bounds, tile count and attribution
175
+ * for exactly this — so a subscriber can tell what an archive holds before
176
+ * committing to 80 GiB of it. For a long time nothing read them back, which had
177
+ * a consequence well beyond the feed being less informative than it looked: a
178
+ * mirror's `servable` flag is `Boolean(entry.pmtiles)`, so an archive arrived
179
+ * with no summary, was unservable, and stayed unservable until the head warmer
180
+ * managed to read the header out of the swarm — while the answer had been in
181
+ * the feed all along.
182
+ *
183
+ * Deliberately partial. There is no `vectorLayers` here, because the publisher
184
+ * has none to give: planetiler writes that section after every tile, so on a
185
+ * planet archive it is the very end of the file. An entry summarised from a
186
+ * feed therefore stays due for warming, which is what fills the rest in.
187
+ *
188
+ * @param {string} block - The item XML.
189
+ * @returns {object | undefined} - The summary, or undefined if absent.
190
+ */
191
+ function mapSummary(block) {
192
+ const format = tag(block, 'pmtiles:format');
193
+ // Format is the field `servable` and the tile routes key off, so a summary
194
+ // without it is not a summary. Better no object at all than one that reports
195
+ // an archive as readable and then cannot say what is in it.
196
+ if (!format) return undefined;
197
+
198
+ const number = (name) => {
199
+ const raw = tag(block, name);
200
+ if (raw === undefined || raw.trim() === '') return undefined;
201
+ const value = Number(raw);
202
+ return Number.isFinite(value) ? value : undefined;
203
+ };
204
+
205
+ const bounds = (tag(block, 'pmtiles:bounds') ?? '')
206
+ .split(',')
207
+ .map((part) => Number(part.trim()))
208
+ .filter((part) => Number.isFinite(part));
209
+
210
+ return {
211
+ format,
212
+ minZoom: number('pmtiles:minzoom'),
213
+ maxZoom: number('pmtiles:maxzoom'),
214
+ bounds: bounds.length === 4 ? bounds : undefined,
215
+ tileCount: number('pmtiles:tiles'),
216
+ attribution: tag(block, 'pmtiles:attribution'),
217
+ // Says where this came from, because a summary taken on trust from another
218
+ // node is not the same fact as one read off the archive's own header, and
219
+ // the difference matters when the two disagree.
220
+ source: 'feed',
221
+ };
222
+ }
223
+
170
224
  /**
171
225
  * Parses a subscribed feed.
172
226
  *
@@ -222,6 +276,10 @@ export function parseFeed(body) {
222
276
  // more than once, so take all of them.
223
277
  md5: tag(block, 'pmtiles:md5'),
224
278
  mtime: isoDate(tag(block, 'pmtiles:mtime')),
279
+ // What the publisher already knows about the archive's contents. Absent
280
+ // from a generic torrent feed, and absent from ours for an archive the
281
+ // publishing node has not summarised either.
282
+ pmtiles: mapSummary(block),
225
283
  categories: [...block.matchAll(/<category>([\s\S]*?)<\/category>/g)]
226
284
  .map((match) => decode(match[1]).trim())
227
285
  .filter(Boolean),
package/src/library.js CHANGED
@@ -836,6 +836,17 @@ export class Library {
836
836
  complete,
837
837
  // Held until the download finishes, which may be hours away.
838
838
  originMtime: options.originMtime,
839
+ // What the peer that offered this says it holds, where it said anything.
840
+ // The head warmer replaces it with what the archive's own header says as
841
+ // soon as it can read one; until then this is what makes the archive
842
+ // servable at all, since `servable` is simply whether a summary exists.
843
+ //
844
+ // Spread rather than set, because catalog.put merges by spreading the
845
+ // record over the existing one -- and a key present with an undefined
846
+ // value still overwrites. Setting it unconditionally would mean an
847
+ // archive re-added from a feed that carries no summary silently lost the
848
+ // one it had already read for itself.
849
+ ...(options.pmtiles ? { pmtiles: options.pmtiles } : {}),
839
850
  });
840
851
 
841
852
  // The engine's add resolves once metadata is in hand, so for a magnet
package/src/prewarm.js CHANGED
@@ -20,6 +20,39 @@ const DEFAULT_MAX_BACKOFF_SECONDS = 600;
20
20
  /** How long to let the node settle before the first attempt. */
21
21
  const DEFAULT_INITIAL_DELAY_SECONDS = 10;
22
22
 
23
+ /** How often to look for an archive that needs its head read. */
24
+ const DEFAULT_INTERVAL_SECONDS = 30;
25
+
26
+ /**
27
+ * How many consecutive passes may find nothing to warm before saying so.
28
+ *
29
+ * Silence is the right output for a node whose archives are all summarised, and
30
+ * it is also what a node produces when every archive is being skipped for a
31
+ * reason nobody can see — an entry whose kind is not yet known, most often,
32
+ * because it was joined by magnet and its metainfo has not arrived. One line
33
+ * after a few minutes of that separates the two without turning a healthy node
34
+ * into a log generator.
35
+ */
36
+ const IDLE_PASSES_BEFORE_REPORTING = 10;
37
+
38
+ /**
39
+ * How many passes in a row an archive may claim on the strength of "not yet".
40
+ *
41
+ * An archive joined from a magnet has no metainfo until BEP 9 finishes, which
42
+ * is usually seconds, so charging it the full backoff wastes a wait that was
43
+ * nearly over — hence the fast retry. What that must not do is cost nothing for
44
+ * ever. A pass warms one archive, chosen as the *first* that is due, and an
45
+ * entry that stays due at no cost is chosen again on the next pass and on every
46
+ * pass after it. One archive stuck this way therefore stops every other archive
47
+ * on the node from being warmed at all, silently, and for as long as it is
48
+ * stuck — which on a node whose peer never answers is indefinitely.
49
+ *
50
+ * Past this many passes the wait has plainly stopped being nearly over, and it
51
+ * is charged as an attempt like any other so the backoff can spread the
52
+ * attempts out and let its neighbours through.
53
+ */
54
+ const MAX_CONSECUTIVE_EARLY_PASSES = 5;
55
+
23
56
  /**
24
57
  * Whether a failure means "not yet" rather than "not working".
25
58
  *
@@ -47,8 +80,11 @@ export class HeadWarmer {
47
80
  #tried = new Map();
48
81
  #attempts = new Map();
49
82
  #waiting = new Set();
83
+ /** Consecutive passes each archive has answered "not yet" to. */
84
+ #early = new Map();
50
85
  #running = false;
51
86
  #runningSince = 0;
87
+ #idlePasses = 0;
52
88
 
53
89
  /**
54
90
  * @param {object} tiles - The tile store, for `summarize`.
@@ -71,6 +107,31 @@ export class HeadWarmer {
71
107
  );
72
108
  }
73
109
 
110
+ /**
111
+ * Why this node is not warming, or null when it is.
112
+ *
113
+ * Exists so `start()` can say which of its several reasons applied. They are
114
+ * each a bare `return` on a condition, and the difference between them was
115
+ * invisible: a node with warming switched off, a node whose interval was set
116
+ * to zero, and a node warming perfectly well with nothing to do all produced
117
+ * exactly the same empty log.
118
+ * @returns {string | null} - The reason, or null.
119
+ */
120
+ get disabledReason() {
121
+ if (this.#config.tiles?.prewarm === false) {
122
+ return 'tiles.prewarm is false';
123
+ }
124
+ if (typeof this.#tiles?.summarize !== 'function') {
125
+ return 'this node has no tile store to read headers with';
126
+ }
127
+ const seconds =
128
+ this.#config.tiles?.prewarmIntervalSeconds ?? DEFAULT_INTERVAL_SECONDS;
129
+ if (seconds <= 0) {
130
+ return `tiles.prewarmIntervalSeconds is ${seconds}`;
131
+ }
132
+ return null;
133
+ }
134
+
74
135
  /**
75
136
  * Whether an archive is worth reading the head of right now.
76
137
  *
@@ -162,7 +223,11 @@ export class HeadWarmer {
162
223
  }
163
224
 
164
225
  const entry = this.#catalog.list().find((candidate) => this.due(candidate));
165
- if (!entry) return null;
226
+ if (!entry) {
227
+ this.#reportIdle();
228
+ return null;
229
+ }
230
+ this.#idlePasses = 0;
166
231
 
167
232
  this.#running = true;
168
233
  this.#runningSince = this.#now();
@@ -175,6 +240,11 @@ export class HeadWarmer {
175
240
 
176
241
  this.#tried.set(entry.infoHash, this.#now());
177
242
  this.#waiting.delete(entry.infoHash);
243
+ // The metainfo evidently arrived, so the run of "not yet" answers is
244
+ // over. Cleared rather than left standing, since a later magnet re-add of
245
+ // the same archive starts its own wait and should get its own fast
246
+ // retries rather than inheriting a spent allowance.
247
+ this.#early.delete(entry.infoHash);
178
248
  // Counted even on success, because a read that got the header and not
179
249
  // the metadata has not finished and will be back. The count only matters
180
250
  // while an archive is still due, and one that is done is never asked
@@ -210,44 +280,103 @@ export class HeadWarmer {
210
280
  return stored;
211
281
  } catch (error) {
212
282
  if (tooEarly(error)) {
213
- // Not stamped, so the next pass tries again in seconds rather than in
214
- // minutes — and said once, because a node that has just started will
215
- // answer this way until the metainfo lands.
216
- if (!this.#waiting.has(entry.infoHash)) {
217
- this.#waiting.add(entry.infoHash);
218
- console.log(
219
- `[warm] ${entry.name}: waiting for the torrent metadata before ` +
220
- 'reading its head',
221
- );
283
+ const passes = (this.#early.get(entry.infoHash) ?? 0) + 1;
284
+ this.#early.set(entry.infoHash, passes);
285
+
286
+ if (passes <= MAX_CONSECUTIVE_EARLY_PASSES) {
287
+ // Not stamped, so the next pass tries again in seconds rather than in
288
+ // minutes — and said once, because a node that has just started will
289
+ // answer this way until the metainfo lands.
290
+ if (!this.#waiting.has(entry.infoHash)) {
291
+ this.#waiting.add(entry.infoHash);
292
+ console.log(
293
+ `[warm] ${entry.name}: waiting for the torrent metadata before ` +
294
+ 'reading its head',
295
+ );
296
+ }
297
+ return null;
222
298
  }
223
- return null;
299
+
300
+ // Waited long enough to stop calling it a wait. Said out loud, because
301
+ // this is the moment the archive goes from "about to work" to "may
302
+ // never work", and it is otherwise the quietest possible transition.
303
+ console.warn(
304
+ `[warm] ${entry.name}: still has no torrent metadata after ` +
305
+ `${passes} passes; treating that as a failure so other archives ` +
306
+ 'get a turn',
307
+ );
308
+ } else {
309
+ // A real attempt: it reached the swarm and found nothing. Ordinary
310
+ // while an archive is young, since the piece holding the header may not
311
+ // exist anywhere reachable yet. Worth saying, because it is the answer
312
+ // to "why is my preview blank", and the backoff keeps it from becoming
313
+ // noise.
314
+ console.warn(`[warm] ${entry.name}: ${error.message}`);
315
+ this.#early.delete(entry.infoHash);
224
316
  }
225
317
 
226
- // A real attempt: it reached the swarm and found nothing. Ordinary while
227
- // an archive is young, since the piece holding the header may not exist
228
- // anywhere reachable yet. Worth saying, because it is the answer to "why
229
- // is my preview blank", and the backoff keeps it from becoming noise.
230
318
  this.#tried.set(entry.infoHash, this.#now());
231
319
  this.#waiting.delete(entry.infoHash);
232
320
  this.#attempts.set(
233
321
  entry.infoHash,
234
322
  (this.#attempts.get(entry.infoHash) ?? 0) + 1,
235
323
  );
236
- console.warn(`[warm] ${entry.name}: ${error.message}`);
237
324
  return null;
238
325
  } finally {
239
326
  this.#running = false;
240
327
  }
241
328
  }
242
329
 
330
+ /**
331
+ * Says why nothing is being warmed, when that has gone on long enough to be
332
+ * worth explaining.
333
+ *
334
+ * Distinguishes the two ways a pass finds nothing: everything is summarised,
335
+ * which is the goal, and everything is being skipped, which is a fault that
336
+ * otherwise looks identical. The archives that are neither summarised nor
337
+ * eligible are named, because the reason is almost always visible in the name
338
+ * — an entry still called by its infohash has no metainfo yet, so `guessKind`
339
+ * cannot tell it is PMTiles and `due` refuses it.
340
+ * @returns {void}
341
+ */
342
+ #reportIdle() {
343
+ this.#idlePasses++;
344
+ if (this.#idlePasses !== IDLE_PASSES_BEFORE_REPORTING) return;
345
+
346
+ const unread = this.#catalog
347
+ .list()
348
+ .filter((entry) => !entry.pmtiles?.format);
349
+ if (unread.length === 0) {
350
+ console.log('[warm] every archive has been summarised; nothing to warm');
351
+ return;
352
+ }
353
+ console.warn(
354
+ `[warm] ${unread.length} archive(s) have no summary and none are ` +
355
+ 'eligible to be warmed; their kind is not PMTiles, or is not yet ' +
356
+ `known: ${unread
357
+ .slice(0, 5)
358
+ .map((entry) => entry.name ?? entry.infoHash)
359
+ .join(', ')}${unread.length > 5 ? ', …' : ''}`,
360
+ );
361
+ }
362
+
243
363
  /**
244
364
  * Starts warming, and keeps at it.
245
365
  * @returns {void}
246
366
  */
247
367
  start() {
248
- if (!this.enabled) return;
249
- const seconds = this.#config.tiles?.prewarmIntervalSeconds ?? 30;
250
- if (seconds <= 0) return;
368
+ // Said once at startup, either way. A node that is not warming is a node
369
+ // whose mirrored archives will not become servable on their own, and that
370
+ // is far too important to be inferred from the absence of log lines --
371
+ // which is exactly how it had to be diagnosed before.
372
+ const reason = this.disabledReason;
373
+ if (reason) {
374
+ console.warn(`[warm] not warming: ${reason}`);
375
+ return;
376
+ }
377
+ const seconds =
378
+ this.#config.tiles?.prewarmIntervalSeconds ?? DEFAULT_INTERVAL_SECONDS;
379
+ console.log(`[warm] reading archive heads every ${seconds}s`);
251
380
 
252
381
  const run = () =>
253
382
  this.sweep().catch((error) =>
@@ -409,6 +409,16 @@ export class SubscriptionManager {
409
409
  // The mtime on the node that built it, which BitTorrent will not carry.
410
410
  // Restored when the download completes.
411
411
  originMtime: item.mtime,
412
+ // What the publisher says the archive holds: format, zoom range, bounds.
413
+ //
414
+ // This is the difference between a mirror that can serve tiles the moment
415
+ // it joins and one that cannot serve them until it has read the header out
416
+ // of the swarm -- which, for an 80 GiB archive whose only seed is busy,
417
+ // can be a very long time, and used to be the only way an entry ever got
418
+ // a summary at all. The head warmer still runs and still overwrites this
419
+ // with what the archive itself says; this only means the wait is not
420
+ // spent unservable.
421
+ pmtiles: item.pmtiles,
412
422
  };
413
423
 
414
424
  // The .torrent is preferred where there is one: it carries the trackers