pmtiles-swarm 0.75.0 → 0.76.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,44 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.76.0
11
+ ### ✨ Features and improvements
12
+ - **A stopped export stays stopped.** Stopping one left a checkpoint, and a checkpoint says what was
13
+ in progress but not why it stopped - so the next restart could not tell somebody pressing Stop
14
+ from a crash, and picked it up again. Stopping now writes a marker beside the checkpoint, and a
15
+ restart reports what it is holding rather than resuming it. Starting or resuming clears the marker.
16
+
17
+ And the work can be thrown away, which it could not be before: `DELETE /api/stacks/:id/bake/work`
18
+ removes the working directory, which for an abandoned export is hundreds of gigabytes of buffered
19
+ tiles that previously had to be found by hand. Refused while a merge is running rather than pulled
20
+ out from under it.
21
+
22
+ ### 🐞 Bug fixes
23
+ - **An edit inside one clock tick went unnoticed.** `stacks.json` is re-read when its modification
24
+ time changes, and a filesystem's clock is coarser than an edit: on NTFS the tick is about 15 ms,
25
+ measured here at 36 rapid rewrites in 40 landing on the same timestamp. Two edits that close
26
+ together left the mtime alone and the second was never read.
27
+
28
+ The size is compared as well now, which catches the ones that changed the file's length - most of
29
+ them - and costs nothing, since the stat was already being made.
30
+
31
+ This is also what made the reload test flake, roughly one run in three: it wrote the file twice in
32
+ quick succession and then depended on how those two writes fell against the clock. It stamps both
33
+ writes to the same instant now and asserts they really are the same, so it tests whether a change
34
+ is noticed rather than whether the clock happened to tick.
35
+ - **A stack with a shallow global source served holes above z14.** How far the merge would climb
36
+ for a source with no tile at this zoom was a fixed six levels. GEBCO is z0-8 and the sea floor has
37
+ no more detail to give, so a stack serving z16 has to upscale that z8 tile eight levels - and at
38
+ z15 the climb stopped one short of the only tile that existed. Over open water, where the other
39
+ source was sparse and had nothing either, no source contributed at all and the stack correctly
40
+ answered no-tile. A rectangular hole, one tile wide, in the middle of the sea.
41
+
42
+ It is derived now rather than fixed: the deepest zoom the stack serves, less the shallowest source
43
+ under it. Nothing to set and nothing to get wrong - the right answer is computable, and a smaller
44
+ one would only punch holes. Somebody who wants the merge to stop climbing says so with `maxzoom`,
45
+ which stops the stack serving that deep at all: the same wish, said where it also stops the work.
46
+ Never below the old six, so no stack reaches less far than it did.
47
+
10
48
  ## 0.75.0
11
49
  ### ✨ Features and improvements
12
50
  - **`maskRange`, because nodata is a band and not a number.** `maskValues` and `maskColors` both
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.75.0",
3
+ "version": "0.76.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
@@ -3303,6 +3303,31 @@ export function createApp({
3303
3303
  }),
3304
3304
  );
3305
3305
 
