pmtiles-swarm 0.64.0 → 0.66.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
@@ -4,6 +4,62 @@
4
4
  ### ✨ Features and improvements
5
5
  - _...Add new stuff here..._
6
6
 
7
+ ### 🐞 Bug fixes
8
+ - _...Add new stuff here..._
9
+
10
+ ## 0.66.0
11
+ ### ✨ Features and improvements
12
+ - **An export asks where it should go, and what to call it.** **Export to archive** now opens a
13
+ dialog instead of a confirmation, because there is something to decide. The save location uses
14
+ the same picker every other destination in this console uses, so a location named once under
15
+ Settings is offered here too — a baked planet is hundreds of gigabytes and which disk it lands on
16
+ is not a detail. The name and description default to the stack's and can be changed.
17
+
18
+ The filename is shown as the name is typed. A name is not a filename — separators and the
19
+ characters Windows refuses become dashes, and the date is appended so successive builds do not
20
+ collide — and a preview promising one thing while the server writes another would be worse than
21
+ no preview at all. A test asserts the browser's rule and `safeSegment` agree.
22
+
23
+ ### 🐞 Bug fixes
24
+ - **A save location the node did not know was silently ignored.** The export resolved it after the
25
+ merge and swallowed the error, so naming a location that did not exist, or one that could not be
26
+ written, meant an hour of merging answered with a shrug and the archive on the default disk. It
27
+ is resolved before anything starts and the error is allowed through, which is what lets the
28
+ dialog put it beside the field that caused it.
29
+
30
+
31
+
32
+ ### 🐞 Bug fixes
33
+ - _...Add new stuff here..._
34
+
35
+ ## 0.65.0
36
+ ### ✨ Features and improvements
37
+ - **A bake stays out of the way of the node it runs on.** It runs in the main process — unlike
38
+ hashing, there is no sidecar to send it to — and the merge's pixel maths is entirely
39
+ synchronous, so every millisecond of it is a millisecond the node is not answering requests.
40
+ Three changes, all of them from measuring rather than assuming.
41
+
42
+ **The pixel maths moved to a worker.** `src/pixels.js` runs `elevation.js` and `rgba.js`
43
+ unchanged on another thread. Against a request arriving every 5 ms while a bake runs, the delay
44
+ that request sees at the 99th percentile falls from 10.9 ms to 6.5 ms with two sources, and from
45
+ 18.6 ms to 10.6 ms with four. The bake is 2–17% slower for it. Rasters are handed over rather
46
+ than copied — a decoded tile is most of a megabyte per source — so a merge takes ownership of
47
+ what it is given, which is safe because nothing reads a contribution afterwards and is asserted
48
+ rather than assumed. Serving does not use it: one tile is a few milliseconds nobody notices.
49
+
50
+ **The checkpoint appends instead of rewriting.** It went through `serializeDirectory`, which was
51
+ elegant reuse and the wrong tool — that is a distribution format, and producing it costs a varint
52
+ pass over every entry. Re-encoding all of them every time also made the total work quadratic in
53
+ the length of the job. Fixed 24-byte records can be appended, and only the last one can change
54
+ once written, so a checkpoint costs the work since the last one: **446 ms at four million entries
55
+ becomes 11 ms, and stays 11 ms**.
56
+
57
+ **And `stacks.bakePauseMs`**, how long a bake waits between tiles. Zero by default, which is
58
+ right for a node baking and doing nothing else. On a node that is also serving maps it is the
59
+ direct trade between how long the bake takes and how much of the machine it takes while running.
60
+
61
+
62
+
7
63
  ### 🐞 Bug fixes
8
64
  - _...Add new stuff here..._
9
65
 
