pmtiles-swarm 0.5.3 → 0.5.5

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,33 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.5.5
11
+ ### 🐞 Bug fixes
12
+ - **An archive could retire itself from head-warming and never say so.** The test for "already
13
+ read" was `summary.format !== 'pbf'`, and any stored summary *without* a format satisfies
14
+ that — so it counted as read, not vector, and therefore finished. A read that raced its
15
+ deadline leaves exactly such a summary behind, and from then on the archive was permanently
16
+ ineligible: no attempts, no log lines, and nothing to explain the silence. A summary now only
17
+ counts as an answer if it actually names a format.
18
+ - **A read that never settles no longer disables warming for good.** The flag that stops two
19
+ reads running at once was held for the life of the process by a read that never returned, and
20
+ every later pass then returned at its first line — silently, for every archive. It is
21
+ abandoned after three times the metadata timeout, with a line saying so.
22
+
23
+ ## 0.5.4
24
+ ### ✨ Features and improvements
25
+ - **The head-warm waits sensibly at both ends.** It used to read at the instant the node
26
+ started — when an archive joined by magnet has no metainfo, the engine has no peers, and the
27
+ attempt is certain to find nothing — and then leave a flat two minutes between every try
28
+ afterwards. Neither suited what was actually being waited for. The first pass now comes ten
29
+ seconds in, and the wait after an attempt that did not finish starts at fifteen seconds and
30
+ doubles to a ten-minute ceiling: seconds early on, when what is missing is usually a peer or a
31
+ piece already in flight, and minutes later on, when it is one piece at the far end of an
32
+ archive nobody has finished downloading.
33
+
34
+ New under `tiles`: `prewarmInitialDelaySeconds` (10) and `prewarmMaxBackoffSeconds` (600).
35
+ `prewarmBackoffSeconds` is now the *first* wait rather than every wait, and defaults to 15.
36
+
10
37
  ## 0.5.3
11
38
  ### 🐞 Bug fixes
12
39
  - **A "high" priority hint asked for nothing at all.** libtorrent's scale runs 0 to 7 and **4 is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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
