pmtiles-swarm 0.70.0 → 0.71.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,33 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.71.0
11
+ ### ✨ Features and improvements
12
+ - **The preview comes back to where it was opened from.** The link out of a preview said
13
+ "console" and went to the archive list, whatever had been previewed - so previewing a stack and
14
+ coming back meant finding the Stacks tab again. The console now names its view in the address
15
+ and honours one it is given, and a preview links to the view it belongs to.
16
+
17
+ - **A category offers terrain where its newest build is terrain.** Archives and stacks got the
18
+ second button; categories did not, on either page. Same rule and the same reason: the raster
19
+ keeps the plain name because it is the view that shows a hole.
20
+
21
+ ### 🐞 Bug fixes
22
+ - **A stopped export resumed; a restarted service did not.** Both were called "stopping keeps the
23
+ work", and only one of them did. **Stop export** cancels the job, which writes a checkpoint. A
24
+ service restart tells it nothing at all: the process is torn down, and whatever had been merged
25
+ since the last checkpoint was merged again.
26
+
27
+ Two things were wrong. Nothing asked a running export to stop when the node did, so exports are
28
+ now a shutdown step of their own - cancelled early in the sequence, before the pieces they read
29
+ through are taken away, and waited for so each writes its checkpoint.
30
+
31
+ And a checkpoint fired on a tile count alone, every five thousand. That is the wrong measure for
32
+ a bake merging slowly, which can run for an hour without reaching it - an export that had done a
33
+ few hundred tiles had never checkpointed at all, so a restart lost everything it had done. There
34
+ is a clock now as well, thirty seconds, whichever comes first. A checkpoint costs about eleven
35
+ milliseconds.
36
+
10
37
  ## 0.70.0
11
38
  ### ✨ Features and improvements
12
39
  - **An export works on the disk the archive is going to.** It worked under the data directory and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.70.0",
3
+ "version": "0.71.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/bake-jobs.js CHANGED
@@ -108,6 +108,38 @@ export class BakeManager {
108
108
  return true;
109
109
  }
110
110
 
