pmtiles-swarm 0.64.0 → 0.65.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 +31 -0
- package/docs/tile-stacks.md +54 -0
- package/package.json +1 -1
- package/src/bake-jobs.js +50 -17
- package/src/bake.js +104 -24
- package/src/config.js +11 -0
- package/src/pixel-worker.js +70 -0
- package/src/pixels.js +129 -0
- package/src/stack-tile.js +32 -1
- package/src/web/index.html +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@
|
|
|
4
4
|
### ✨ Features and improvements
|
|
5
5
|
- _...Add new stuff here..._
|
|
6
6
|
|
|
7
|
+
### 🐞 Bug fixes
|
|
8
|
+
- _...Add new stuff here..._
|
|
9
|
+
|
|
10
|
+
## 0.65.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A bake stays out of the way of the node it runs on.** It runs in the main process — unlike
|
|
13
|
+
hashing, there is no sidecar to send it to — and the merge's pixel maths is entirely
|
|
14
|
+
synchronous, so every millisecond of it is a millisecond the node is not answering requests.
|
|
15
|
+
Three changes, all of them from measuring rather than assuming.
|
|
16
|
+
|
|
17
|
+
**The pixel maths moved to a worker.** `src/pixels.js` runs `elevation.js` and `rgba.js`
|
|
18
|
+
unchanged on another thread. Against a request arriving every 5 ms while a bake runs, the delay
|
|
19
|
+
that request sees at the 99th percentile falls from 10.9 ms to 6.5 ms with two sources, and from
|
|
20
|
+
18.6 ms to 10.6 ms with four. The bake is 2–17% slower for it. Rasters are handed over rather
|
|
21
|
+
than copied — a decoded tile is most of a megabyte per source — so a merge takes ownership of
|
|
22
|
+
what it is given, which is safe because nothing reads a contribution afterwards and is asserted
|
|
23
|
+
rather than assumed. Serving does not use it: one tile is a few milliseconds nobody notices.
|
|
24
|
+
|
|
25
|
+
**The checkpoint appends instead of rewriting.** It went through `serializeDirectory`, which was
|
|
26
|
+
elegant reuse and the wrong tool — that is a distribution format, and producing it costs a varint
|
|
27
|
+
pass over every entry. Re-encoding all of them every time also made the total work quadratic in
|
|
28
|
+
the length of the job. Fixed 24-byte records can be appended, and only the last one can change
|
|
29
|
+
once written, so a checkpoint costs the work since the last one: **446 ms at four million entries
|
|
30
|
+
becomes 11 ms, and stays 11 ms**.
|
|
31
|
+
|
|
32
|
+
**And `stacks.bakePauseMs`**, how long a bake waits between tiles. Zero by default, which is
|
|
33
|
+
right for a node baking and doing nothing else. On a node that is also serving maps it is the
|
|
34
|
+
direct trade between how long the bake takes and how much of the machine it takes while running.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
7
38
|
### 🐞 Bug fixes
|
|
8
39
|
- _...Add new stuff here..._
|
|
9
40
|
|
package/docs/tile-stacks.md
CHANGED
|
@@ -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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.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
|
@@ -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
|
/**
|
|
@@ -158,6 +159,53 @@ export class BakeManager {
|
|
|
158
159
|
const destination = path.join(workDir, job.name);
|
|
159
160
|
const format = outputFormat(resolved);
|
|
160
161
|
|
|
162
|
+
// Only where there is pixel work to move. A passthrough bake hands bytes
|
|
163
|
+
// straight through and would pay for a thread it never uses.
|
|
164
|
+
const pixels = codec ? new PixelWorker() : null;
|
|
165
|
+
try {
|
|
166
|
+
await this.#merge(
|
|
167
|
+
job,
|
|
168
|
+
resolved,
|
|
169
|
+
codec,
|
|
170
|
+
pixels,
|
|
171
|
+
workDir,
|
|
172
|
+
destination,
|
|
173
|
+
format,
|
|
174
|
+
);
|
|
175
|
+
} finally {
|
|
176
|
+
await pixels?.close().catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The second half. `addLocalArchive` registers the add in the library's own
|
|
180
|
+
// in-progress list, which is what the archives view already draws -- so the
|
|
181
|
+
// hashing shows up there without this having to report it twice.
|
|
182
|
+
job.phase = 'importing';
|
|
183
|
+
const entry = await this.#library.addLocalArchive(destination, {
|
|
184
|
+
categories: options.categories ?? resolved.stack.categories,
|
|
185
|
+
// Moved out of the working directory as it is taken on, so a finished
|
|
186
|
+
// archive does not live among the checkpoint files of the job that made
|
|
187
|
+
// it.
|
|
188
|
+
publishDir: options.publishDir ?? (await this.#savePath(options)),
|
|
189
|
+
mode: 'mirror',
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
job.infoHash = entry.infoHash;
|
|
193
|
+
job.phase = 'done';
|
|
194
|
+
job.finishedAt = new Date().toISOString();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The merging half, which is where the time goes.
|
|
199
|
+
* @param {object} job - The job to update as it goes.
|
|
200
|
+
* @param {object} resolved - The resolved stack.
|
|
201
|
+
* @param {object|null} codec - The codec, where the recipe needs one.
|
|
202
|
+
* @param {object|null} pixels - Somewhere to do the pixel maths.
|
|
203
|
+
* @param {string} workDir - Where the unfinished work lives.
|
|
204
|
+
* @param {string} destination - Where the archive goes.
|
|
205
|
+
* @param {string} format - The output format.
|
|
206
|
+
* @returns {Promise<void>} - Resolves when the file is written.
|
|
207
|
+
*/
|
|
208
|
+
async #merge(job, resolved, codec, pixels, workDir, destination, format) {
|
|
161
209
|
// Read through the tile store rather than off the disk, so a cache-mode
|
|
162
210
|
// source is scanned the same way it is served: its directories come out of
|
|
163
211
|
// the swarm, and the store holds them to its own byte budget.
|
|
@@ -194,10 +242,12 @@ export class BakeManager {
|
|
|
194
242
|
resolved,
|
|
195
243
|
tiles: this.#tiles,
|
|
196
244
|
codec,
|
|
245
|
+
pixels,
|
|
197
246
|
signal: job.controller.signal,
|
|
198
247
|
format,
|
|
199
248
|
}),
|
|
200
249
|
header: { format },
|
|
250
|
+
pauseMs: this.#config.stacks?.bakePauseMs ?? 0,
|
|
201
251
|
metadata: {
|
|
202
252
|
name: resolved.stack.title ?? job.stackId,
|
|
203
253
|
description: resolved.stack.description,
|
|
@@ -215,23 +265,6 @@ export class BakeManager {
|
|
|
215
265
|
});
|
|
216
266
|
|
|
217
267
|
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
268
|
}
|
|
236
269
|
|
|
237
270
|
/**
|
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 {
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
147
|
+
* Writes a checkpoint, appending what is new rather than rewriting it all.
|
|
98
148
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the
|
|
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
|
-
* @
|
|
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.
|
|
113
|
-
|
|
178
|
+
paths.state,
|
|
179
|
+
JSON.stringify({ ...state, entryCount: entries.length }),
|
|
114
180
|
);
|
|
115
|
-
|
|
181
|
+
return entries.length;
|
|
116
182
|
}
|
|
117
183
|
|
|
118
184
|
/**
|
|
@@ -297,7 +363,7 @@ export function tileTypeFor(format) {
|
|
|
297
363
|
* @returns {Function} - `(z, x, y) => Promise<Buffer|null>`.
|
|
298
364
|
*/
|
|
299
365
|
export function mergeTileFor(options) {
|
|
300
|
-
const { resolved, tiles, codec, signal } = options;
|
|
366
|
+
const { resolved, tiles, codec, signal, pixels } = options;
|
|
301
367
|
const format = options.format ?? outputFormat(resolved);
|
|
302
368
|
const size = options.size ?? outputSize(resolved.stack);
|
|
303
369
|
|
|
@@ -313,6 +379,7 @@ export function mergeTileFor(options) {
|
|
|
313
379
|
signal,
|
|
314
380
|
size,
|
|
315
381
|
format,
|
|
382
|
+
pixels,
|
|
316
383
|
});
|
|
317
384
|
|
|
318
385
|
// A required source that cannot be read stops the job. Baking around it
|
|
@@ -357,6 +424,7 @@ export async function bakeStack(options) {
|
|
|
357
424
|
header = {},
|
|
358
425
|
deduplicate = true,
|
|
359
426
|
checkpointEvery = DEFAULT_CHECKPOINT_EVERY,
|
|
427
|
+
pauseMs = 0,
|
|
360
428
|
} = options;
|
|
361
429
|
|
|
362
430
|
if (!sources?.length) throw new Error('a bake needs at least one source');
|
|
@@ -386,13 +454,14 @@ export async function bakeStack(options) {
|
|
|
386
454
|
let skipped = found?.skipped ?? 0;
|
|
387
455
|
let lastTileId = found?.lastTileId ?? -1;
|
|
388
456
|
let sinceCheckpoint = 0;
|
|
457
|
+
let persisted = found?.entries.length ?? 0;
|
|
389
458
|
|
|
390
459
|
/**
|
|
391
460
|
* Writes down where the job has got to.
|
|
392
461
|
* @returns {Promise<void>} - Resolves once it is durable.
|
|
393
462
|
*/
|
|
394
463
|
const checkpoint = async () => {
|
|
395
|
-
await writeCheckpoint(
|
|
464
|
+
persisted = await writeCheckpoint(
|
|
396
465
|
workDir,
|
|
397
466
|
{
|
|
398
467
|
revision,
|
|
@@ -404,6 +473,7 @@ export async function bakeStack(options) {
|
|
|
404
473
|
clustered: writer.clustered,
|
|
405
474
|
},
|
|
406
475
|
writer.entries,
|
|
476
|
+
persisted,
|
|
407
477
|
);
|
|
408
478
|
sinceCheckpoint = 0;
|
|
409
479
|
};
|
|
@@ -429,6 +499,16 @@ export async function bakeStack(options) {
|
|
|
429
499
|
sinceCheckpoint += 1;
|
|
430
500
|
onProgress?.({ written, skipped, tileId, z, x, y });
|
|
431
501
|
if (sinceCheckpoint >= checkpointEvery) await checkpoint();
|
|
502
|
+
|
|
503
|
+
// Handing time back, where the operator asked for that. A bake on a node
|
|
504
|
+
// that is also serving tiles holds the main thread for a few milliseconds
|
|
505
|
+
// per tile -- the pixel maths is synchronous -- and this is the knob that
|
|
506
|
+
// trades how long the bake takes for how much of the machine it takes
|
|
507
|
+
// while it runs. Off by default, because a node baking nothing else pays
|
|
508
|
+
// for this and gets nothing.
|
|
509
|
+
if (pauseMs > 0) {
|
|
510
|
+
await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
511
|
+
}
|
|
432
512
|
}
|
|
433
513
|
} catch (error) {
|
|
434
514
|
// 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 } =
|
|
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
|
package/src/web/index.html
CHANGED
|
@@ -5149,6 +5149,18 @@ Every piece is hashed against the ` +
|
|
|
5149
5149
|
'and caching them would put a second copy of the archive ' +
|
|
5150
5150
|
'beside the first. Evicted least-recently-used.',
|
|
5151
5151
|
},
|
|
5152
|
+
{
|
|
5153
|
+
key: 'stacks.bakePauseMs',
|
|
5154
|
+
label: 'Pause between tiles while exporting',
|
|
5155
|
+
type: 'number',
|
|
5156
|
+
placeholder: '0',
|
|
5157
|
+
unit: 'ms',
|
|
5158
|
+
help:
|
|
5159
|
+
'Zero is as fast as it can go. A merge does its pixel maths ' +
|
|
5160
|
+
'on the main thread, so an export holds it for a few ' +
|
|
5161
|
+
'milliseconds per tile and every request waits behind that. ' +
|
|
5162
|
+
'Set this on a node that is also serving maps.',
|
|
5163
|
+
},
|
|
5152
5164
|
{
|
|
5153
5165
|
key: 'stacks.cacheDir',
|
|
5154
5166
|
label: 'Where they go',
|