pmtiles-swarm 0.96.0 → 0.97.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,47 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.97.0
11
+ ### ✨ Features and improvements
12
+ - **A stack can be told to forget what it has merged, and forgets it by itself when its recipe
13
+ changes.** Editing a stack never served a stale tile — the cache key covers the recipe's revision,
14
+ so the old tiles simply stop being asked for — but they stayed on the disk, spending the budget
15
+ until eviction happened to reach them. Under a stack nobody is actively panning, that is a long
16
+ time, and a node whose stacks are edited often could end up holding mostly tiles no request can
17
+ reach.
18
+
19
+ Merged tiles now carry the stack they belong to in their filename, after the digest so the
20
+ sharding still spreads and in the name so it survives a restart. `StackStore` compares revisions
21
+ on every reload and says which recipes are no longer what they were; the node clears those. It
22
+ covers every way a recipe moves — the console's Save, `PUT /api/stacks/<id>`, an import, a delete,
23
+ a stack feed, and an operator editing `stacks.json` by hand — and announces nothing when a
24
+ rewrite leaves the recipes the same, so a restart does not read as an edit and throw away the last
25
+ run's work.
26
+
27
+ Alongside it, a **Clear cache** button on each stack in the console, showing what it would free,
28
+ and `DELETE /api/stacks/<id>/cache` behind it. That is for what a revision cannot see: an archive
29
+ rewritten under an address that did not change, a cutline redrawn, a codec upgraded, or simply
30
+ wanting the disk back now. `/api/stacks` reports `cache: {entries, bytes}` per stack.
31
+
32
+ Both take the stacks **nesting** the cleared one with them, however many levels up. An outer
33
+ stack's tiles were merged from the inner one's, so clearing one level and not the other leaves the
34
+ older answer being served from above. Tiles written by an earlier version have no stack in their
35
+ name; they are evicted as before and `DELETE /api/storage/merged-tiles` still empties everything.
36
+ See [tile-stacks.md](docs/tile-stacks.md) — "Clearing what a stack has merged".
37
+
38
+ ### 🐞 Bug fixes
39
+ - **A directory the configuration named but nobody had made stopped the unit dead.**
40
+ `status=226/NAMESPACE`, six milliseconds of CPU, and nothing in the journal — because the program
41
+ never ran: with `ProtectSystem=strict`, systemd builds the mount namespace first, and a
42
+ `ReadWritePaths=` entry that does not exist makes that fail. Every path is now prefixed with `-`,
43
+ systemd's "ignore this if it does not exist", so the node starts and a write that has nowhere to
44
+ go fails where it is attempted, naming the path. A missing directory should be a message, not a
45
+ silence.
46
+
47
+ `init --systemd` also lists the directories the configuration names that are not there yet, with
48
+ the `install -d` line for each. See [running-as-a-service.md](docs/running-as-a-service.md) —
49
+ "status=226/NAMESPACE".
50
+
10
51
  ## 0.96.0
11
52
  ### ✨ Features and improvements
12
53
  - **The swarm-read limit is memory now, not a count.** `tiles.maxOpenSwarmArchives: 16` becomes
@@ -189,6 +189,39 @@ The unit below is what it generates, with your paths in it. Worth reading
189
189
  either way — a generated file you do not understand is a hand-written one you
190
190
  have not written yet.
191
191
 
192
+ ## `status=226/NAMESPACE`
193
+
194
+ The unit exits in six milliseconds, having produced nothing at all:
195
+
196
+ ```
197
+ Process: 991182 ExecStart=... (code=exited, status=226/NAMESPACE)
198
+ ```
199
+
200
+ Nothing is wrong with the program; it never ran. With `ProtectSystem=strict`,
201
+ systemd builds a mount namespace before starting anything, and a directory
202
+ named in `ReadWritePaths=` that **does not exist** makes that fail. There is no
203
+ log from the node because there was no node.
204
+
205
+ Find it:
206
+
207
+ ```
208
+ systemctl show -p ReadWritePaths --value pmtiles-swarm | tr ' ' '
209
+ ' | sed 's/^-//' | while read -r p; do [ -d "$p" ] || echo "missing: $p"; done
210
+ ```
211
+
212
+ Then create what is missing and restart:
213
+
214
+ ```
215
+ sudo install -d -o pmtiles-swarm -g pmtiles-swarm -m 0775 /the/missing/path
216
+ sudo systemctl restart pmtiles-swarm
217
+ ```
218
+
219
+ Units generated from version 0.97.0 onwards prefix every path with `-`, which
220
+ is systemd for "ignore this if it does not exist" — so a missing directory
221
+ becomes a write that fails later, naming the path, rather than a unit that will
222
+ not start and says nothing about why. `init --systemd` also lists the
223
+ directories the configuration names that are not there yet.
224
+
192
225
  ## Regenerating the unit on a node that already runs