111
+ /**
112
+ * Stops every running bake and waits for each to write its checkpoint.
113
+ *
114
+ * For a service stopping. Without this a restart kills a bake where it
115
+ * stands: the merging half is cancellable and keeps its work, but only if
116
+ * something tells it to stop, and a process being torn down does not. What
117
+ * was merged since the last checkpoint would be merged again.
118
+ * @param {object} [options] - `timeoutMs` to stop waiting.
119
+ * @returns {Promise<number>} - How many were stopped.
120
+ */
121
+ async stopAll(options = {}) {
122
+ const running = [...this.#jobs.values()].filter((job) => !job.finishedAt);
123
+ if (running.length === 0) return 0;
124
+
125
+ for (const job of running) {
126
+ job.cancelling = true;
127
+ job.controller.abort();
128
+ }
129
+
130
+ // Bounded, because a shutdown that waits for ever is a shutdown that gets
131
+ // killed harder. A checkpoint is milliseconds; what takes time is the tile
132
+ // in flight noticing it has been abandoned.
133
+ const timeout = new Promise((resolve) =>
134
+ setTimeout(resolve, options.timeoutMs ?? 10000).unref?.(),
135
+ );
136
+ await Promise.race([
137
+ Promise.all(running.map((job) => job.promise?.catch(() => {}))),
138
+ timeout,
139
+ ]);
140
+ return running.length;
141
+ }
142
+
111
143
  /**
112
144
  * Starts a bake, and answers as soon as it is running rather than when it
113
145
  * finishes.
package/src/bake.js CHANGED
@@ -25,6 +25,17 @@ import { Compression, PMTilesWriter, TileType } from './pmtiles-write.js';
25
25
  /** Tiles merged between checkpoints. */
26
26
  const DEFAULT_CHECKPOINT_EVERY = 5000;
27
27
 
28
+ /**
29
+ * Seconds between checkpoints, however few tiles have been done.
30
+ *
31
+ * A count alone is the wrong measure. A bake merging slowly -- a big tile, a
32
+ * cache-mode source, a stack with several layers -- can run for an hour
33
+ * without reaching five thousand, and a process killed before its first
34
+ * checkpoint has nothing to resume from at all. A checkpoint costs about
35
+ * eleven milliseconds, so doing one on the clock is close to free.
36
+ */
37
+ const DEFAULT_CHECKPOINT_SECONDS = 30;
38
+
28
39
  /** Tiles merged at once. */
29
40
  const DEFAULT_CONCURRENCY = 4;
30
41
 
@@ -454,6 +465,7 @@ export async function bakeStack(options) {
454
465
  header = {},
455
466
  deduplicate = true,
456
467
  checkpointEvery = DEFAULT_CHECKPOINT_EVERY,
468
+ checkpointSeconds = DEFAULT_CHECKPOINT_SECONDS,
457
469
  pauseMs = 0,
458
470
  concurrency = DEFAULT_CONCURRENCY,
459
471
  } = options;
@@ -486,6 +498,7 @@ export async function bakeStack(options) {
486
498
  let skipped = found?.skipped ?? 0;
487
499
  let lastTileId = found?.lastTileId ?? -1;
488
500
  let sinceCheckpoint = 0;
501
+ let checkpointedAt = Date.now();
489
502
  let persisted = found?.entries.length ?? 0;
490
503
 
491
504
  /**
@@ -508,6 +521,7 @@ export async function bakeStack(options) {
508
521
  persisted,
509
522
  );
510
523
  sinceCheckpoint = 0;
524
+ checkpointedAt = Date.now();
511
525
  };
512
526
 
513
527
  try {
@@ -563,7 +577,13 @@ export async function bakeStack(options) {
563
577
  }
564
578
 
565
579
  batch = [];
566
- if (sinceCheckpoint >= checkpointEvery) await checkpoint();
580
+ // Whichever comes first. The count keeps a fast bake from checkpointing
581
+ // constantly; the clock keeps a slow one from never checkpointing at
582
+ // all.
583
+ const due =
584
+ sinceCheckpoint >= checkpointEvery ||
585
+ Date.now() - checkpointedAt >= checkpointSeconds * 1000;
586
+ if (due) await checkpoint();
567
587
  signal?.throwIfAborted();
568
588
 
569
589
  // Handing time back, where the operator asked for that. A bake on a node
package/src/index.js CHANGED
@@ -474,6 +474,22 @@ PMTILES_SWARM_PUBLIC_URL
474
474
  cutlines,
475
475
  });
476
476
 
477
+ // Early in the sequence, so an export is told to stop before the pieces it
478
+ // reads through are taken away. Its checkpoint is the hours already spent.
479
+ stoppers.unshift({
480
+ label: 'stack exports',
481
+ stop: async () => {
482
+ const stopped = await bakes.stopAll();
483
+ if (stopped > 0) {
484
+ console.log(
485
+ `[shutdown] stopped ${stopped} stack export(s); each kept its ` +
486
+ 'checkpoint, so exporting again carries on from there',
487
+ );
488
+ }
489
+ },
490
+ ms: 12000,
491
+ });
492
+
477
493
  // Reads the head of anything joined but not yet understood — the header,
478
494
  // then the root directory and metadata it points at. Without this an archive
479
495
  // being mirrored is unservable until the download happens to reach byte
@@ -4019,7 +4019,12 @@ Every piece is hashed against the ` +
4019
4019
  ? `<div class="links">
4020
4020
  ${copyable(ends.tileJson, 'TileJSON')}
4021
4021
  ${copyable(ends.xyz, 'XYZ')}
4022
- ${link(ends.preview, 'preview')}
4022
+ ${
4023
+ drawsAsTerrain(newest)
4024
+ ? `${link(`${ends.preview}?raw=1`, 'preview')}
4025
+ ${link(ends.preview, 'terrain')}`
4026
+ : link(ends.preview, 'preview')
4027
+ }
4023
4028
  ${link(ends.torrent, '.torrent', true)}
4024
4029
  ${copyable(ends.magnet, 'magnet', true)}
4025
4030
  ${copyable(ends.feed, 'RSS')}
@@ -6995,17 +7000,19 @@ Every piece is hashed against the ` +
6995
7000
  }
6996
7001
 
6997
7002
  // ── Tabs ──────────────────────────────────────────────────────────────
7003
+ /** Views a URL may name, so a link can come back to the right one. */
7004
+ const TABS = ['archives', 'categories', 'stacks', 'traffic', 'settings'];
7005
+
6998
7006
  const showTab = (name) => {
6999
- for (const tab of [
7000
- 'archives',
7001
- 'categories',
7002
- 'stacks',
7003
- 'traffic',
7004
- 'settings',
7005
- ]) {
7007
+ for (const tab of TABS) {
7006
7008
  $(`view-${tab}`).hidden = tab !== name;
7007
7009
  $(`tab-${tab}`).classList.toggle('on', tab === name);
7008
7010
  }
7011
+ // Written into the address, so a page that sends somebody here can say
7012
+ // where to land -- a preview of a stack belongs back at Stacks, not at
7013
+ // whatever the console opens on. `replaceState` rather than assigning
7014
+ // the hash, which would scroll and add a history entry per tab.
7015
+ history.replaceState(null, '', name === 'archives' ? '#' : `#${name}`);
7009
7016
  if (name === 'settings') loadSettings().catch((e) => toast(e.message));
7010
7017
  if (name === 'categories') loadCategories().catch((e) => toast(e.message));
7011
7018
  if (name === 'stacks') loadStacks().catch((e) => toast(e.message));
@@ -7014,6 +7021,13 @@ Every piece is hashed against the ` +
7014
7021
  loadSwarmTraffic();
7015
7022
  }
7016
7023
  };
7024
+ // A view named in the address wins over the one this opens on. Ignored
7025
+ // when it names nothing, so an old bookmark is not a blank console.
7026
+ const tabFromUrl = () => {
7027
+ const named = location.hash.replace('#', '');
7028
+ return TABS.includes(named) ? named : null;
7029
+ };
7030
+
7017
7031
  // The footer's year, set from the clock rather than typed into a file
7018
7032
  // nobody will remember to edit. The version beside it arrives with the
7019
7033
  // first status, and reads "…" until then.
@@ -8312,6 +8326,10 @@ Every piece is hashed against the ` +
8312
8326
  // An unreachable server is reported by refresh() in the status line.
8313
8327
  }
8314
8328
  refresh();
8329
+ // After the archives have been asked for, so the tab that opens is the
8330
+ // one asked for and not the one this happens to start on.
8331
+ const named = tabFromUrl();
8332
+ if (named && named !== 'archives') showTab(named);
8315
8333
  }
8316
8334
 
8317
8335
  start();
@@ -69,7 +69,7 @@
69
69
  <code id="endpoint"></code>
70
70
  <span class="links">
71
71
  <a id="mode" href="#" hidden></a>
72
- <a href="/">← console</a>
72
+ <a id="back" href="/">← console</a>
73
73
  </span>
74
74
  </header>
75
75
  <div id="note" hidden></div>
@@ -96,6 +96,18 @@
96
96
  );
97
97
  const $ = (id) => document.getElementById(id);
98
98
 
99
+ // Back to the view this was opened from rather than to whatever the
100
+ // console opens on. The path already says which: a stack was reached
101
+ // from Stacks, a category from Categories, an archive from the list.
102
+ const back = $('back');
103
+ if (back) {
104
+ back.href = location.pathname.startsWith('/stacks/')
105
+ ? '/#stacks'
106
+ : location.pathname.startsWith('/latest/')
107
+ ? '/#categories'
108
+ : '/';
109
+ }
110
+
99
111
  const fail = (message) => {
100
112
  document.body.innerHTML = `<div class="banner">${message}</div>`;
101
113
  };