pmtiles-swarm 0.14.0 → 0.14.2

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,42 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.14.2
11
+ ### 🐞 Bug fixes
12
+ - **Waiting for the DHT's `ready` event was not enough.** It fires when the bootstrap lookup
13
+ *finishes*, whether or not that lookup found anything — so a node with no working UDP path
14
+ reports itself ready and then fails every put with "No nodes to query", and the warning added in
15
+ 0.14.1 never fired because nothing had gone wrong by its measure.
16
+
17
+ It now waits for the routing table to have something in it, and says how empty it is when it
18
+ gives up:
19
+
20
+ ```
21
+ [mutable] DHT ready with 42 nodes
22
+ [mutable] the DHT found no peers in 60s. Publishing will fail until it does — check that
23
+ outbound UDP is not blocked, and that the bootstrap hosts resolve
24
+ ```
25
+
26
+ Per-category failures carry the count too, because "No nodes to query" with an empty table is a
27
+ network problem and the same message with a populated one is not.
28
+
29
+ ## 0.14.1
30
+ ### 🐞 Bug fixes
31
+ - **The publisher waited a fixed fifteen seconds for the DHT and then published into an empty
32
+ routing table**, so on a real node every category failed at once:
33
+
34
+ ```
35
+ [mutable] openmaptiles failed: failed to publish mutable record: No nodes to query
36
+ ```
37
+
38
+ A fixed delay was a bet on how long bootstrapping takes. It now waits for the DHT's own `ready`
39
+ event instead, which fires in a couple of seconds on a healthy network, and only gives up after
40
+ a minute — saying so when it does, because a DHT that never bootstraps means outbound UDP is not
41
+ getting out and every later failure is a consequence of that.
42
+ - **A failed attempt no longer waits out the whole republish interval.** Nothing published meant
43
+ half an hour of advertising a public key that resolves to nothing before trying again. It now
44
+ retries after 30 seconds, backing off towards the interval.
45
+
10
46
  ## 0.14.0
11
47
  ### ✨ Features and improvements
12
48
  - **The Categories page hands you the URL a style should actually use.** A new **For a style** row
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
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/publisher.js CHANGED
@@ -50,8 +50,11 @@ import { mutableMagnet, publishInfoHash } from './mutable.js';
50
50
  /** Republish well inside the ~2h a DHT keeps an item. */
51
51
  const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
52
52
 
53
- /** Grace period before the first publish, for the DHT to find peers. */
54
- const DEFAULT_READY_MS = 15_000;
53
+ /** How long to wait for the DHT to bootstrap before publishing anyway. */
54
+ const DEFAULT_READY_MS = 60_000;
55
+
56
+ /** First retry delay after an attempt where nothing was published. */
57
+ const RETRY_MS = 30_000;
55
58
 
56
59
  export class MutablePublisher {
57
60
  #catalog;
@@ -59,6 +62,7 @@ export class MutablePublisher {
59
62
  #key;
60
63
  #intervalMs;
61
64
  #timer = null;
65
+ #retryTimer = null;
62
66
  #stopped = false;
63
67
  #log;
64
68
  /** Last published infohash per category, so an unchanged build stays quiet. */
@@ -134,10 +138,19 @@ export class MutablePublisher {
134
138
  );
135
139
  }
136
140
  } catch (error) {
137
- // One category failing must not stop the rest.
138
- this.#log(`[mutable] ${category} failed: ${error.message}`);
141
+ // One category failing must not stop the rest. The node count goes in
142
+ // the line because "No nodes to query" with an empty table is a
143
+ // network problem and the same message with a full one is not.
144
+ const nodes = this.#nodeCount();
145
+ this.#log(
146
+ `[mutable] ${category} failed: ${error.message}` +
147
+ (nodes === null ? '' : ` (${nodes} DHT nodes known)`),
148
+ );
139
149
  }
140
150
  }
151
+ if (done.length === 0 && this.#current().size > 0) {
152
+ this.#scheduleRetry(options.retryDelayMs ?? RETRY_MS);
153
+ }
141
154
  return done;
142
155
  }
143
156
 