193
226
 
194
227
  The unit is derived from two things that move: the configuration, and how many
@@ -38,6 +38,7 @@ of its parts.
38
38
  - [The codec problem](#the-codec-problem)
39
39
  - [Cost, and the caches that make it bearable](#cost-and-the-caches-that-make-it-bearable)
40
40
  - [Caching headers and ETags](#caching-headers-and-etags)
41
+ - [Clearing what a stack has merged](#clearing-what-a-stack-has-merged)
41
42
  - [TileJSON for a stack](#tilejson-for-a-stack)
42
43
  - [When a source will not answer](#when-a-source-will-not-answer)
43
44
  - [Baking a stack into an archive](#baking-a-stack-into-an-archive)
@@ -524,6 +525,50 @@ etag = H(stackId, stackRevision, resolvedInfoHashes[], z, x, y)
524
525
  `stackRevision` bumps whenever the recipe is edited, which invalidates every
525
526
  cached tile without anything having to remember to.
526
527
 
528
+ ### Clearing what a stack has merged
529
+
530
+ Invalidation and clearing are two different problems, and the ETag only solves
531
+ the first. An edited recipe is never served from the old tiles — the key
532
+ changed — but those tiles are still on the disk, spending the budget until
533
+ eviction reaches them. Under a stack nobody is panning, that is a long time.
534
+
535
+ So the cache filename carries the stack as well as the digest:
536
+
537
+ ```
538
+ <sha1(etag:ext)>.<sha1(stackId)[0:12]>.<ext>
539
+ ```
540
+
541
+ The stack tag goes **after** the digest, because the first two characters of
542
+ the name are the shard directory: putting the stack first would file every
543
+ tile of a busy stack in one directory. It is in the name rather than in an
544
+ index beside the tiles for the reason `load()` gives — a name survives a
545
+ restart and cannot disagree with the file it is on.
546
+
547
+ Two things use it:
548
+
549
+ - **`StackStore` announces recipes that changed**, and the node clears them.
550
+ This covers every way a recipe moves: the console's Save, `PUT
551
+ /api/stacks/<id>`, an import, a delete, a stack feed, and an operator with an
552
+ editor open on `stacks.json`. It compares revisions rather than timestamps,
553
+ so rewriting the file with the same recipes in it announces nothing — which
554
+ is what makes it safe to run on every reload, and what stops a restart from
555
+ reading as an edit and discarding the last run's work.
556
+ - **`DELETE /api/stacks/<id>/cache`**, behind a per-stack button in the
557
+ console, for what a revision cannot see: an archive rewritten under an
558
+ address that did not change, a cutline redrawn, a codec upgraded, or simply
559
+ wanting the disk back now. `/api/stacks` reports `cache: {entries, bytes}`
560
+ per stack so the button can say what it would free, and `null` there means
561
+ the node keeps nothing at all (`stacks.cacheBytes` is 0).
562
+
563
+ Both take the stacks **nesting** the changed one with them, transitively. An
564
+ outer stack's tiles were merged from the inner one's, so clearing one level and
565
+ not the other leaves the older answer being served from above.
566
+
567
+ Tiles written before the tag existed have no stack in their name. They are
568
+ indexed and evicted like any other, and a per-stack clear does not match them;
569
+ they age out on their own. `DELETE /api/storage/merged-tiles` still empties the
570
+ lot.
571
+
527
572
  Cache-control follows the split the tile routes already make: a stack whose
528
573
  sources are **all** pinned by `archive` can never change, so
529
574
  `public, max-age=31536000, immutable`. A stack with **any** category-resolved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.96.0",
3
+ "version": "0.97.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
@@ -54,6 +54,7 @@ import {
54
54
  resolveStack,
55
55
  stackCoverage,
56
56
  stackEtag,
57
+ stacksAffectedBy,
57
58
  } from './stacks.js';
58
59
  import { bakeRevision } from './bake.js';
59
60
 
@@ -3394,6 +3395,11 @@ export function createApp({
3394
3395
  // draws progress on the row, and one poll is what keeps that
3395
3396
  // honest while a bake is running.
3396
3397
  bake: bakes?.get(stack.id) ?? null,
3398
+ // What this stack has already merged and kept. Null rather than
3399
+ // zeroes where the cache is off, so a row can tell "nothing kept
3400
+ // yet" from "nothing is ever kept here" and offer the button only
3401
+ // where it would do something.
3402
+ cache: stackCache?.enabled ? stackCache.usage(stack.id) : null,
3397
3403
  // The schedules aimed at this stack, so its row can say it
3398
3404
  // exports itself without anybody going to look. Several is normal:
3399
3405
  // a nightly build to the fast disk and a weekly one published
@@ -3752,6 +3758,45 @@ export function createApp({
3752
3758
  }),
3753
3759
  );
3754
3760
 
3761
+ /**
3762
+ * Throws away the tiles this stack has already merged.
3763
+ *
3764
+ * Editing the recipe does this by itself — the key covers the revision, so
3765
+ * the store drops them as it saves. This is for what a revision cannot see:
3766
+ * an archive rewritten under an address that did not change, a cutline
3767
+ * redrawn, a codec upgraded, or simply wanting the disk back now rather than
3768
+ * when eviction reaches it.
3769
+ *
3770
+ * Takes the stacks nesting this one with it. Their tiles were merged from
3771
+ * this one's, so clearing here and not there would leave the older answer
3772
+ * being served one level up.
3773
+ */
3774
+ app.delete(
3775
+ '/api/stacks/:id/cache',
3776
+ route(async (req, res) => {
3777
+ await stacks?.refresh();
3778
+ if (!stacks?.get(req.params.id)) {
3779
+ return res.status(404).json({ error: 'no such stack' });
3780
+ }
3781
+ if (!stackCache?.enabled) {
3782
+ // 409 rather than 404: the stack is there and the request makes
3783
+ // sense, the node is simply not keeping anything to clear.
3784
+ return res.status(409).json({
3785
+ error: 'nothing is cached: stacks.cacheBytes is 0 on this node',
3786
+ });
3787
+ }
3788
+
3789
+ const affected = [...stacksAffectedBy(stacks.list(), [req.params.id])];
3790
+ let cleared = 0;
3791
+ let bytes = 0;
3792
+ for (const id of affected) {
3793
+ bytes += stackCache.usage(id).bytes;
3794
+ cleared += await stackCache.clear(id);
3795
+ }
3796
+ res.json({ ok: true, cleared, bytes, stacks: affected });
3797
+ }),
3798
+ );
3799
+
3755
3800
  /**
3756
3801
  * Looks a stack up and resolves it, or answers why it cannot be served.
3757
3802
  * @param {import('express').Request} req - The request.
package/src/index.js CHANGED
@@ -254,23 +254,35 @@ PMTILES_SWARM_PUBLIC_URL
254
254
 
255
255
  const catalog = new Catalog(config.dataDir);
256
256
  await catalog.load();
257
+ // Merged tiles only — see the route. Indexed from disk at startup so a
258
+ // restart does not throw away work the node has already paid for.
259
+ const stackCache = new StackCache({
260
+ dir: config.stacks?.cacheDir ?? path.join(config.dataDir, 'stack-cache'),
261
+ maxBytes: config.stacks?.cacheBytes,
262
+ });
263
+ await stackCache.load();
264
+
257
265
  // Recipes rather than archives, so this is its own file and its own
258
266
  // store. A missing stacks.json is simply no stacks.
259
- const stacks = new StackStore(config.dataDir);
267
+ //
268
+ // Built after the cache so it can drop what a changed recipe has already
269
+ // merged. The key covers the revision, so those tiles are never served —
270
+ // this is about the disk they would otherwise sit on until eviction reached
271
+ // them, which under a stack nobody is panning is a long time. Not awaited:
272
+ // a save should not wait on unlinking files nothing is going to read, and a
273
+ // clear that fails leaves entries that were already unreachable.
274
+ const stacks = new StackStore(config.dataDir, {
275
+ onChanged: (ids) => {
276
+ for (const id of ids) stackCache.clear(id).catch(() => {});
277
+ },
278
+ });
260
279
  await stacks.load();
261
- // Merged tiles only -- see the route. Indexed from disk at startup so a
262
- // restart does not throw away work the node has already paid for.
280
+
263
281
  // Shapes a recipe can clip a source to. A missing one is a problem reported
264
282
  // on the stack that names it, not a reason for this to fail.
265
283
  const cutlines = new CutlineStore(config.dataDir);
266
284
  await cutlines.load();
267
285
 
268
- const stackCache = new StackCache({
269
- dir: config.stacks?.cacheDir ?? path.join(config.dataDir, 'stack-cache'),
270
- maxBytes: config.stacks?.cacheBytes,
271
- });
272
- await stackCache.load();
273
-
274
286
  const engine = createEngine(config);
275
287
  // The slowest, because it announces "stopped" to every tracker, and an
276
288
  // unreachable one costs a timeout each. Registered first so it stops last.
@@ -181,6 +181,33 @@ export async function runInit(options = {}, write = console.log) {
181
181
  write(' added to the installed unit by hand is not in this one. Move it');
182
182
  write(' into the configuration — as a save location, a watched folder or');
183
183
  write(' a cache path — and it will be derived from now on.');
184
+
185
+ // A directory the configuration names and nobody has made is the one way
186
+ // a correct unit still fails: systemd cannot mount what is not there, so
187
+ // it refuses the whole unit before the process runs.
188
+ const absent = [];
189
+ for (const dir of writablePaths(held, configPath)) {
190
+ const there = await fs
191
+ .access(dir)
192
+ .then(() => true)
193
+ .catch(() => false);
194
+ if (!there) absent.push(dir);
195
+ }
196
+ if (absent.length > 0) {
197
+ write('');
198
+ write(` ${absent.length} of those directories do not exist yet:`);
199
+ write('');
200
+ for (const dir of absent) {
201
+ write(
202
+ ` sudo install -d -o ${options.user ?? 'pmtiles-swarm'} ` +
203
+ `-g ${options.user ?? 'pmtiles-swarm'} -m 0775 ${dir}`,
204
+ );
205
+ }
206
+ write('');
207
+ write(' The unit ignores a directory that is not there rather than');
208
+ write(' refusing to start, so what you would see instead is a write');
209
+ write(' failing later, naming the path.');
210
+ }
184
211
  return 0;
185
212
  }
186
213
 
@@ -28,12 +28,32 @@ import path from 'node:path';
28
28
  */
29
29
  const SHARD = 2;
30
30
 
31
+ /**
32
+ * How much of the stack id's digest goes in the filename. Long enough that two
33
+ * of a node's stacks colliding is not a thing that happens, short enough that
34
+ * the name is still readable when something goes wrong in the directory.
35
+ */
36
+ const TAG = 12;
37
+
38
+ /**
39
+ * Which stack a filename belongs to, or null for one written before the tag
40
+ * existed. Forgiving on purpose: an untagged file is still a tile that can be
41
+ * counted and evicted, it simply cannot be cleared by stack.
42
+ * @param {string} key - The filename.
43
+ * @returns {string|null} - The tag.
44
+ */
45
+ function tagOf(key) {
46
+ const parts = key.split('.');
47
+ return parts.length === 3 ? parts[1] : null;
48
+ }
49
+
31
50
  /** Merged stack tiles kept on disk, bounded by total size. */
32
51
  export class StackCache {
33
52
  #dir;
34
53
  #maxBytes;
35
54
  #entries = new Map();
36
55
  #bytes = 0;
56
+ #byStack = new Map();
37
57
  #inFlight = new Map();
38
58
  #hits = 0;
39
59
  #misses = 0;
@@ -67,6 +87,7 @@ export class StackCache {
67
87
  async load() {
68
88
  if (!this.enabled) return;
69
89
  this.#entries.clear();
90
+ this.#byStack.clear();
70
91
  this.#bytes = 0;
71
92
  await fs.mkdir(this.#dir, { recursive: true });
72
93
 
@@ -78,8 +99,7 @@ export class StackCache {
78
99
  const file = path.join(dir, name);
79
100
  const stat = await fs.stat(file).catch(() => null);
80
101
  if (!stat?.isFile()) continue;
81
- this.#entries.set(name, { size: stat.size, used: stat.mtimeMs });
82
- this.#bytes += stat.size;
102
+ this.#index(name, stat.size, stat.mtimeMs);
83
103
  }
84
104
  }
85
105
  await this.#evict();
@@ -93,16 +113,41 @@ export class StackCache {
93
113
  * makes invalidation automatic — editing the recipe or rebuilding a source
94
114
  * changes the key rather than needing anything to remember to delete the old
95
115
  * one, and the entries nobody asks for again fall out through eviction.
116
+ *
117
+ * Which stack it was goes in the name too, after the digest rather than
118
+ * before it so the sharding still spreads. That is the one thing an operator
119
+ * clearing a single stack needs and the ETag cannot give: an ETag says
120
+ * whether two tiles are the same and nothing about where either came from.
121
+ * In the name rather than in a file beside the tiles, for the reason `load`
122
+ * gives — a name survives a restart, and cannot disagree with the tile.
123
+ * @param {string} stackId - Whose tile this is.
96
124
  * @param {string} etag - The tile's ETag, which already covers all of it.
97
125
  * @param {string} extension - The tile format, so two never collide.
98
126
  * @returns {string} - A filename.
99
127
  */
100
- static key(etag, extension) {
128
+ static key(stackId, etag, extension) {
101
129
  const digest = crypto
102
130
  .createHash('sha1')
103
131
  .update(`${etag}:${extension}`)
104
132
  .digest('hex');
105
- return `${digest}.${extension}`;
133
+ return `${digest}.${StackCache.tag(stackId)}.${extension}`;
134
+ }
135
+
136
+ /**
137
+ * How a stack id appears in a filename.
138
+ *
139
+ * Hashed rather than written out. A stack id is URL-safe and would make a
140
+ * fine filename, but it may hold the dot the name is split on, and a
141
+ * fixed-width tag keeps reading it a split rather than a search.
142
+ * @param {string} stackId - The stack id.
143
+ * @returns {string} - The tag.
144
+ */
145
+ static tag(stackId) {
146
+ return crypto
147
+ .createHash('sha1')
148
+ .update(String(stackId ?? ''))
149
+ .digest('hex')
150
+ .slice(0, TAG);
106
151
  }
107
152
 
108
153
  /**
@@ -114,6 +159,49 @@ export class StackCache {
114
159
  return path.join(this.#dir, key.slice(0, SHARD), key);
115
160
  }
116
161
 
162
+ /**
163
+ * Records one tile, in the total and against its stack.
164
+ *
165
+ * The per-stack totals are kept as entries come and go rather than counted
166
+ * when they are asked for. The console lists every stack on a poll, and
167
+ * counting would walk the whole index once per stack per poll — for a number
168
+ * that only ever changes here.
169
+ * @param {string} key - The filename.
170
+ * @param {number} size - Its size in bytes.
171
+ * @param {number} used - When it was last wanted.
172
+ * @returns {void}
173
+ */
174
+ #index(key, size, used) {
175
+ this.#forget(key);
176
+ const tag = tagOf(key);
177
+ this.#entries.set(key, { size, used, tag });
178
+ this.#bytes += size;
179
+ if (!tag) return;
180
+ const held = this.#byStack.get(tag) ?? { entries: 0, bytes: 0 };
181
+ held.entries += 1;
182
+ held.bytes += size;
183
+ this.#byStack.set(tag, held);
184
+ }
185
+
186
+ /**
187
+ * Drops one tile from the index, leaving the file to the caller.
188
+ * @param {string} key - The filename.
189
+ * @returns {object|null} - What was indexed, if anything was.
190
+ */
191
+ #forget(key) {
192
+ const entry = this.#entries.get(key);
193
+ if (!entry) return null;
194
+ this.#entries.delete(key);
195
+ this.#bytes -= entry.size;
196
+ const held = entry.tag ? this.#byStack.get(entry.tag) : null;
197
+ if (held) {
198
+ held.entries -= 1;
199
+ held.bytes -= entry.size;
200
+ if (held.entries <= 0) this.#byStack.delete(entry.tag);
201
+ }
202
+ return entry;
203
+ }
204
+
117
205
  /**
118
206
  * Reads a tile, or null when it is not here.
119
207
  * @param {string} key - The filename.
@@ -130,8 +218,7 @@ export class StackCache {
130
218
  if (!body) {
131
219
  // Indexed but gone — something outside this process removed it. Forget
132
220
  // it rather than trusting the index over the disk.
133
- this.#entries.delete(key);
134
- this.#bytes -= entry.size;
221
+ this.#forget(key);
135
222
  this.#misses += 1;
136
223
  return null;
137
224
  }
@@ -162,10 +249,7 @@ export class StackCache {
162
249
  return;
163
250
  }
164
251
 
165
- const previous = this.#entries.get(key);
166
- if (previous) this.#bytes -= previous.size;
167
- this.#entries.set(key, { size: body.length, used: Date.now() });
168
- this.#bytes += body.length;
252
+ this.#index(key, body.length, Date.now());
169
253
  await this.#evict();
170
254
  }
171
255
 
@@ -199,33 +283,68 @@ export class StackCache {
199
283
  const oldest = [...this.#entries.entries()].sort(
200
284
  (a, b) => a[1].used - b[1].used,
201
285
  );
202
- for (const [key, entry] of oldest) {
286
+ for (const [key] of oldest) {
203
287
  if (this.#bytes <= this.#maxBytes) break;
204
- this.#entries.delete(key);
205
- this.#bytes -= entry.size;
288
+ this.#forget(key);
206
289
  await fs.rm(this.#pathFor(key), { force: true }).catch(() => {});
207
290
  }
208
291
  }
209
292
 
210
293
  /**
211
- * Throws away every tile it is holding.
294
+ * Throws away the tiles of one stack, or every tile it is holding.
295
+ *
296
+ * Editing a recipe does not need this: the key covers the revision, so the
297
+ * old tiles simply stop being asked for and fall out through eviction. What
298
+ * this is for is the two cases the key cannot see — an operator who wants
299
+ * the space back now, and a source whose bytes changed underneath an address
300
+ * that did not.
301
+ *
302
+ * A stack this has never held is not an error. It is the ordinary answer for
303
+ * one whose tiles have already been evicted, and a caller clearing several
304
+ * at once should not have to know which.
212
305
  *
213
306
  * The hit and miss counters are left alone: they say what the cache has been
214
307
  * doing since the process started, which emptying it does not unmake. The
215
- * files go one at a time and forgivingly -- one that vanished underneath is
308
+ * files go one at a time and forgivingly — one that vanished underneath is
216
309
  * one fewer to remove.
310
+ * @param {string} [stackId] - Whose tiles to drop; all of them when absent.
217
311
  * @returns {Promise<number>} - How many tiles went.
218
312
  */
219
- async clear() {
220
- const keys = [...this.#entries.keys()];
221
- this.#entries.clear();
222
- this.#bytes = 0;
313
+ async clear(stackId) {
314
+ const keys =
315
+ stackId === undefined
316
+ ? [...this.#entries.keys()]
317
+ : this.#keysOf(StackCache.tag(stackId));
223
318
  for (const key of keys) {
319
+ this.#forget(key);
224
320
  await fs.rm(this.#pathFor(key), { force: true }).catch(() => {});
225
321
  }
226
322
  return keys.length;
227
323
  }
228
324
 
325
+ /**
326
+ * The keys one stack's tiles are under.
327
+ * @param {string} tag - The stack's tag.
328
+ * @returns {string[]} - The filenames.
329
+ */
330
+ #keysOf(tag) {
331
+ const keys = [];
332
+ for (const [key, entry] of this.#entries) {
333
+ if (entry.tag === tag) keys.push(key);
334
+ }
335
+ return keys;
336
+ }
337
+
338
+ /**
339
+ * What one stack is holding, so its row can say so and offer to clear it.
340
+ * @param {string} stackId - The stack id.
341
+ * @returns {object} - `{entries, bytes}`, both zero for a stack with none.
342
+ */
343
+ usage(stackId) {
344
+ const held = this.#byStack.get(StackCache.tag(stackId));
345
+ return { entries: held?.entries ?? 0, bytes: held?.bytes ?? 0 };
346
+ }
347
+
229
348
  /**
230
349
  * What the cache is holding, for the console and for tests.
231
350
  * @returns {object} - entries, bytes, maxBytes, hits, misses.
package/src/stack-tile.js CHANGED
@@ -974,9 +974,11 @@ export async function answerStackTile(options) {
974
974
  // Keyed by the ETag, which already covers the recipe's revision and what its
975
975
  // sources resolved to -- so an edited stack or a rebuilt source produces a
976
976
  // different key rather than needing anything to remember to invalidate the
977
- // old one.
977
+ // old one. The stack's id goes in beside it, which the digest cannot be read
978
+ // back out of, so a stack's tiles can also be cleared on purpose.
978
979
  const cacheKey = stackCache?.enabled
979
980
  ? StackCache.key(
981
+ resolved.stack.id,
980
982
  `${stackEtag(resolved, z, x, y)}:${size ?? 'auto'}`,
981
983
  format,
982
984
  )
package/src/stacks.js CHANGED
@@ -603,6 +603,43 @@ export function stacksUsing(stacks, entry, categoryInfo) {
603
603
  return found;
604
604
  }
605
605
 
606
+ /**
607
+ * Every stack that would be affected by these ones changing.
608
+ *
609
+ * A stack can be a source of another, and the outer one's tiles are built from
610
+ * the inner one's — so a recipe that changed takes its dependents with it,
611
+ * however many levels up they are. Followed over both the recipes as they were
612
+ * and as they are, because a stack that has just stopped nesting another still
613
+ * holds tiles that were merged while it did.
614
+ * @param {Stack[]} stacks - Every stack, in any order and with duplicates.
615
+ * @param {Iterable<string>} ids - The stacks that changed.
616
+ * @returns {Set<string>} - Those ids and everything above them.
617
+ */
618
+ export function stacksAffectedBy(stacks, ids) {
619
+ const above = new Map();
620
+ for (const stack of stacks ?? []) {
621
+ for (const source of stack.sources ?? []) {
622
+ if (!source?.stack) continue;
623
+ const users = above.get(source.stack) ?? new Set();
624
+ users.add(stack.id);
625
+ above.set(source.stack, users);
626
+ }
627
+ }
628
+
629
+ const affected = new Set();
630
+ const queue = [...ids];
631
+ while (queue.length) {
632
+ const id = queue.pop();
633
+ // A stack may name another twice, and two stacks may name the same one.
634
+ // Stopping at what has been seen is also what stops a loop, which the
635
+ // resolver refuses but the file on disk can still contain.
636
+ if (!id || affected.has(id)) continue;
637
+ affected.add(id);
638
+ for (const user of above.get(id) ?? []) queue.push(user);
639
+ }
640
+ return affected;
641
+ }
642
+
606
643
  /**
607
644
  * Derives the zoom range, bounds and attribution a resolved stack advertises.
608
645
  *
@@ -839,13 +876,20 @@ export class StackStore {
839
876
  #mtime = null;
840
877
  #size = null;
841
878
  #checkedAt = 0;
879
+ #revisions = new Map();
880
+ #onChanged;
842
881
 
843
882
  /**
844
883
  * Creates a store backed by a file.
845
884
  * @param {string} dataDir - Directory holding stacks.json.
885
+ * @param {object} [options] - `onChanged(ids)`, called with the stacks whose
886
+ * recipe is no longer what it was — by an edit here, by a feed, or by
887
+ * somebody writing the file. Anything holding work derived from a recipe
888
+ * hears about it here rather than having to watch the file itself.
846
889
  */
847
- constructor(dataDir) {
890
+ constructor(dataDir, { onChanged } = {}) {
848
891
  this.#file = path.join(dataDir, 'stacks.json');
892
+ this.#onChanged = onChanged ?? null;
849
893
  }
850
894
 
851
895
  /**
@@ -857,6 +901,8 @@ export class StackStore {
857
901
  * @returns {Promise<void>} - Resolves once loaded.
858
902
  */
859
903
  async load() {
904
+ const were = this.#revisions;
905
+ const before = this.list();
860
906
  this.#stacks.clear();
861
907
  this.#problems.clear();
862
908
  let raw;
@@ -869,6 +915,9 @@ export class StackStore {
869
915
  if (error.code === 'ENOENT') {
870
916
  this.#mtime = null;
871
917
  this.#size = null;
918
+ // A file that has been deleted is every stack in it removed, which is
919
+ // as much a change as an edit.
920
+ this.#settle(were, before);
872
921
  return;
873
922
  }
874
923
  throw error;
@@ -881,6 +930,38 @@ export class StackStore {
881
930
  this.#stacks.set(stack.id, stack);
882
931
  if (problems.length) this.#problems.set(stack.id, problems);
883
932
  }
933
+ this.#settle(were, before);
934
+ }
935
+
936
+ /**
937
+ * Takes the revisions again and says which stacks are not what they were.
938
+ *
939
+ * A revision rather than a timestamp or a flag, so this is the same answer
940
+ * whichever way the change arrived — the console's Save, a stack feed, or
941
+ * an operator with an editor open on stacks.json. Rewriting the file with
942
+ * the same recipes in it announces nothing, which is what makes it safe to
943
+ * call this on every reload.
944
+ *
945
+ * The first load announces nothing either: there is no earlier revision for
946
+ * anything to differ from, so a restart does not read as an edit and throw
947
+ * away the merged tiles the last run paid for.
948
+ * @param {Map<string, string>} were - The revisions before.
949
+ * @param {Stack[]} before - The recipes before, for what nested what.
950
+ * @returns {void}
951
+ */
952
+ #settle(were, before) {
953
+ this.#revisions = new Map(
954
+ [...this.#stacks].map(([id, stack]) => [id, stackRevision(stack)]),
955
+ );
956
+ // Removed stacks are in here too: their revision is now undefined, which
957
+ // is not what it was. Added ones are not, and have nothing to clear.
958
+ const changed = [...were.keys()].filter(
959
+ (id) => this.#revisions.get(id) !== were.get(id),
960
+ );
961
+ if (!changed.length || !this.#onChanged) return;
962
+ this.#onChanged([
963
+ ...stacksAffectedBy([...before, ...this.list()], changed),
964
+ ]);
884
965
  }
885
966
 
886
967
  /**
@@ -958,9 +1039,12 @@ export class StackStore {
958
1039
  error.problems = problems;
959
1040
  throw error;
960
1041
  }
1042
+ const were = this.#revisions;
1043
+ const before = this.list();
961
1044
  this.#stacks.set(stack.id, stack);
962
1045
  this.#problems.delete(stack.id);
963
1046
  await this.#flush();
1047
+ this.#settle(were, before);
964
1048
  return stack;
965
1049
  }
966
1050
 
@@ -970,9 +1054,12 @@ export class StackStore {
970
1054
  * @returns {Promise<boolean>} - Whether anything was there.
971
1055
  */
972
1056
  async remove(id) {
1057
+ const were = this.#revisions;
1058
+ const before = this.list();
973
1059
  if (!this.#stacks.delete(id)) return false;
974
1060
  this.#problems.delete(id);
975
1061
  await this.#flush();
1062
+ this.#settle(were, before);
976
1063
  return true;
977
1064
  }
978
1065
 
package/src/systemd.js CHANGED
@@ -101,7 +101,14 @@ export function unitFor({
101
101
 
102
102
  // Wrapped the way systemd's own examples are, because this list grows with
103
103
  // every watched folder and a single line of them is unreadable in a diff.
104
- const readWrite = paths.join(' \\\n ');
104
+ //
105
+ // Each prefixed with `-`, which is systemd for "ignore this if it does not
106
+ // exist". Without it a directory named here and not yet created stops the
107
+ // unit before the process runs: `status=226/NAMESPACE`, six milliseconds of
108
+ // CPU, and nothing in the journal from a program that never started. With
109
+ // it the node starts and the write fails where it is attempted, naming the
110
+ // directory. A missing directory should be a message, not a silence.
111
+ const readWrite = paths.map((one) => `-${one}`).join(' \\\n ');
105
112
 
106
113
  return `# pmtiles-swarm, generated by \`pmtiles-swarm init --systemd\`.
107
114
  #
@@ -177,6 +184,11 @@ ProtectHome=read-only
177
184
  PrivateTmp=true
178
185
  NoNewPrivileges=true
179
186
 
187
+ # Each path is prefixed with a dash, which is systemd for "ignore this if it
188
+ # does not exist". Without it a directory named here and not yet created
189
+ # stops the unit before the process runs -- status=226/NAMESPACE, six
190
+ # milliseconds of CPU, and nothing in the journal from a program that never
191
+ # started. init --systemd prints the commands that create them.
180
192
  ReadWritePaths=${readWrite}
181
193
 
182
194
  # Group-writable, so what this service creates in a folder shared with whatever
@@ -7774,6 +7774,12 @@ Every piece is hashed against the ` +
7774
7774
  : `<button type="button" data-stack-feed="${escapeHtml(stack.id)}"
7775
7775
  title="The address another node follows to keep its copy of this recipe level with this one. Add it there under Settings → Feeds → Stack feeds.">RSS</button>`
7776
7776
  }
7777
+ ${
7778
+ stack.cache && stack.cache.bytes > 0
7779
+ ? `<button type="button" data-stack-uncache="${escapeHtml(stack.id)}"
7780
+ title="Throws away the ${stack.cache.entries.toLocaleString()} tiles this stack has already merged, so the next request for each merges it again. Saving an edit does this by itself — this is for a source whose bytes changed under an address that did not.">Clear cache (${bytes(stack.cache.bytes)})</button>`
7781
+ : ''
7782
+ }
7777
7783
  <button type="button" data-stack-edit="${escapeHtml(stack.id)}">Edit</button>
7778
7784
  <button type="button" data-stack-copy="${escapeHtml(stack.id)}"
7779
7785
  title="Opens the editor on a copy of this recipe, under a name of its own. Nothing is saved until you save it.">Duplicate</button>
@@ -9173,6 +9179,7 @@ Every piece is hashed against the ` +
9173
9179
  const drop = event.target.dataset.stackDelete;
9174
9180
  const bake = event.target.dataset.stackBake;
9175
9181
  const stopBake = event.target.dataset.stackBakeStop;
9182
+ const uncache = event.target.dataset.stackUncache;
9176
9183
  if (bake !== undefined) {
9177
9184
  const { stacks } = await api('/api/stacks');
9178
9185
  await openBakeDialog(
@@ -9224,6 +9231,31 @@ Every piece is hashed against the ` +
9224
9231
  },
9225
9232
  { copy: true, taken },
9226
9233
  );
9234
+ } else if (uncache !== undefined) {
9235
+ // No confirmation, for the reason the Storage panel gives: a merged
9236
+ // tile can be merged again. What it costs is the merge, and the row
9237
+ // says how much disk it is getting back.
9238
+ try {
9239
+ const gone = await api(
9240
+ `/api/stacks/${encodeURIComponent(uncache)}/cache`,
9241
+ { method: 'DELETE' },
9242
+ );
9243
+ // The others are named rather than counted: pressing one
9244
+ // button and clearing three stacks is surprising until you can
9245
+ // see that the other two are built on this one.
9246
+ const also = gone.stacks.filter((id) => id !== uncache);
9247
+ // Nothing to free is a real answer, not a failure: eviction may
9248
+ // have reached these tiles between the row being drawn and the
9249
+ // button being pressed.
9250
+ toast(
9251
+ `${gone.bytes > 0 ? `Freed ${bytes(gone.bytes)} from` : 'Cleared'} ${uncache}${
9252
+ also.length ? ` and ${also.join(', ')}` : ''
9253
+ }`,
9254
+ );
9255
+ loadStacks().catch((e) => toast(e.message));
9256
+ } catch (error) {
9257
+ toast(error.message);
9258
+ }
9227
9259
  } else if (drop !== undefined) {
9228
9260
  if (!confirm(`Delete the stack "${drop}"?`)) return;
9229
9261
  try {