3306
+ /**
3307
+ * Throws away what a stopped export had done.
3308
+ *
3309
+ * Separate from stopping, and deliberately a second decision. Stopping keeps
3310
+ * the work because an export may be hours in and somebody may want it back;
3311
+ * this is for when they do not, and until now the only way to be rid of
3312
+ * hundreds of gigabytes of buffered tiles was to find the directory by hand.
3313
+ */
3314
+ app.delete(
3315
+ '/api/stacks/:id/bake/work',
3316
+ route(async (req, res) => {
3317
+ if (!bakes) {
3318
+ return res.status(501).json({ error: 'this node does not bake' });
3319
+ }
3320
+ const discarded = await bakes.discard(req.params.id);
3321
+ if (!discarded) {
3322
+ return res.status(409).json({
3323
+ error:
3324
+ 'there is no stopped export for that stack, or one is still running',
3325
+ });
3326
+ }
3327
+ return res.json({ discarded: true });
3328
+ }),
3329
+ );
3330
+
3306
3331
  /**
3307
3332
  * Creates or replaces a stack.
3308
3333
  *
package/src/bake-jobs.js CHANGED
@@ -7,7 +7,11 @@ import {
7
7
  bakeStack,
8
8
  bakedArchiveName,
9
9
  bakedName,
10
+ clearStopped,
11
+ discardCheckpoint,
12
+ markStopped,
10
13
  mergeTileFor,
14
+ wasStopped,
11
15
  } from './bake.js';
12
16
  import { PixelWorker } from './pixels.js';
13
17
  import { outputFormat } from './stack-tile.js';
@@ -124,6 +128,10 @@ export class BakeManager {
124
128
  #loadCodec;
125
129
  #cutlines;
126
130
  #jobs = new Map();
131
+ // Exports somebody stopped, whose work is still on disk. Kept so the console
132
+ // can offer to resume or discard them: after a restart there is no job in
133
+ // memory, only a directory nobody would think to look for.
134
+ #held = new Map();
127
135
 
128
136
  /**
129
137
  * @param {object} deps - The library, the tile store, the config and the codec probe.
@@ -152,11 +160,36 @@ export class BakeManager {
152
160
  */