@@ -460,13 +460,28 @@ const DEFAULTS = {
460
460
  /** How often to look for an archive whose head has not been read. */
461
461
  prewarmIntervalSeconds: 30,
462
462
  /**
463
- * How long to leave an archive alone after a failed read.
463
+ * How long to let the node settle before the first attempt.
464
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.
465
+ * At the moment a node starts, an archive joined by magnet has no metainfo
466
+ * and the engine has no peers, so a read attempted immediately is certain
467
+ * to find nothing.
468
468
  */
469
- prewarmBackoffSeconds: 120,
469
+ prewarmInitialDelaySeconds: 10,
470
+ /**
471
+ * The first wait after an attempt that did not finish the job, doubling up
472
+ * to `prewarmMaxBackoffSeconds`.
473
+ *
474
+ * Not finishing is ordinary rather than exceptional here, so this is a
475
+ * retry interval and not an error budget — and it doubles because the two
476
+ * ends of the problem want different answers. Just after a start, what is
477
+ * being waited for is usually seconds away: a peer, a connection, a piece
478
+ * already in flight. Ten attempts later it is one piece at the far end of
479
+ * an archive nobody has finished, and asking every fifteen seconds
480
+ * achieves nothing but log lines.
481
+ */
482
+ prewarmBackoffSeconds: 15,
483
+ /** Where the doubling stops. */
484
+ prewarmMaxBackoffSeconds: 600,
470
485
  /**
471
486
  * What a missing tile answers with: true for 404, false for 204.
472
487
  *
package/src/prewarm.js CHANGED
@@ -21,8 +21,14 @@
21
21
  * the first few seconds if the right few kilobytes are asked for first.
22
22
  */
23
23
 
24
- /** How long to leave an archive alone after a failed attempt. */
25
- const DEFAULT_BACKOFF_MS = 120000;
24
+ /** The first wait after an attempt that did not finish the job. */
25
+ const DEFAULT_BACKOFF_SECONDS = 15;
26
+
27
+ /** Where the doubling stops. */
28
+ const DEFAULT_MAX_BACKOFF_SECONDS = 600;
29
+
30
+ /** How long to let the node settle before the first attempt. */
31
+ const DEFAULT_INITIAL_DELAY_SECONDS = 10;
26
32
 
27
33
  /**
28
34
  * Whether a failure means "not yet" rather than "not working".
@@ -49,8 +55,10 @@ export class HeadWarmer {
49
55
  #now;
50
56
  #timer;
51
57
  #tried = new Map();
58
+ #attempts = new Map();
52
59
  #waiting = new Set();
53
60
  #running = false;
61
+ #runningSince = 0;
54
62
 
55
63
  /**
56
64
  * @param {object} tiles - The tile store, for `summarize`.
@@ -87,14 +95,41 @@ export class HeadWarmer {
87
95
  // archive this can say anything about.
88
96
  if (entry.kind && entry.kind !== 'pmtiles') return false;
89
97
 
98
+ // A summary that names a format is one a header was actually read for.
99
+ // Anything else — an empty object, or one left behind by a read that raced
100
+ // its deadline — is not an answer, and treating it as one retired the
101
+ // archive permanently: no logs, no retries, nothing to explain the silence.
90
102
  const summary = entry.pmtiles;
91
- if (summary && (summary.format !== 'pbf' || summary.vectorLayers)) {
103
+ const read = Boolean(summary?.format);
104
+ if (read && (summary.format !== 'pbf' || summary.vectorLayers)) {
92
105
  return false;
93
106
  }
94
107
 
95
108
  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);
109
+ return this.#now() - last >= this.#backoffFor(entry.infoHash);
110
+ }
111
+
112
+ /**
113
+ * How long to wait before trying this archive again.
114
+ *
115
+ * Doubling, from a short first wait to a long ceiling. A flat interval is
116
+ * wrong at both ends: right after a node starts, the thing being waited for
117
+ * is usually a few seconds away — a peer, a connection, a piece already in
118
+ * flight — so a two-minute wait wastes most of it. Ten attempts later the
119
+ * thing being waited for is a single piece at the far end of an archive
120
+ * nobody has finished downloading, and asking every two minutes achieves
121
+ * nothing but log lines.
122
+ * @param {string} infoHash - The archive.
123
+ * @returns {number} - Milliseconds to wait.
124
+ */
125
+ #backoffFor(infoHash) {
126
+ const base =
127
+ (this.#config.tiles?.prewarmBackoffSeconds ?? DEFAULT_BACKOFF_SECONDS) * 1000;
128
+ const ceiling =
129
+ (this.#config.tiles?.prewarmMaxBackoffSeconds ?? DEFAULT_MAX_BACKOFF_SECONDS) *
130
+ 1000;
131
+ const attempts = this.#attempts.get(infoHash) ?? 0;
132
+ return Math.min(base * 2 ** Math.max(0, attempts - 1), ceiling);
98
133
  }
99
134
 
100
135
  /**
@@ -108,12 +143,27 @@ export class HeadWarmer {
108
143
  * @returns {Promise<object|null>} - The entry warmed, or null.
109
144
  */
110
145
  async sweep() {
111
- if (!this.enabled || this.#running) return null;
146
+ if (!this.enabled) return null;
147
+
148
+ if (this.#running) {
149
+ // A read that never settles would otherwise hold this flag for the life
150
+ // of the process and stop every archive from ever being warmed again —
151
+ // silently, since nothing logs a pass that returns at the first line.
152
+ const stuck = (this.#config.tiles?.metadataTimeoutMs ?? 120000) * 3;
153
+ if (this.#now() - this.#runningSince < stuck) return null;
154
+ console.warn(
155
+ `[warm] a read has been running for ${Math.round(
156
+ (this.#now() - this.#runningSince) / 1000,
157
+ )}s and is being abandoned`,
158
+ );
159
+ this.#running = false;
160
+ }
112
161
 
113
162
  const entry = this.#catalog.list().find((candidate) => this.due(candidate));
114
163
  if (!entry) return null;
115
164
 
116
165
  this.#running = true;
166
+ this.#runningSince = this.#now();
117
167
  try {
118
168
  // The long timeout, not the interactive one: this is a byte range from
119
169
  // an archive nobody has asked for a piece of yet.
@@ -123,6 +173,14 @@ export class HeadWarmer {
123
173
 
124
174
  this.#tried.set(entry.infoHash, this.#now());
125
175
  this.#waiting.delete(entry.infoHash);
176
+ // Counted even on success, because a read that got the header and not
177
+ // the metadata has not finished and will be back. The count only matters
178
+ // while an archive is still due, and one that is done is never asked
179
+ // about again.
180
+ this.#attempts.set(
181
+ entry.infoHash,
182
+ (this.#attempts.get(entry.infoHash) ?? 0) + 1,
183
+ );
126
184
 
127
185
  const stored = await this.#catalog.put({
128
186
  infoHash: entry.infoHash,
@@ -169,6 +227,10 @@ export class HeadWarmer {
169
227
  // is my preview blank", and the backoff keeps it from becoming noise.
170
228
  this.#tried.set(entry.infoHash, this.#now());
171
229
  this.#waiting.delete(entry.infoHash);
230
+ this.#attempts.set(
231
+ entry.infoHash,
232
+ (this.#attempts.get(entry.infoHash) ?? 0) + 1,
233
+ );
172
234
  console.warn(`[warm] ${entry.name}: ${error.message}`);
173
235
  return null;
174
236
  } finally {
@@ -189,7 +251,17 @@ export class HeadWarmer {
189
251
  this.sweep().catch((error) =>
190
252
  console.error(`[warm] sweep failed: ${error.message}`),
191
253
  );
192
- run();
254
+
255
+ // Not immediately. At the moment a node starts, an archive joined by
256
+ // magnet has no metainfo, the engine has no peers, and nothing can be read
257
+ // from anywhere — so the first pass is guaranteed to find nothing and
258
+ // exists only to say so.
259
+ const delay =
260
+ (this.#config.tiles?.prewarmInitialDelaySeconds ??
261
+ DEFAULT_INITIAL_DELAY_SECONDS) * 1000;
262
+ const first = setTimeout(run, Math.max(0, delay));
263
+ first.unref?.();
264
+
193
265
  this.#timer = setInterval(run, seconds * 1000);
194
266
  this.#timer.unref?.();
195
267
  }