@@ -45,6 +45,7 @@ of its parts.
45
45
  - [What to iterate](#what-to-iterate)
46
46
  - [Running it](#running-it)
47
47
  - [What a baked archive says about itself](#what-a-baked-archive-says-about-itself)
48
+ - [Staying out of the way of the node it runs on](#staying-out-of-the-way-of-the-node-it-runs-on)
48
49
  - [What identifies a bake](#what-identifies-a-bake)
49
50
  - [Starting one, and watching it](#starting-one-and-watching-it)
50
51
  - [What exists now](#what-exists-now)
@@ -636,6 +637,59 @@ reason the live stack answers 404.
636
637
  - **Cancellable**, and stopping keeps the work: realising it is the wrong recipe
637
638
  should not mean waiting it out, and it should not mean starting over either.
638
639
 
640
+ ### Staying out of the way of the node it runs on
641
+
642
+ A bake runs in the main process, and unlike hashing there is no sidecar to send
643
+ it to. That matters because `elevation.js` and `rgba.js` are entirely
644
+ synchronous — decoding heights, masking, resampling and painting are loops over
645
+ typed arrays — so every millisecond of it is a millisecond the node is not
646
+ answering requests. Three things follow from having measured that rather than
647
+ assumed it.
648
+
649
+ **The pixel maths goes to a worker.** `src/pixels.js` and `src/pixel-worker.js`
650
+ run the same functions, unchanged, on another thread. Measured against a request
651
+ arriving every 5 ms while a bake runs, the delay that request sees at the 99th
652
+ percentile:
653
+
654
+ | workload | on the main thread | in a worker |
655
+ | ------------------------- | ------------------ | ----------- |
656
+ | 2 sources, 512px | 10.9 ms | 6.5 ms |
657
+ | 4 sources, 512px | 18.6 ms | 10.6 ms |
658
+ | 8 sources, 512px, blurred | 22.3 ms | 17.1 ms |
659
+
660
+ The bake itself is 2–17% slower for it, which is the trade. Rasters are handed
661
+ to the worker rather than copied — a decoded tile is most of a megabyte per
662
+ source, and copying each one is work on the very thread this exists to keep
663
+ free. The cost of that is the caller gives them up, which is safe because
664
+ nothing reads a contribution after its merge, and is asserted rather than left
665
+ as a comment.
666
+
667
+ Serving does not use it. One tile's merge is a few milliseconds nobody notices,
668
+ and a thread per request would cost more than it saved.
669
+
670
+ **The checkpoint appends instead of rewriting.** The first version wrote entries
671
+ through `serializeDirectory`, which was elegant reuse and the wrong tool: that
672
+ is a _distribution_ format, and producing it costs a varint pass over every
673
+ entry — 264 ms at a million entries, of which only 12 ms is the compression.
674
+ Re-encoding all of them every checkpoint also made the total work quadratic in
675
+ the length of the job.
676
+
677
+ A checkpoint is read once, by this process, on the machine that wrote it. Fixed
678
+ 24-byte records cost nothing to produce and can be appended, and only the last
679
+ one can change after it is written — a run of identical tiles extends it. So a
680
+ checkpoint now costs the work since the last one:
681
+
682
+ | entries | rewriting everything | appending |
683
+ | --------- | -------------------- | --------- |
684
+ | 100,000 | 36 ms | 11 ms |
685
+ | 1,000,000 | 191 ms | 11 ms |
686
+ | 4,000,000 | 446 ms | 11 ms |
687
+
688
+ **And there is a knob.** `stacks.bakePauseMs` is how long a bake waits between
689
+ tiles. Zero is as fast as it can go, which is right for a node baking and doing
690
+ nothing else. On a node that is also serving maps it is the direct trade: how
691
+ long the bake takes against how much of the machine it takes while it runs.
692
+
639
693
  ### What identifies a bake
640
694
 
641
695
  `bakeRevision` is the recipe's revision and what each source resolved to,
@@ -676,6 +730,20 @@ worth writing at all.
676
730
  planet bake is hours, and a request that waited for the archive is a request
677
731
  nothing could hold open. `DELETE` on the same address stops it.
678
732
 
733
+ The button opens a dialog rather than a confirmation, because there is
734
+ something to decide. **Where it lands** uses the same picker every other
735
+ destination in this console uses, so a location named once under Settings is
736
+ offered here too — and a baked planet is hundreds of gigabytes, so which disk it
737
+ goes on is not a detail. The location is resolved _before_ anything is merged:
738
+ a name this node does not know, or a path it cannot write, is the caller's
739
+ mistake and they can fix it, but only if they are told now rather than an hour
740
+ later.
741
+
742
+ **What it is called** defaults to the stack's title and can be changed, as can
743
+ the description. The dialog shows the filename as the name is typed, because a
744
+ name is not a filename — the same sanitising the server does, and a preview that
745
+ promised something else would be worse than no preview.
746
+
679
747
  A bake has two halves and they are watched in two places, deliberately. Merging
680
748
  is about a stack, so it is reported on the stack — tiles written, tiles skipped,
681
749
  the zoom it is working through. What happens afterwards is an archive being
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.64.0",
3
+ "version": "0.66.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/api.js CHANGED
@@ -3181,6 +3181,8 @@ export function createApp({
3181
3181
  categories: req.body?.categories,
3182
3182
  location: req.body?.location,
3183
3183
  savePath: req.body?.savePath,
3184
+ name: req.body?.name,
3185
+ description: req.body?.description,
3184
3186
  });
3185
3187
  return res.status(202).json({ bake: job });
3186
3188
  } catch (error) {
package/src/bake-jobs.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  bakedName,
7
7
  mergeTileFor,
8
8
  } from './bake.js';
9
+ import { PixelWorker } from './pixels.js';
9
10
  import { outputFormat } from './stack-tile.js';
10
11
 
11
12
  /**
@@ -112,9 +113,21 @@ export class BakeManager {
112
113
  throw error;
113
114
  }
114
115
 
116
+ // Resolved before anything starts, and allowed to throw. A location that
117
+ // does not exist or cannot be written is the caller's mistake and they can
118
+ // fix it -- but only if they are told now. Checked at the end instead, it
119
+ // is an hour of merging answered with a shrug and the default disk.
120
+ const publishDir =
121
+ options.publishDir ?? (await this.#savePath(options)) ?? undefined;
122
+
123
+ // What the archive will be called, which the caller may have chosen. The
124
+ // file follows it; the date is what keeps successive builds apart.
125
+ const archiveName = options.name?.trim() || resolved.stack.title || stackId;
126
+
115
127
  const job = {
116
128
  stackId,
117
129
  title: resolved.stack.title ?? stackId,
130
+ archiveName,
118
131
  phase: 'merging',
119
132
  startedAt: new Date().toISOString(),
120
133
  written: 0,
@@ -125,7 +138,8 @@ export class BakeManager {
125
138
  finishedAt: null,
126
139
  error: null,
127
140
  infoHash: null,
128
- name: bakedName(resolved, { when: new Date() }),
141
+ name: bakedName(resolved, { when: new Date(), name: archiveName }),
142
+ publishDir,
129
143
  };
130
144
  this.#jobs.set(stackId, job);
131
145
 
@@ -158,6 +172,63 @@ export class BakeManager {
158
172
  const destination = path.join(workDir, job.name);
159
173
  const format = outputFormat(resolved);
160
174
 
175
+ // Only where there is pixel work to move. A passthrough bake hands bytes
176
+ // straight through and would pay for a thread it never uses.
177
+ const pixels = codec ? new PixelWorker() : null;
178
+ try {
179
+ await this.#merge(
180
+ job,
181
+ resolved,
182
+ codec,
183
+ pixels,
184
+ workDir,
185
+ destination,
186
+ format,
187
+ options,
188
+ );
189
+ } finally {
190
+ await pixels?.close().catch(() => {});
191
+ }
192
+
193
+ // The second half. `addLocalArchive` registers the add in the library's own
194
+ // in-progress list, which is what the archives view already draws -- so the
195
+ // hashing shows up there without this having to report it twice.
196
+ job.phase = 'importing';
197
+ const entry = await this.#library.addLocalArchive(destination, {
198
+ categories: options.categories ?? resolved.stack.categories,
199
+ // Moved out of the working directory as it is taken on, so a finished
200
+ // archive does not live among the checkpoint files of the job that made
201
+ // it.
202
+ publishDir: job.publishDir,
203
+ mode: 'mirror',
204
+ });
205
+
206
+ job.infoHash = entry.infoHash;
207
+ job.phase = 'done';
208
+ job.finishedAt = new Date().toISOString();
209
+ }
210
+
211
+ /**
212
+ * The merging half, which is where the time goes.
213
+ * @param {object} job - The job to update as it goes.
214
+ * @param {object} resolved - The resolved stack.
215
+ * @param {object|null} codec - The codec, where the recipe needs one.
216
+ * @param {object|null} pixels - Somewhere to do the pixel maths.
217
+ * @param {string} workDir - Where the unfinished work lives.
218
+ * @param {string} destination - Where the archive goes.
219
+ * @param {string} format - The output format.
220
+ * @returns {Promise<void>} - Resolves when the file is written.
221
+ */
222
+ async #merge(
223
+ job,
224
+ resolved,
225
+ codec,
226
+ pixels,
227
+ workDir,
228
+ destination,
229
+ format,
230
+ options,
231
+ ) {
161
232
  // Read through the tile store rather than off the disk, so a cache-mode
162
233
  // source is scanned the same way it is served: its directories come out of
163
234
  // the swarm, and the store holds them to its own byte budget.
@@ -194,13 +265,15 @@ export class BakeManager {
194
265
  resolved,
195
266
  tiles: this.#tiles,
196
267
  codec,
268
+ pixels,
197
269
  signal: job.controller.signal,
198
270
  format,
199
271
  }),
200
272
  header: { format },
273
+ pauseMs: this.#config.stacks?.bakePauseMs ?? 0,
201
274
  metadata: {
202
- name: resolved.stack.title ?? job.stackId,
203
- description: resolved.stack.description,
275
+ name: job.archiveName,
276
+ description: options.description ?? resolved.stack.description,
204
277
  attribution: resolved.stack.attribution,
205
278
  encoding: resolved.stack.output?.encoding,
206
279
  encodingFactors: resolved.stack.output,
@@ -215,23 +288,6 @@ export class BakeManager {
215
288
  });
216
289
 
217
290
  job.tiles = result.written;
218
-
219
- // The second half. `addLocalArchive` registers the add in the library's own
220
- // in-progress list, which is what the archives view already draws -- so the
221
- // hashing shows up there without this having to report it twice.
222
- job.phase = 'importing';
223
- const entry = await this.#library.addLocalArchive(destination, {
224
- categories: options.categories ?? resolved.stack.categories,
225
- // Moved out of the working directory as it is taken on, so a finished
226
- // archive does not live among the checkpoint files of the job that made
227
- // it.
228
- publishDir: options.publishDir ?? (await this.#savePath(options)),
229
- mode: 'mirror',
230
- });
231
-
232
- job.infoHash = entry.infoHash;
233
- job.phase = 'done';
234
- job.finishedAt = new Date().toISOString();
235
291
  }
236
292
 
237
293
  /**
@@ -240,9 +296,10 @@ export class BakeManager {
240
296
  * @returns {Promise<string|undefined>} - A path, or undefined for the default.
241
297
  */
242
298
  async #savePath(options) {
243
- const explicit = await this.#library
244
- .resolveSavePath(options)
245
- .catch(() => undefined);
299
+ // Not caught. `resolveSavePath` refuses a name this node does not know and
300
+ // a path it cannot write, and both are worth refusing rather than quietly
301
+ // putting hundreds of gigabytes somewhere else.
302
+ const explicit = await this.#library.resolveSavePath(options);
246
303
  return explicit ?? this.#config.savePath ?? undefined;
247
304
  }
248
305
 
@@ -262,6 +319,8 @@ export class BakeManager {
262
319
  skipped: job.skipped,
263
320
  zoom: job.zoom,
264
321
  name: job.name,
322
+ archiveName: job.archiveName,
323
+ publishDir: job.publishDir ?? null,
265
324
  infoHash: job.infoHash,
266
325
  error: job.error,
267
326
  };
package/src/bake.js CHANGED
@@ -1,18 +1,12 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import zlib from 'node:zlib';
4
3
  import { tileIdToZxy } from 'pmtiles';
5
- import { deserializeDirectory, unionOfTileIds } from './pmtiles-scan.js';
4
+ import { unionOfTileIds } from './pmtiles-scan.js';
6
5
  import crypto from 'node:crypto';
7
6
  import { safeSegment } from './savepath.js';
8
7
  import { answerStackTile, outputFormat, outputSize } from './stack-tile.js';
9
8
  import { needsCodec, stackRevision } from './stacks.js';
10
- import {
11
- Compression,
12
- PMTilesWriter,
13
- TileType,
14
- serializeDirectory,
15
- } from './pmtiles-write.js';
9
+ import { Compression, PMTilesWriter, TileType } from './pmtiles-write.js';
16
10
 
17
11
  /**
18
12
  * Running a stack over its sources' coverage and writing a real archive.
@@ -51,6 +45,62 @@ export function checkpointPaths(workDir) {
51
45
  };
52
46
  }
53
47
 
48
+ /**
49
+ * One entry, as a checkpoint stores it.
50
+ *
51
+ * A fixed record rather than `serializeDirectory`, which was the first thing
52
+ * tried here and is the wrong tool. That is a *distribution* format: compact on
53
+ * disk, and it costs a varint pass over every entry to produce -- 264 ms at a
54
+ * million entries, of which only 12 ms is the compression. Re-encoding all of
55
+ * them every checkpoint also makes the total work quadratic in the length of
56
+ * the job.
57
+ *
58
+ * A checkpoint wants none of that. It is read once, by this process, on a
59
+ * machine that just wrote it. Fixed-width records cost nothing to produce and
60
+ * can be appended, which is what turns the checkpoint from a stall that grows
61
+ * into one that does not.
62
+ */
63
+ const RECORD_BYTES = 24;
64
+
65
+ /**
66
+ * Packs entries into fixed records.
67
+ * @param {object[]} entries - The entries to pack.
68
+ * @returns {Buffer} - `RECORD_BYTES` per entry.
69
+ */
70
+ function packEntries(entries) {
71
+ const buffer = Buffer.alloc(entries.length * RECORD_BYTES);
72
+ for (const [index, entry] of entries.entries()) {
73
+ const at = index * RECORD_BYTES;
74
+ // Doubles for the two that can be large: a tile id past z26 and an offset
75
+ // past 4 GiB both exceed what 32 bits hold, and both are safe integers.
76
+ buffer.writeDoubleLE(entry.tileId, at);
77
+ buffer.writeDoubleLE(entry.offset, at + 8);
78
+ buffer.writeUInt32LE(entry.length, at + 16);
79
+ buffer.writeUInt32LE(entry.runLength, at + 20);
80
+ }
81
+ return buffer;
82
+ }
83
+
84
+ /**
85
+ * Reads packed records back into entries.
86
+ * @param {Buffer} buffer - What `packEntries` wrote.
87
+ * @returns {object[]} - The entries.
88
+ */
89
+ function unpackEntries(buffer) {
90
+ const count = Math.floor(buffer.length / RECORD_BYTES);
91
+ const entries = new Array(count);
92
+ for (let index = 0; index < count; index += 1) {
93
+ const at = index * RECORD_BYTES;
94
+ entries[index] = {
95
+ tileId: buffer.readDoubleLE(at),
96
+ offset: buffer.readDoubleLE(at + 8),
97
+ length: buffer.readUInt32LE(at + 16),
98
+ runLength: buffer.readUInt32LE(at + 20),
99
+ };
100
+ }
101
+ return entries;
102
+ }
103
+
54
104
  /**
55
105
  * Reads a checkpoint, if there is one worth resuming from.
56
106
  *
@@ -83,10 +133,10 @@ export async function readCheckpoint(workDir, revision) {
83
133
  // shorter than the entries say, some of what they point at is not there.
84
134
  if (buffered.size < state.dataBytes) return null;
85
135
 
86
- let entries;
87
- try {
88
- entries = deserializeDirectory(zlib.gunzipSync(stored));
89
- } catch {
136
+ const entries = unpackEntries(stored);
137
+ // A record file shorter than the state claims means the two disagree about
138
+ // what was written, and the entries are the half that can be believed.
139
+ if (state.entryCount !== undefined && entries.length !== state.entryCount) {
90
140
  return null;
91
141
  }
92
142
 
@@ -94,25 +144,41 @@ export async function readCheckpoint(workDir, revision) {
94
144
  }
95
145
 
96
146
  /**
97
- * Writes a checkpoint, replacing whatever was there.
147
+ * Writes a checkpoint, appending what is new rather than rewriting it all.
98
148
  *
99
- * Entries go through `serializeDirectory`, which is the compact form this
100
- * project already writes and reads. Nothing here invents a second encoding for
101
- * the same array.
149
+ * Only the last entry can change once written -- a run of identical tiles
150
+ * extends it -- so everything before that is already on disk and correct. The
151
+ * write is therefore the size of the work since the last checkpoint, not the
152
+ * size of the job so far.
102
153
  * @param {string} workDir - The working directory.
103
- * @param {object} state - Scalars worth keeping.
154
+ * @param {object} state - Scalars worth keeping, including `entryCount`.
104
155
  * @param {object[]} entries - The entries as they stand.
105
- * @returns {Promise<void>} - Resolves once both files are on disk.
156
+ * @param {number} [persisted] - How many are already on disk.
157
+ * @returns {Promise<number>} - How many are on disk now.
106
158
  */
107
- export async function writeCheckpoint(workDir, state, entries) {
159
+ export async function writeCheckpoint(workDir, state, entries, persisted = 0) {
108
160
  const paths = checkpointPaths(workDir);
161
+
162
+ // Back up one, because the last record written may have grown a longer run
163
+ // since. Everything before it is settled.
164
+ const from = Math.max(0, Math.min(persisted, entries.length) - 1);
165
+ const handle = await fs.open(paths.entries, persisted > 0 ? 'r+' : 'w');
166
+ try {
167
+ const tail = packEntries(entries.slice(from));
168
+ if (tail.length > 0)
169
+ await handle.write(tail, 0, tail.length, from * RECORD_BYTES);
170
+ await handle.truncate(entries.length * RECORD_BYTES);
171
+ } finally {
172
+ await handle.close();
173
+ }
174
+
109
175
  // Entries first. A state file naming more progress than the entries hold
110
176
  // would resume into an archive missing tiles it believes it wrote.
111
177
  await fs.writeFile(
112
- paths.entries,
113
- entries.length > 0 ? serializeDirectory(entries) : Buffer.alloc(0),
178
+ paths.state,
179
+ JSON.stringify({ ...state, entryCount: entries.length }),
114
180
  );
115
- await fs.writeFile(paths.state, JSON.stringify(state));
181
+ return entries.length;
116
182
  }
117
183
 
118
184
  /**
@@ -166,12 +232,16 @@ function stamp(when = new Date()) {
166
232
  * map and two files cannot share a path. The archive's *name* is not dated --
167
233
  * see `bakedMetadata` -- so a rebuild keeps its identity the way every other
168
234
  * rebuild here does.
235
+ *
236
+ * Follows the name it is given, so renaming an export in the dialog renames the
237
+ * file too. A name and a filename that disagree is a small thing that costs an
238
+ * afternoon the first time somebody goes looking on disk.
169
239
  * @param {object} resolved - The resolved stack.
170
- * @param {object} [options] - `when`, and `suffix` to break a tie.
240
+ * @param {object} [options] - `name` to override the stack's, `when`, `suffix`.
171
241
  * @returns {string} - A filename.
172
242
  */
173
243
  export function bakedName(resolved, options = {}) {
174
- const title = resolved.stack.title ?? resolved.stack.id;
244
+ const title = options.name ?? resolved.stack.title ?? resolved.stack.id;
175
245
  const slug = safeSegment(title) || 'stack';
176
246
  const tail = options.suffix ? `-${options.suffix}` : '';
177
247
  return `${slug}-${stamp(options.when)}${tail}.pmtiles`;
@@ -297,7 +367,7 @@ export function tileTypeFor(format) {
297
367
  * @returns {Function} - `(z, x, y) => Promise<Buffer|null>`.
298
368
  */
299
369
  export function mergeTileFor(options) {
300
- const { resolved, tiles, codec, signal } = options;
370
+ const { resolved, tiles, codec, signal, pixels } = options;
301
371
  const format = options.format ?? outputFormat(resolved);
302
372
  const size = options.size ?? outputSize(resolved.stack);
303
373
 
@@ -313,6 +383,7 @@ export function mergeTileFor(options) {
313
383
  signal,
314
384
  size,
315
385
  format,
386
+ pixels,
316
387
  });
317
388
 
318
389
  // A required source that cannot be read stops the job. Baking around it
@@ -357,6 +428,7 @@ export async function bakeStack(options) {
357
428
  header = {},
358
429
  deduplicate = true,
359
430
  checkpointEvery = DEFAULT_CHECKPOINT_EVERY,
431
+ pauseMs = 0,
360
432
  } = options;
361
433
 
362
434
  if (!sources?.length) throw new Error('a bake needs at least one source');
@@ -386,13 +458,14 @@ export async function bakeStack(options) {
386
458
  let skipped = found?.skipped ?? 0;
387
459
  let lastTileId = found?.lastTileId ?? -1;
388
460
  let sinceCheckpoint = 0;
461
+ let persisted = found?.entries.length ?? 0;
389
462
 
390
463
  /**
391
464
  * Writes down where the job has got to.
392
465
  * @returns {Promise<void>} - Resolves once it is durable.
393
466
  */
394
467
  const checkpoint = async () => {
395
- await writeCheckpoint(
468
+ persisted = await writeCheckpoint(
396
469
  workDir,
397
470
  {
398
471
  revision,
@@ -404,6 +477,7 @@ export async function bakeStack(options) {
404
477
  clustered: writer.clustered,
405
478
  },
406
479
  writer.entries,
480
+ persisted,
407
481
  );
408
482
  sinceCheckpoint = 0;
409
483
  };
@@ -429,6 +503,16 @@ export async function bakeStack(options) {
429
503
  sinceCheckpoint += 1;
430
504
  onProgress?.({ written, skipped, tileId, z, x, y });
431
505
  if (sinceCheckpoint >= checkpointEvery) await checkpoint();
506
+
507
+ // Handing time back, where the operator asked for that. A bake on a node
508
+ // that is also serving tiles holds the main thread for a few milliseconds
509
+ // per tile -- the pixel maths is synchronous -- and this is the knob that
510
+ // trades how long the bake takes for how much of the machine it takes
511
+ // while it runs. Off by default, because a node baking nothing else pays
512
+ // for this and gets nothing.
513
+ if (pauseMs > 0) {
514
+ await new Promise((resolve) => setTimeout(resolve, pauseMs));
515
+ }
432
516
  }
433
517
  } catch (error) {
434
518
  // A cancelled bake keeps its work. Deleting it would make stopping and
package/src/config.js CHANGED
@@ -364,6 +364,17 @@ const DEFAULTS = {
364
364
  * be thrown away, and a backup should be able to tell the difference.
365
365
  */
366
366
  cacheDir: undefined,
367
+ /**
368
+ * Milliseconds a bake waits between tiles. Zero is as fast as it can go.
369
+ *
370
+ * The pixel maths a merge does is synchronous, so a bake holds the main
371
+ * thread for a few milliseconds per tile -- and on a node that is also
372
+ * serving tiles, that is every request waiting behind it. This is the knob
373
+ * that trades how long the bake takes for how much of the machine it takes
374
+ * while it runs. Off by default: a node baking nothing pays for this and
375
+ * gets nothing.
376
+ */
377
+ bakePauseMs: 0,
367
378
  },
368
379
  /**
369
380
  * Folders scanned for new archives. Each entry is `{ path, categories,
@@ -0,0 +1,70 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import { encodeHeights, fillNodata, mergeElevation } from './elevation.js';
3
+ import { compositeRgba, toRaster } from './rgba.js';
4
+
5
+ /**
6
+ * The pixel half of a merge, off the main thread.
7
+ *
8
+ * `elevation.js` and `rgba.js` are entirely synchronous — decoding heights,
9
+ * masking, resampling and painting are all loops over typed arrays — so on the
10
+ * main thread a merge blocks everything else for as long as it takes. Serving
11
+ * one tile that way is a few milliseconds nobody notices. A bake is that, over
12
+ * and over, for hours, on a node that is also answering requests.
13
+ *
14
+ * This runs the same functions, unchanged, in a worker. Nothing here decides
15
+ * anything; the caller has already worked out what to merge and how.
16
+ */
17
+
18
+ /**
19
+ * Merges one tile's contributions and encodes the result.
20
+ * @param {object} job - `{space, contributions, options, output}`.
21
+ * @returns {object|null} - A raster, or null where nothing covered the tile.
22
+ */
23
+ function run(job) {
24
+ const { space, contributions, options, output = {} } = job;
25
+
26
+ if (space === 'rgba') {
27
+ const composited = compositeRgba(contributions, options);
28
+ if (!composited) return null;
29
+ // Alpha is kept unless the recipe asks for a flat tile.
30
+ return toRaster(composited, output.alpha !== false);
31
+ }
32
+
33
+ const merged = mergeElevation(contributions, options);
34
+ if (!merged) return null;
35
+ fillNodata(merged, output.nodata);
36
+ return encodeHeights(merged, {
37
+ ...output,
38
+ width: options.size,
39
+ height: options.size,
40
+ encoding: job.encoding,
41
+ });
42
+ }
43
+
44
+ parentPort?.on('message', (message) => {
45
+ try {
46
+ const raster = run(message.job);
47
+ if (!raster) {
48
+ parentPort.postMessage({ id: message.id, raster: null });
49
+ return;
50
+ }
51
+ // Copied into a buffer of its own and transferred, rather than handed over
52
+ // as it stands. A Buffer can be a view into a shared pool, and transferring
53
+ // that pool would take memory this worker is still using along with it.
54
+ const bytes = Uint8Array.prototype.slice.call(raster.data);
55
+ parentPort.postMessage(
56
+ {
57
+ id: message.id,
58
+ raster: {
59
+ data: bytes,
60
+ width: raster.width,
61
+ height: raster.height,
62
+ channels: raster.channels,
63
+ },
64
+ },
65
+ [bytes.buffer],
66
+ );
67
+ } catch (error) {
68
+ parentPort.postMessage({ id: message.id, error: error.message });
69
+ }
70
+ });
package/src/pixels.js ADDED
@@ -0,0 +1,129 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { Worker } from 'node:worker_threads';
4
+
5
+ /**
6
+ * Somewhere other than the main thread to do a merge's pixel maths.
7
+ *
8
+ * Opt-in and handed to `answerStackTile`, rather than switched on for
9
+ * everything. Serving one tile does a few milliseconds of synchronous work and
10
+ * nobody notices; a bake does that for hours beside a node that is also
11
+ * answering requests, and there it is the difference between a slow bake and a
12
+ * slow node. See docs/tile-stacks.md — "Running it".
13
+ */
14
+
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ /** How long to wait for a worker to answer before giving up on it. */
18
+ const REPLY_TIMEOUT_MS = 120000;
19
+
20
+ export class PixelWorker {
21
+ #worker;
22
+ #pending = new Map();
23
+ #next = 1;
24
+ #closed = false;
25
+
26
+ /**
27
+ * Starts a worker.
28
+ * @param {object} [options] - `timeoutMs` for a reply.
29
+ */
30
+ constructor(options = {}) {
31
+ this.#worker = new Worker(path.join(here, 'pixel-worker.js'));
32
+ this.timeoutMs = options.timeoutMs ?? REPLY_TIMEOUT_MS;
33
+ // The thread does not hold the process open. A bake keeps it busy; nothing
34
+ // else should keep it alive.
35
+ this.#worker.unref();
36
+
37
+ this.#worker.on('message', (message) => {
38
+ const waiting = this.#pending.get(message.id);
39
+ if (!waiting) return;
40
+ this.#pending.delete(message.id);
41
+ clearTimeout(waiting.timer);
42
+ if (message.error) waiting.reject(new Error(message.error));
43
+ else waiting.resolve(message.raster);
44
+ });
45
+
46
+ // A worker that dies takes every request in flight with it, and saying so
47
+ // is better than leaving them pending for ever.
48
+ const fail = (error) => {
49
+ this.#closed = true;
50
+ for (const waiting of this.#pending.values()) {
51
+ clearTimeout(waiting.timer);
52
+ waiting.reject(error);
53
+ }
54
+ this.#pending.clear();
55
+ };
56
+ this.#worker.on('error', fail);
57
+ this.#worker.on('exit', (code) => {
58
+ if (!this.#closed)
59
+ fail(new Error(`the pixel worker exited with ${code}`));
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Merges one tile's contributions, off this thread.
65
+ * @param {object} job - `{space, contributions, options, output, encoding}`.
66
+ * @returns {Promise<object|null>} - A raster, or null where nothing covered it.
67
+ */
68
+ async merge(job) {
69
+ // Async so that everything below rejects rather than throws. A caller
70
+ // reaching for `.catch` on a job it handed over should not be handed an
71
+ // exception instead, and a malformed raster is otherwise thrown from the
72
+ // copy below before there is a promise to reject.
73
+ if (this.#closed) throw new Error('the pixel worker is closed');
74
+
75
+ const id = this.#next;
76
+ this.#next += 1;
77
+
78
+ // Rasters are handed over rather than cloned, and where possible without
79
+ // being copied first either. A decoded tile is most of a megabyte per
80
+ // source, and copying every one of them is work on the very thread this
81
+ // exists to keep free -- enough of it to cost more than it saves.
82
+ //
83
+ // Copied only when the buffer does not own its memory. Node pools
84
+ // allocations under 4 KiB, and transferring a pooled buffer would hand
85
+ // over the whole pool with everything else in it; a raster is far larger
86
+ // than that and so is always its own allocation, but the check is what
87
+ // makes that a fact rather than an assumption.
88
+ //
89
+ // Either way the caller gives up these rasters. Nothing reads them after a
90
+ // merge, which is what makes that safe.
91
+ const transfers = [];
92
+ const owned = (bytes) =>
93
+ bytes.byteOffset === 0 && bytes.buffer.byteLength === bytes.byteLength;
94
+ const contributions = job.contributions.map((contribution) => {
95
+ if (!contribution?.raster?.data) return contribution;
96
+ const source = contribution.raster.data;
97
+ const bytes = owned(source)
98
+ ? source
99
+ : Uint8Array.prototype.slice.call(source);
100
+ transfers.push(bytes.buffer);
101
+ return {
102
+ ...contribution,
103
+ raster: { ...contribution.raster, data: bytes },
104
+ };
105
+ });
106
+
107
+ return new Promise((resolve, reject) => {
108
+ const timer = setTimeout(() => {
109
+ this.#pending.delete(id);
110
+ reject(new Error('the pixel worker did not answer'));
111
+ }, this.timeoutMs);
112
+ timer.unref?.();
113
+ this.#pending.set(id, { resolve, reject, timer });
114
+ this.#worker.postMessage(
115
+ { id, job: { ...job, contributions } },
116
+ transfers,
117
+ );
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Stops the worker.
123
+ * @returns {Promise<void>} - Resolves once it is gone.
124
+ */
125
+ async close() {
126
+ this.#closed = true;
127
+ await this.#worker.terminate();
128
+ }
129
+ }
package/src/stack-tile.js CHANGED
@@ -294,6 +294,7 @@ async function merge({
294
294
  rgba,
295
295
  format,
296
296
  gathered,
297
+ pixels,
297
298
  }) {
298
299
  const { contributors, contributions } = gathered;
299
300
  const first = contributions.find(Boolean);
@@ -306,6 +307,34 @@ async function merge({
306
307
  Math.max(...contributions.filter(Boolean).map((c) => c.raster.width));
307
308
  const output = resolved.stack.output ?? {};
308
309
 
310
+ const merging = {
311
+ z,
312
+ x,
313
+ y,
314
+ size: grid,
315
+ resampling: resolved.stack.resampling,
316
+ gaussianBlurSigma: resolved.stack.gaussianBlurSigma,
317
+ };
318
+
319
+ // Off this thread, where somebody has provided somewhere to put it. The same
320
+ // functions either way -- a worker runs `elevation.js` and `rgba.js`
321
+ // unchanged -- so this is about where the work happens and not what it is.
322
+ if (pixels) {
323
+ const off = await pixels.merge({
324
+ space: rgba ? 'rgba' : 'elevation',
325
+ contributions,
326
+ options: merging,
327
+ output,
328
+ encoding: output.encoding ?? first.source?.encoding,
329
+ });
330
+ if (!off) return { contributors, format, empty: true };
331
+ const body = await codec.encode(off, {
332
+ format,
333
+ lossless: rgba ? output.lossless === true : true,
334
+ });
335
+ return { contributors, format, body };
336
+ }
337
+
309
338
  // The two spaces differ only here. Everything around this -- reading, the
310
339
  // parent fallback, the cache, the headers -- is the same either way, which is
311
340
  // why they are one path rather than two.
@@ -370,7 +399,8 @@ async function merge({
370
399
  * @returns {Promise<StackAnswer>} - What to serve, or why there is nothing.
371
400
  */
372
401
  export async function answerStackTile(options) {
373
- const { resolved, z, x, y, tiles, codec, stackCache, signal, size } = options;
402
+ const { resolved, z, x, y, tiles, codec, stackCache, signal, size, pixels } =
403
+ options;
374
404
  const format = options.format ?? outputFormat(resolved);
375
405
  const rgba = resolved.stack.space === 'rgba';
376
406
 
@@ -426,6 +456,7 @@ export async function answerStackTile(options) {
426
456
  rgba,
427
457
  format,
428
458
  gathered,
459
+ pixels,
429
460
  });
430
461
 
431
462
  // Awaited, though it is tempting not to be. The write is a local disk write
@@ -644,6 +644,59 @@
644
644
  </form>
645
645
  </dialog>
646
646
 
647
+ <dialog id="bake-dialog">
648
+ <form method="dialog" id="bake-form">
649
+ <h2>Export to archive</h2>
650
+ <div class="sub" style="margin-bottom:0.8rem">
651
+ Runs the recipe over its sources and writes the result as a real
652
+ archive, with its own infohash, seeded like any other. It reads every
653
+ tile the sources hold, so it can take hours — and it can be stopped,
654
+ which keeps the work for the next run to carry on from.
655
+ </div>
656
+
657
+ <div class="field">
658
+ <label>Stack</label>
659
+ <div class="sub"><code id="bake-stack"></code></div>
660
+ </div>
661
+
662
+ <div class="field">
663
+ <label for="bake-name">Name</label>
664
+ <input id="bake-name" placeholder="Terrain" />
665
+ <div class="sub">
666
+ What the archive is called, and what a rebuild of it keeps — so
667
+ leave the date out of it. The file gets one:
668
+ <code id="bake-filename"></code>
669
+ </div>
670
+ </div>
671
+
672
+ <div class="field">
673
+ <label for="bake-description">Description</label>
674
+ <input id="bake-description" placeholder="Bathymetry under terrain" />
675
+ <div class="sub">
676
+ Optional. The date this was baked is added to whatever is here.
677
+ </div>
678
+ </div>
679
+
680
+ <div id="bake-location"></div>
681
+
682
+ <div class="field">
683
+ <label for="bake-categories">Categories</label>
684
+ <input id="bake-categories" placeholder="terrain, weekly" />
685
+ <div class="sub">
686
+ Comma separated. What the archive is filed under once it is added,
687
+ which is what <code>/latest/&lt;category&gt;/</code> follows. Left
688
+ empty, it takes the stack's own.
689
+ </div>
690
+ </div>
691
+
692
+ <div class="sub" id="bake-error" style="color: var(--danger)"></div>
693
+ <menu>
694
+ <button value="cancel">Cancel</button>
695
+ <button id="bake-go" value="default">Export</button>
696
+ </menu>
697
+ </form>
698
+ </dialog>
699
+
647
700
  <dialog id="adopt-dialog">
648
701
  <form method="dialog" id="adopt-form">
649
702
  <h2>Adopt existing torrents</h2>
@@ -5149,6 +5202,18 @@ Every piece is hashed against the ` +
5149
5202
  'and caching them would put a second copy of the archive ' +
5150
5203
  'beside the first. Evicted least-recently-used.',
5151
5204
  },
5205
+ {
5206
+ key: 'stacks.bakePauseMs',
5207
+ label: 'Pause between tiles while exporting',
5208
+ type: 'number',
5209
+ placeholder: '0',
5210
+ unit: 'ms',
5211
+ help:
5212
+ 'Zero is as fast as it can go. A merge does its pixel maths ' +
5213
+ 'on the main thread, so an export holds it for a few ' +
5214
+ 'milliseconds per tile and every request waits behind that. ' +
5215
+ 'Set this on a node that is also serving maps.',
5216
+ },
5152
5217
  {
5153
5218
  key: 'stacks.cacheDir',
5154
5219
  label: 'Where they go',
@@ -7666,6 +7731,87 @@ Every piece is hashed against the ` +
7666
7731
  $('stacks-add').onclick = () =>
7667
7732
  openStackDialog().catch((e) => toast(e.message));
7668
7733
 
7734
+ /**
7735
+ * Asks where an export should land before starting it.
7736
+ *
7737
+ * A dialog rather than a confirm, because there is something to decide:
7738
+ * a baked planet is hundreds of gigabytes and the disk it goes on is not
7739
+ * a detail. The same picker every other destination in this console
7740
+ * uses, so a location named once under Settings is offered here too.
7741
+ * @param {string} id - The stack to export.
7742
+ * @returns {Promise<void>} - Resolves once it is asked for or dismissed.
7743
+ */
7744
+ async function openBakeDialog(id, stack) {
7745
+ $('bake-stack').textContent = id;
7746
+ $('bake-error').textContent = '';
7747
+ $('bake-name').value = stack?.title ?? id;
7748
+ $('bake-description').value = stack?.description ?? '';
7749
+ $('bake-categories').value = (stack?.categories ?? []).join(', ');
7750
+ showBakeFilename();
7751
+ $('bake-location').innerHTML = locationPicker('bake-loc');
7752
+ await fillLocations('bake-loc');
7753
+ $('bake-dialog').showModal();
7754
+ }
7755
+
7756
+ /**
7757
+ * Shows what the file will be called, as the name is typed.
7758
+ *
7759
+ * The same rules the server applies, because a name is not a filename:
7760
+ * separators and the characters Windows refuses become dashes, and the
7761
+ * date is appended so successive builds do not collide. Seeing that
7762
+ * before pressing the button beats finding it on disk afterwards.
7763
+ * @returns {void}
7764
+ */
7765
+ function showBakeFilename() {
7766
+ const typed = $('bake-name').value.trim() || $('bake-stack').textContent;
7767
+ const slug =
7768
+ typed
7769
+ .replace(/[/\\<>:"|?*\u0000-\u001f\u007f -]/g, '-')
7770
+ .replace(/-{2,}/g, '-')
7771
+ .replace(/^[.\s-]+/, '')
7772
+ .replace(/[.\s-]+$/, '')
7773
+ .slice(0, 120) || 'stack';
7774
+ const day = new Date().toISOString().slice(0, 10).replaceAll('-', '');
7775
+ $('bake-filename').textContent = `${slug}-${day}.pmtiles`;
7776
+ }
7777
+
7778
+ $('bake-name').addEventListener('input', showBakeFilename);
7779
+
7780
+ $('bake-form').addEventListener('submit', async (event) => {
7781
+ if (event.submitter?.value === 'cancel') return;
7782
+ event.preventDefault();
7783
+
7784
+ const id = $('bake-stack').textContent;
7785
+ const categories = $('bake-categories')
7786
+ .value.split(',')
7787
+ .map((one) => one.trim())
7788
+ .filter(Boolean);
7789
+ const name = $('bake-name').value.trim();
7790
+ const description = $('bake-description').value.trim();
7791
+
7792
+ try {
7793
+ await api(`/api/stacks/${encodeURIComponent(id)}/bake`, {
7794
+ method: 'POST',
7795
+ body: {
7796
+ ...chosenLocation('bake-loc'),
7797
+ ...(categories.length > 0 ? { categories } : {}),
7798
+ ...(name ? { name } : {}),
7799
+ ...(description ? { description } : {}),
7800
+ },
7801
+ });
7802
+ } catch (error) {
7803
+ // Kept open, because the answer is usually something to change here
7804
+ // -- a location that does not exist, or a codec that is not
7805
+ // installed -- rather than something to go away and think about.
7806
+ $('bake-error').textContent = error.message;
7807
+ return;
7808
+ }
7809
+
7810
+ $('bake-dialog').close();
7811
+ toast(`Exporting ${id}`);
7812
+ loadStacks().catch((e) => toast(e.message));
7813
+ });
7814
+
7669
7815
  // Editing and deleting hang off the list, which is redrawn on every
7670
7816
  // change, so they are delegated the same way the source rows are.
7671
7817
  $('stacks-list').addEventListener('click', async (event) => {
@@ -7674,27 +7820,11 @@ Every piece is hashed against the ` +
7674
7820
  const bake = event.target.dataset.stackBake;
7675
7821
  const stopBake = event.target.dataset.stackBakeStop;
7676
7822
  if (bake !== undefined) {
7677
- // Confirmed, because it is not a small thing: it reads every tile
7678
- // every source holds, writes a whole archive, and then hashes it.
7679
- if (
7680
- !confirm(
7681
- `Run "${bake}" over its sources and write the result as a new ` +
7682
- 'archive?\n\nThis reads every tile the sources hold and can ' +
7683
- 'take hours. It can be stopped, and stopping keeps the work.',
7684
- )
7685
- ) {
7686
- return;
7687
- }
7688
- try {
7689
- await api(`/api/stacks/${encodeURIComponent(bake)}/bake`, {
7690
- method: 'POST',
7691
- body: {},
7692
- });
7693
- toast(`Exporting ${bake}`);
7694
- } catch (error) {
7695
- toast(error.message);
7696
- }
7697
- loadStacks().catch((e) => toast(e.message));
7823
+ const { stacks } = await api('/api/stacks');
7824
+ await openBakeDialog(
7825
+ bake,
7826
+ stacks.find((one) => one.id === bake),
7827
+ ).catch((e) => toast(e.message));
7698
7828
  } else if (stopBake !== undefined) {
7699
7829
  try {
7700
7830
  await api(`/api/stacks/${encodeURIComponent(stopBake)}/bake`, {