153
161
  get(stackId) {
154
162
  const job = this.#jobs.get(stackId);
155
- return job ? this.#describe(job) : null;
163
+ if (job) return this.#describe(job);
164
+
165
+ // Nothing running, but work somebody stopped may still be there. Reported
166
+ // in the same shape so the console has one thing to read.
167
+ const stopped = this.#held.get(stackId);
168
+ if (!stopped) return null;
169
+ return {
170
+ stackId,
171
+ phase: 'stopped',
172
+ written: stopped.written,
173
+ resumable: true,
174
+ ...stopped.describe,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Every stack with work waiting that nobody has decided about.
180
+ * @returns {string[]} - Their ids.
181
+ */
182
+ heldStacks() {
183
+ return [...this.#held.keys()];
156
184
  }
157
185
 
158
186
  /**
159
- * Stops a bake, leaving its work where the next run can pick it up.
187
+ * Stops a bake, leaving its work for somebody to pick up or throw away.
188
+ *
189
+ * Marked as stopped on purpose, so the next start leaves it alone. The work
190
+ * is still there and `resume` takes it up again -- what changes is who
191
+ * decides, which for a job that may be hours from finishing should be a
192
+ * person rather than a restart.
160
193
  * @param {string} stackId - Which stack.
161
194
  * @returns {boolean} - True if there was one to stop.
162
195
  */
@@ -164,10 +197,46 @@ export class BakeManager {
164
197
  const job = this.#jobs.get(stackId);
165
198
  if (!job || job.finishedAt) return false;
166
199
  job.cancelling = true;
200
+ job.stoppedOnPurpose = true;
167
201
  job.controller.abort();
202
+ // Not awaited: the abort has to reach the merge now, and a mark written a
203
+ // moment later is still written long before anything restarts.
204
+ markStopped(workDirFor(job, this.#config)).catch(() => {});
168
205
  return true;
169
206
  }
170
207
 
208
+ /**
209
+ * Throws away what a stopped export had done.
210
+ *
211
+ * The counterpart to stopping. An export that will not be finished leaves
212
+ * hundreds of gigabytes of buffered tiles behind, and until now the only way
213
+ * to be rid of them was to find the directory by hand.
214
+ * @param {string} stackId - Which stack.
215
+ * @returns {Promise<boolean>} - True if there was work to discard.
216
+ */
217
+ async discard(stackId) {
218
+ const running = this.#jobs.get(stackId);
219
+ // Refused rather than raced. Removing the directory under a running merge
220
+ // would have it fail on its next write, reporting a disk problem for
221
+ // something somebody chose.
222
+ if (running && !running.finishedAt) return false;
223
+
224
+ let found = false;
225
+ for (const root of this.#workRoots()) {
226
+ const directory = path.join(root, WORK_DIR, stackId);
227
+ const there = await fs
228
+ .access(directory)
229
+ .then(() => true)
230
+ .catch(() => false);
231
+ if (!there) continue;
232
+ await discardCheckpoint(directory);
233
+ found = true;
234
+ }
235
+ this.#jobs.delete(stackId);
236
+ this.#held.delete(stackId);
237
+ return found;
238
+ }
239
+
171
240
  /**
172
241
  * Picks up exports a previous run did not finish.
173
242
  *
@@ -203,6 +272,21 @@ export class BakeManager {
203
272
  const resolved = resolve(stackId);
204
273
  if (!resolved || bakeRevision(resolved) !== state.revision) continue;
205
274
 
275
+ // Somebody stopped this one. It stays where it is until they say
276
+ // otherwise -- an export begun again by a restart is the opposite of
277
+ // what pressing Stop meant.
278
+ if (await wasStopped(path.join(directory, stackId))) {
279
+ this.#held.set(stackId, {
280
+ written: state.written ?? 0,
281
+ describe: state.describe,
282
+ });
283
+ console.log(
284
+ `[bake] ${stackId} was stopped on purpose; leaving its ` +
285
+ `${state.written ?? 0} tiles for you to resume or discard`,
286
+ );
287
+ continue;
288
+ }
289
+
206
290
  try {
207
291
  const job = await this.start({ resolved, ...state.describe });
208
292
  started.push(job);
@@ -367,6 +451,12 @@ export class BakeManager {
367
451
  const destination = path.join(workDir, job.name);
368
452
  const format = outputFormat(resolved);
369
453
 
454
+ // Running again is the answer to having been stopped, so the mark goes.
455
+ // Left behind, an export somebody restarted by hand would be passed over
456
+ // by the next restart, which is the same surprise the other way round.
457
+ await clearStopped(workDir);
458
+ this.#held.delete(job.stackId);
459
+
370
460
  // Sized with the batch, so every merge in flight has a thread to do its
371
461
  // arithmetic on rather than queueing behind one. Only where there is pixel
372
462
  // work to move: a passthrough bake hands bytes straight through and would
package/src/bake.js CHANGED
@@ -42,6 +42,7 @@ const DEFAULT_CONCURRENCY = 4;
42
42
  /** What the working directory holds while a bake is in progress. */
43
43
  const FILES = Object.freeze({
44
44
  state: 'bake-state.json',
45
+ stopped: 'bake-stopped.json',
45
46
  entries: 'bake-entries.bin',
46
47
  tiles: 'bake-tiles.bin',
47
48
  });
@@ -59,6 +60,64 @@ export function checkpointPaths(workDir) {
59
60
  };
60
61
  }
61
62
 
63
+ /**
64
+ * Records that an export was stopped on purpose.
65
+ *
66
+ * A checkpoint says what was in progress, not why it stopped, so a crash and
67
+ * somebody pressing Stop look identical on disk -- and the node picks both up
68
+ * on the next start. That is right for the crash and wrong for the person, who
69
+ * stopped it and then watched it begin again.
70
+ *
71
+ * Written beside the checkpoint rather than into it, so it cannot be lost to a
72
+ * half-finished write of the state the export is still making.
73
+ * @param {string} workDir - The bake's working directory.
74
+ * @returns {Promise<void>} - Resolves once it is written.
75
+ */
76
+ export async function markStopped(workDir) {
77
+ await fs
78
+ .writeFile(
79
+ path.join(workDir, FILES.stopped),
80
+ JSON.stringify({ at: new Date().toISOString() }),
81
+ )
82
+ .catch(() => {});
83
+ }
84
+
85
+ /**
86
+ * Forgets that an export was stopped, because it is running again.
87
+ * @param {string} workDir - The bake's working directory.
88
+ * @returns {Promise<void>} - Resolves once it is gone.
89
+ */
90
+ export async function clearStopped(workDir) {
91
+ await fs
92
+ .rm(path.join(workDir, FILES.stopped), { force: true })
93
+ .catch(() => {});
94
+ }
95
+
96
+ /**
97
+ * Whether an export was stopped on purpose rather than interrupted.
98
+ * @param {string} workDir - The bake's working directory.
99
+ * @returns {Promise<boolean>} - True when somebody stopped it.
100
+ */
101
+ export async function wasStopped(workDir) {
102
+ return fs
103
+ .access(path.join(workDir, FILES.stopped))
104
+ .then(() => true)
105
+ .catch(() => false);
106
+ }
107
+
108
+ /**
109
+ * Forgets an export's unfinished work.
110
+ *
111
+ * The whole directory, because a checkpoint is only meaningful with the tiles
112
+ * it names -- leaving either behind is leaving something that will be picked
113
+ * up and found wanting.
114
+ * @param {string} workDir - The bake's working directory.
115
+ * @returns {Promise<void>} - Resolves once it is gone.
116
+ */
117
+ export async function discardCheckpoint(workDir) {
118
+ await fs.rm(workDir, { recursive: true, force: true });
119
+ }
120
+
62
121
  /**
63
122
  * One entry, as a checkpoint stores it.
64
123
  *
package/src/stack-tile.js CHANGED
@@ -36,9 +36,42 @@ import { TileReadError } from './tiles.js';
36
36
  * a tile in an archive or a hole where one is not needed.
37
37
  */
38
38
 
39
- /** How far up the pyramid a merge will climb for a source with no tile here. */
39
+ /**
40
+ * How far up the pyramid a merge will climb for a source with no tile here,
41
+ * where the stack does not say and nothing can be worked out.
42
+ */
40
43
  const PARENT_LIMIT = 6;
41
44
 
45
+ /**
46
+ * How far this stack has to climb for its shallowest source to keep working.
47
+ *
48
+ * A global source is shallow on purpose -- GEBCO is z0-8 and the sea floor has
49
+ * no more detail to give -- so serving a stack to z16 means upscaling that z8
50
+ * tile eight levels. A fixed limit truncates exactly the arrangement this
51
+ * feature is for: at z15 the climb stopped one level short of the only tile
52
+ * that exists, no source contributed, and the stack answered no-tile over open
53
+ * water.
54
+ *
55
+ * So it is derived from what the stack spans rather than assumed or set: the
56
+ * right answer is computable, and a recipe naming a smaller one would only
57
+ * punch holes in itself. Somebody who wants the merge to stop climbing says so
58
+ * with `maxzoom`, which stops the stack serving that deep at all -- the same
59
+ * wish, said where it also stops the work.
60
+ *
61
+ * Never below the old fixed limit, so no stack reaches less far than it did.
62
+ * @param {object} resolved - The resolved stack.
63
+ * @returns {number} - Levels a source may climb.
64
+ */
65
+ export function parentLimitFor(resolved) {
66
+ const { maxzoom } = stackCoverage(resolved);
67
+ const shallowest = resolved.sources
68
+ .map((source) => source.entry?.pmtiles?.maxZoom)
69
+ .filter((zoom) => Number.isFinite(zoom));
70
+ if (!shallowest.length || !Number.isFinite(maxzoom)) return PARENT_LIMIT;
71
+
72
+ return Math.max(PARENT_LIMIT, maxzoom - Math.min(...shallowest));
73
+ }
74
+
42
75
  /** The deepest zoom a tile id is defined for. */
43
76
  const MAX_ZOOM = 26;
44
77
 
@@ -141,8 +174,8 @@ export function clipsFor(resolved, cutlines, z, x, y, size = 256) {
141
174
  * @param {object} options - Source, coordinates, the tile store and whether to climb.
142
175
  * @returns {Promise<object|null>} - The tile and the zoom it came from.
143
176
  */
144
- async function readFrom({ source, z, x, y, tiles, climb, signal }) {
145
- const floor = climb ? Math.max(0, z - PARENT_LIMIT) : z;
177
+ async function readFrom({ source, z, x, y, tiles, climb, signal, limit }) {
178
+ const floor = climb ? Math.max(0, z - (limit ?? PARENT_LIMIT)) : z;
146
179
  for (let at = z; at >= floor; at -= 1) {
147
180
  const shift = z - at;
148
181
  const tile = await tiles.getTile(
@@ -322,6 +355,7 @@ export function passThroughRead({
322
355
  * @returns {Promise<object>} - `{contributors, contributions}` or `{error}`.
323
356
  */
324
357
  async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
358
+ const limit = parentLimitFor(resolved);
325
359
  return Promise.all(
326
360
  resolved.sources.map(async (source, index) => {
327
361
  if (!source.entry) return { source, found: null };
@@ -340,6 +374,7 @@ async function readAll({ resolved, z, x, y, tiles, signal, clips }) {
340
374
  tiles,
341
375
  climb: true,
342
376
  signal,
377
+ limit,
343
378
  }),
344
379
  };
345
380
  } catch (error) {
package/src/stacks.js CHANGED
@@ -528,6 +528,7 @@ export class StackStore {
528
528
  #stacks = new Map();
529
529
  #problems = new Map();
530
530
  #mtime = null;
531
+ #size = null;
531
532
  #checkedAt = 0;
532
533
 
533
534
  /**
@@ -552,10 +553,13 @@ export class StackStore {
552
553
  let raw;
553
554
  try {
554
555
  raw = JSON.parse(await fs.readFile(this.#file, 'utf8'));
555
- this.#mtime = (await fs.stat(this.#file)).mtimeMs;
556
+ const stat = await fs.stat(this.#file);
557
+ this.#mtime = stat.mtimeMs;
558
+ this.#size = stat.size;
556
559
  } catch (error) {
557
560
  if (error.code === 'ENOENT') {
558
561
  this.#mtime = null;
562
+ this.#size = null;
559
563
  return;
560
564
  }
561
565
  throw error;
@@ -588,9 +592,15 @@ export class StackStore {
588
592
  if (now - this.#checkedAt < 1000) return false;
589
593
  this.#checkedAt = now;
590
594
 
595
+ // Size as well as the timestamp. A filesystem's clock is coarser than an
596
+ // edit: on NTFS the tick is about 15 ms, so two writes in quick succession
597
+ // land on the same mtime and the second would never be seen. Comparing the
598
+ // length as well catches the ones that changed it, which is most of them --
599
+ // and it costs nothing, since the stat was made anyway.
591
600
  const stat = await fs.stat(this.#file).catch(() => null);
592
601
  const mtime = stat?.mtimeMs ?? null;
593
- if (mtime === this.#mtime) return false;
602
+ const size = stat?.size ?? null;
603
+ if (mtime === this.#mtime && size === this.#size) return false;
594
604
  await this.load();
595
605
  return true;
596
606
  }
@@ -676,6 +686,8 @@ export class StackStore {
676
686
  `,
677
687
  );
678
688
  await fs.rename(temp, this.#file);
679
- this.#mtime = (await fs.stat(this.#file)).mtimeMs;
689
+ const written = await fs.stat(this.#file);
690
+ this.#mtime = written.mtimeMs;
691
+ this.#size = written.size;
680
692
  }
681
693
  }
@@ -1905,18 +1905,21 @@
1905
1905
  );
1906
1906
 
1907
1907
  /**
1908
- * A mask range as the field shows it.
1908
+ * One end of a mask range, for the box that shows it.
1909
1909
  *
1910
- * One pair is written plainly; a recipe carrying several is shown as
1911
- * JSON, because the editor offers one band and the file may hold more
1912
- * than the editor can draw.
1910
+ * A recipe may hold several bands where the editor offers one. The first
1911
+ * is what the boxes show, and the row says how many more there are --
1912
+ * silently dropping them would be worse than saying the editor cannot
1913
+ * draw them.
1913
1914
  * @param {*} range - What the recipe says.
1915
+ * @param {number} which - 0 for the low end, 1 for the high.
1914
1916
  * @returns {string} - What to put in the box.
1915
1917
  */
1916
- const rangeText = (range) => {
1918
+ const rangeEdge = (range, which) => {
1917
1919
  if (!Array.isArray(range) || range.length === 0) return '';
1918
- if (Array.isArray(range[0])) return JSON.stringify(range);
1919
- return range.slice(0, 2).join(', ');
1920
+ const first = Array.isArray(range[0]) ? range[0] : range;
1921
+ const edge = first[which];
1922
+ return Number.isFinite(Number(edge)) ? String(edge) : '';
1920
1923
  };
1921
1924
 
1922
1925
  /**
@@ -7635,11 +7638,23 @@ Every piece is hashed against the ` +
7635
7638
  value="${escapeHtml((source.maskValues ?? []).join(', '))}" />
7636
7639
  </label>
7637
7640
  <label class="choice"
7638
- title="Mask every height inside a band, written low, high. Nodata is rarely one number: an archive resampled on its way to being built does not hold what it was authored with, so a sea authored as 0 arrives scattered across -0.9 m to 0 and masking the two ends of that leaves everything between, standing proud of whatever is underneath. A band says what you mean, and asymmetrically: sea is everything up to zero and nothing above it.">
7639
- Mask range
7640
- <input style="width:9rem" placeholder="-1, 0"
7641
- data-stack-field="maskRange" data-stack-index="${index}"
7642
- value="${escapeHtml(rangeText(source.maskRange))}" /> m
7641
+ title="Mask every height between these two, inclusive. Separate from Mask heights, which takes values one at a time — nodata is often a band rather than a number, because an archive resampled on its way to being built does not hold what it was authored with. A sea authored as 0 arrives scattered across -0.9 m to 0, and masking the two ends of that leaves everything between, standing proud of whatever is underneath. Leave both empty for no band.">
7642
+ Mask between
7643
+ <input type="number" step="any" style="width:5.5rem" placeholder="low"
7644
+ data-stack-field="maskRangeLow" data-stack-index="${index}"
7645
+ value="${rangeEdge(source.maskRange, 0)}" />
7646
+ and
7647
+ <input type="number" step="any" style="width:5.5rem" placeholder="high"
7648
+ data-stack-field="maskRangeHigh" data-stack-index="${index}"
7649
+ value="${rangeEdge(source.maskRange, 1)}" /> m
7650
+ ${
7651
+ Array.isArray(source.maskRange) &&
7652
+ Array.isArray(source.maskRange[0]) &&
7653
+ source.maskRange.length > 1
7654
+ ? `<span class="sub">and ${source.maskRange.length - 1} more,
7655
+ which editing here replaces</span>`
7656
+ : ''
7657
+ }
7643
7658
  </label>`;
7644
7659
 
7645
7660
  return `
@@ -7761,16 +7776,22 @@ Every piece is hashed against the ` +
7761
7776
  .filter(Boolean);
7762
7777
  } else if (field === 'required') {
7763
7778
  source.required = event.target.checked;
7764
- } else if (field === 'maskRange') {
7765
- // Held while it is half-typed, the way the clip box is: four numbers
7766
- // arrive one keystroke at a time and a field that cleared itself
7767
- // after the first comma could never be filled in.
7768
- const numbers = event.target.value
7769
- .split(',')
7770
- .map((part) => Number(part.trim()))
7771
- .filter((n) => Number.isFinite(n));
7772
- if (numbers.length >= 2) source.maskRange = numbers.slice(0, 2);
7773
- else if (event.target.value.trim() === '') delete source.maskRange;
7779
+ } else if (field === 'maskRangeLow' || field === 'maskRangeHigh') {
7780
+ // A band needs both ends, so one on its own is a half-typed thought
7781
+ // rather than a range -- read them together, and write nothing until
7782
+ // there are two numbers to write.
7783
+ const row = event.target.closest('.card');
7784
+ const edge = (name) => {
7785
+ const box = row?.querySelector(`[data-stack-field="${name}"]`);
7786
+ return box && box.value.trim() !== '' ? Number(box.value) : null;
7787
+ };
7788
+ const low = edge('maskRangeLow');
7789
+ const high = edge('maskRangeHigh');
7790
+ if (Number.isFinite(low) && Number.isFinite(high)) {
7791
+ source.maskRange = low <= high ? [low, high] : [high, low];
7792
+ } else if (low === null && high === null) {
7793
+ delete source.maskRange;
7794
+ }
7774
7795
  } else if (field === 'feather') {
7775
7796
  const pixels = Number(event.target.value);
7776
7797
  if (event.target.value === '' || !(pixels > 0)) delete source.feather;