@@ -165,17 +178,14 @@ export class MutablePublisher {
165
178
  * @returns {void}
166
179
  */
167
180
  start(options = {}) {
168
- // A put into a DHT that has not found peers yet reaches nobody, and
169
- // bootstrapping is the first thing a fresh node does. Waiting costs one
170
- // interval of staleness at worst and makes the first publish mean
171
- // something.
172
- const first = setTimeout(() => {
173
- if (this.#stopped) return;
174
- this.publishAll({ force: true }).catch((error) =>
175
- this.#log(`[mutable] first publish failed: ${error.message}`),
176
- );
177
- }, options.readyMs ?? DEFAULT_READY_MS);
178
- first.unref?.();
181
+ // Waited for rather than guessed at. A put into a DHT whose routing table
182
+ // is still empty fails with "No nodes to query", and a fixed delay is a
183
+ // bet on how long bootstrapping takes -- one this lost in the field, where
184
+ // fifteen seconds was not enough and every category failed on the first
185
+ // attempt.
186
+ this.#firstPublish(options.readyMs ?? DEFAULT_READY_MS).catch((error) =>
187
+ this.#log(`[mutable] first publish failed: ${error.message}`),
188
+ );
179
189
 
180
190
  this.#timer = setInterval(() => {
181
191
  this.publishAll().catch((error) =>
@@ -190,6 +200,98 @@ export class MutablePublisher {
190
200
  );
191
201
  }
192
202
 
203
+ /**
204
+ * Waits for the DHT to bootstrap, then publishes.
205
+ * @param {number} readyMs - How long to wait before going ahead regardless.
206
+ * @returns {Promise<void>} - Resolves once the first attempt is done.
207
+ */
208
+ async #firstPublish(readyMs) {
209
+ const nodes = await this.#whenDhtReady(readyMs);
210
+ if (this.#stopped) return;
211
+ if (nodes === 0) {
212
+ // Named rather than left to be inferred. A routing table that is still
213
+ // empty after a minute means the bootstrap queries are not being
214
+ // answered, and every "No nodes to query" after this is that same fact
215
+ // reported once per category.
216
+ this.#log(
217
+ `[mutable] the DHT found no peers in ${Math.round(readyMs / 1000)}s. Publishing ` +
218
+ 'will fail until it does — check that outbound UDP is not blocked, and that ' +
219
+ 'the bootstrap hosts resolve',
220
+ );
221
+ } else if (nodes) {
222
+ this.#log(`[mutable] DHT ready with ${nodes} nodes`);
223
+ }
224
+ await this.publishAll({ force: true });
225
+ }
226
+
227
+ /**
228
+ * Waits for the DHT to have somewhere to send a query.
229
+ *
230
+ * `ready` alone is not enough: it fires when the bootstrap lookup finishes,
231
+ * whether or not that lookup found anything, so a node with no UDP path
232
+ * reports itself ready and then fails every put with "No nodes to query".
233
+ * What matters is the size of the routing table.
234
+ * @param {number} timeoutMs - How long to wait.
235
+ * @returns {Promise<number | null>} - Nodes found, or null if unknowable.
236
+ */
237
+ async #whenDhtReady(timeoutMs) {
238
+ const deadline = Date.now() + timeoutMs;
239
+
240
+ if (typeof this.#dht?.once === 'function' && !this.#dht.ready) {
241
+ await new Promise((resolve) => {
242
+ const timer = setTimeout(resolve, timeoutMs);
243
+ timer.unref?.();
244
+ this.#dht.once('ready', () => {
245
+ clearTimeout(timer);
246
+ resolve();
247
+ });
248
+ });
249
+ }
250
+
251
+ let count = this.#nodeCount();
252
+ while (count === 0 && Date.now() < deadline && !this.#stopped) {
253
+ await new Promise((resolve) => {
254
+ const timer = setTimeout(resolve, 1000);
255
+ timer.unref?.();
256
+ });
257
+ count = this.#nodeCount();
258
+ }
259
+ return count;
260
+ }
261
+
262
+ /**
263
+ * How many nodes the DHT currently knows, where it can say.
264
+ * @returns {number | null} - The count, or null for a stand-in that has none.
265
+ */
266
+ #nodeCount() {
267
+ const count = this.#dht?.nodes?.count?.();
268
+ if (typeof count === 'number') return count;
269
+ const listed = this.#dht?.toJSON?.().nodes?.length;
270
+ return typeof listed === 'number' ? listed : null;
271
+ }
272
+
273
+ /**
274
+ * Tries again sooner than the republish interval.
275
+ *
276
+ * An attempt where nothing published usually means the DHT is not ready yet,
277
+ * and waiting out a thirty-minute interval to discover that again is half an
278
+ * hour of a node advertising a key that resolves to nothing.
279
+ * @param {number} delayMs - How long to wait first.
280
+ * @returns {void}
281
+ */
282
+ #scheduleRetry(delayMs) {
283
+ if (this.#stopped || this.#retryTimer) return;
284
+ const timer = setTimeout(() => {
285
+ this.#retryTimer = null;
286
+ if (this.#stopped) return;
287
+ this.publishAll({ retryDelayMs: Math.min(delayMs * 2, this.#intervalMs) }).catch(
288
+ (error) => this.#log(`[mutable] retry failed: ${error.message}`),
289
+ );
290
+ }, delayMs);
291
+ timer.unref?.();
292
+ this.#retryTimer = timer;
293
+ }
294
+
193
295
  /**
194
296
  * Stops republishing.
195
297
  * @returns {void}
@@ -197,6 +299,8 @@ export class MutablePublisher {
197
299
  stop() {
198
300
  this.#stopped = true;
199
301
  if (this.#timer) clearInterval(this.#timer);
302
+ if (this.#retryTimer) clearTimeout(this.#retryTimer);
200
303
  this.#timer = null;
304
+ this.#retryTimer = null;
201
305
  }
202
306
  }