pmtiles-swarm 0.95.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
@@ -2,6 +2,68 @@
2
2
 
3
3
  ## master
4
4
  ### ✨ Features and improvements
5
+ - _...Add new stuff here..._
6
+
7
+ ### 🐞 Bug fixes
8
+ - _...Add new stuff here..._
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
+
51
+ ## 0.96.0
52
+ ### ✨ Features and improvements
53
+ - **The swarm-read limit is memory now, not a count.** `tiles.maxOpenSwarmArchives: 16` becomes
54
+ `tiles.swarmCacheBytes: 1 GiB`, because a count was the wrong unit for it. What is expensive about
55
+ a cache-mode reader is its piece cache, which is **RAM** β€” a map of whole pieces in the node's own
56
+ heap β€” and how big it is depends on the torrent: `max(64 MiB, 8 Γ— pieceLength)`. Sixteen readers
57
+ is a gigabyte against the 4 MiB pieces this project creates and two against the 16 MiB pieces a
58
+ planet torrent usually has, which is not a limit anybody chose.
59
+
60
+ Each reader is asked what its cache costs rather than assumed, so the count now follows from the
61
+ memory: halve `pieceCacheBytes` and twice as many archives stay open for the same gigabyte. The
62
+ last reader is never closed, whatever the budget says β€” the archive just opened is the one being
63
+ read. `maxOpenSwarmArchives` remains for a node that wants a hard count as well; unset by default.
64
+
65
+ Complete archives are unaffected: they hold a file descriptor and no piece cache, and are bounded
66
+ by `maxOpenArchives`.
5
67
  - **`init --systemd` regenerates the unit without touching the configuration.** The unit is derived
6
68
  from the configuration and from how many archives the library holds, and both move β€” a watched
7
69
  folder added later belongs in `ReadWritePaths`, and a grown library needs longer to write its
@@ -12,7 +74,11 @@
12
74
  and a path added to the installed unit by hand will not be in the new one.
13
75
 
14
76
  ### 🐞 Bug fixes
15
- - _...Add new stuff here..._
77
+ - **The unit's `ReadWritePaths` left out where an export writes.** `stackExports[].savePath` and
78
+ `.publishDir`, and a watched folder's `publishDir`, were not among the directories derived from
79
+ the configuration β€” so a nightly bake to a directory named nowhere else ran for an hour and was
80
+ refused the write at the end, with that directory's permissions perfect. Which is the exact
81
+ failure the derived list exists to prevent. Re-run `init --systemd` to pick them up.
16
82
 
17
83
  ## 0.95.0
18
84
  ### ✨ Features and improvements
@@ -727,7 +727,8 @@ what lets a machine with 10 GiB free serve a 700 GiB planet. See
727
727
  | setting | default | |
728
728
  | ----------------------------- | -------- | ------------------------------------------------------------ |
729
729
  | `tiles.maxOpenArchives` | `128` | complete archives kept open; each is a file descriptor |
730
- | `tiles.maxOpenSwarmArchives` | `16` | cache-mode readers, counted apart: each holds a piece cache |
730
+ | `tiles.swarmCacheBytes` | `1 GiB` | **memory** the piece caches of swarm reads share |
731
+ | `tiles.maxOpenSwarmArchives` | unset | a hard count of swarm readers as well, if you want one |
731
732
  | `tiles.maxOpenRemoteArchives` | `64` | readers for a URL or a bucket; cheap to hold, dear to reopen |
732
733
  | `tiles.directoryCacheEntries` | `2000` | header and directory cache, shared across archives |
733
734
  | `tiles.pieceCacheBytes` | unset | sized from the torrent's piece length when unset |
@@ -738,8 +739,31 @@ what lets a machine with 10 GiB free serve a 700 GiB planet. See
738
739
  | `tiles.headerTimeoutMs` | `12000` | how long a TileJSON request waits for a header |
739
740
  | `tiles.sparse` | unset | `true` for 404 on a missing tile, `false` for 204 |
740
741
 
741
- Leave `pieceCacheBytes` unset unless you have a reason. A fixed budget is a trap
742
- with 16 MiB pieces, since 64 MiB holds only four.
742
+ ### Why a swarm read caches pieces in memory at all
743
+
744
+ The swarm's unit is a **piece** β€” 4 MiB for archives this project creates,
745
+ commonly 16 MiB for a planet torrent. A tile is a few kilobytes inside one. So
746
+ fetching a piece to serve one tile and throwing it away means the next tile
747
+ pays for the whole piece again, and PMTiles guarantees there will be a next
748
+ tile in the same piece: its tiles are laid out in Hilbert order, so tiles that
749
+ are neighbours on the map are neighbours in the file. A tile lookup also reads
750
+ the header and a directory before the tile itself, and those live in pieces
751
+ that every single request would otherwise re-fetch.
752
+
753
+ Not on disk, because a disk copy of the pieces is what cache mode exists to
754
+ avoid: the point is a machine with 10 GiB free serving a 700 GiB planet. The
755
+ engine keeps whatever it keeps; this is the layer above it, and evicting from
756
+ it costs nothing.
757
+
758
+ **Trading cache size for open archives.** The two settings multiply. Halving
759
+ `pieceCacheBytes` doubles how many archives stay open within the same
760
+ `swarmCacheBytes`, at the cost of a lower hit rate on each. `16 MiB Γ— 64
761
+ archives` and `64 MiB Γ— 16 archives` are the same gigabyte spent differently:
762
+ the first suits a node reading a tile here and a tile there across many
763
+ archives, the second a node serving a region of a few.
764
+
765
+ Leave `pieceCacheBytes` unset unless you have such a reason. A fixed budget is
766
+ a trap with 16 MiB pieces, since 64 MiB holds only four.
743
767
 
744
768
  ### Three limits, because the handles cost different things
745
769
 
@@ -752,9 +776,13 @@ directory.
752
776
 
753
777
  - **A complete archive** is a file descriptor and its share of the directory
754
778
  cache. The unit allows 65535 descriptors, so hundreds of these are nothing.
755
- - **A cache-mode archive** carries a piece cache sized from the torrent's piece
756
- length. At 16 MiB pieces a hundred of them is gigabytes, which is why this
757
- one keeps the old ceiling and is counted separately.
779
+ - **A cache-mode archive** carries a piece cache, and it is **memory** β€” a map
780
+ of whole pieces in the node's own heap, not a directory on disk. Bounded by
781
+ `swarmCacheBytes` rather than by a count, because a count is the wrong unit:
782
+ a reader allows itself `max(64 MiB, 8 Γ— pieceLength)`, so sixteen of them is
783
+ 1 GiB against the 4 MiB pieces this project creates and 2 GiB against the
784
+ 16 MiB pieces a planet torrent usually has. Stated as memory, the number
785
+ means what an operator cares about and the count follows from it.
758
786
  - **A URL or bucket archive** holds an HTTP reader and the summary it has
759
787
  already read. Cheap to keep, and expensive to reopen: a reopen costs a header
760
788
  and a directory fetch over the network rather than off a disk.
@@ -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.95.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/config.js CHANGED
@@ -379,14 +379,29 @@ const DEFAULTS = {
379
379
  */
380
380
  maxOpenArchives: 128,
381
381
  /**
382
- * Open archives read through the swarm, which is the expensive kind.
382
+ * Memory the piece caches of swarm-read archives may hold between them.
383
383
  *
384
- * Counted apart from the limit above and kept where that limit used to
385
- * be. A cache-mode reader holds a piece cache sized from the torrent's
386
- * piece length, so with 16 MiB pieces a hundred of them is gigabytes --
387
- * the reason the single old limit could not simply be raised.
384
+ * A count would be the wrong unit. What is expensive about a cache-mode
385
+ * reader is its piece cache, and how big that is depends on the torrent:
386
+ * `max(64 MiB, 8 x pieceLength)`, so 64 MiB for the 4 MiB pieces this
387
+ * project creates and 128 MiB for the 16 MiB pieces a planet torrent
388
+ * usually has. Sixteen readers is a gigabyte in one case and two in the
389
+ * other, which is not a limit anybody chose.
390
+ *
391
+ * Stated as memory, the number means what an operator actually cares
392
+ * about, and the count follows from it: lower `pieceCacheBytes` and more
393
+ * archives stay open, at the same ceiling. A budget rather than an
394
+ * allocation -- an archive that has served three tiles holds three
395
+ * pieces.
396
+ */
397
+ swarmCacheBytes: 1024 * 1024 * 1024,
398
+ /**
399
+ * A hard count of swarm-read archives, for a node that wants one.
400
+ *
401
+ * Unset by default: the budget above is the better limit, because it is
402
+ * the thing that runs out. Set this as well and the smaller wins.
388
403
  */
389
- maxOpenSwarmArchives: 16,
404
+ maxOpenSwarmArchives: undefined,
390
405
  /**
391
406
  * Open archives read straight from a URL or a bucket.
392
407
  *
@@ -764,8 +779,16 @@ export function writablePaths(config, configPath) {
764
779
  // directory holding it is written to as surely as any of the above.
765
780
  configPath ? path.dirname(path.resolve(configPath)) : undefined,
766
781
  ...(config?.watch ?? []).map((entry) => entry?.path),
782
+ ...(config?.watch ?? []).map((entry) => entry?.publishDir),
767
783
  ...(config?.locations ?? []).map((entry) => entry?.path),
768
784
  ...(config?.subscriptions ?? []).map((entry) => entry?.savePath),
785
+ // Where a scheduled export puts the archive it just built, and where it
786
+ // publishes a copy of it. Missed for as long as this list has existed:
787
+ // a nightly bake to a directory named nowhere else ran for an hour and
788
+ // was refused the write at the end, with the directory's permissions
789
+ // perfect -- which is the exact failure this list exists to prevent.
790
+ ...(config?.stackExports ?? []).map((entry) => entry?.savePath),
791
+ ...(config?.stackExports ?? []).map((entry) => entry?.publishDir),
769
792
  ].filter((value) => typeof value === 'string' && value);
770
793
 
771
794
  // Deduplicated but deliberately not collapsed into common ancestors. A
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
package/src/tiles.js CHANGED
@@ -49,6 +49,42 @@ export class TileReadError extends Error {
49
49
  }
50
50
  }
51
51
 
52
+ /** What a reader's piece cache holds before it has seen the torrent. */
53
+ const STARTING_CACHE_BYTES = 64 * 1024 * 1024;
54
+
55
+ /**
56
+ * Which swarm-read archives to close for their piece caches to fit a budget.
57
+ *
58
+ * Each reader is asked what its cache costs rather than assumed, because only
59
+ * it knows: the budget it sets for itself is `max(64 MiB, 8 x pieceLength)`,
60
+ * and the piece length is the torrent's. So a node reading 4 MiB-piece
61
+ * archives keeps four times as many open as one reading 16 MiB-piece
62
+ * archives, for the same memory -- which is the arithmetic an operator would
63
+ * otherwise have to do by hand with every piece length in front of them.
64
+ *
65
+ * Least recently used first, and never the last one: the archive just opened
66
+ * is the one being read, and closing it to meet a budget it alone exceeds
67
+ * would serve no tile at all.
68
+ * @param {Array} held - `[key, handle]` pairs, least recently used first.
69
+ * @param {number} budget - Bytes the piece caches may hold between them.
70
+ * @returns {Array} - The pairs to close, in the order to close them.
71
+ */
72
+ export function overSwarmBudget(held, budget) {
73
+ if (!Number.isFinite(budget) || budget <= 0) return [];
74
+ const swarm = held.filter(([, one]) => one?.mode === 'swarm');
75
+ const cost = (one) => one.source?.stats?.cacheBudget ?? STARTING_CACHE_BYTES;
76
+
77
+ let total = swarm.reduce((sum, [, one]) => sum + cost(one), 0);
78
+ const closing = [];
79
+ for (const pair of swarm) {
80
+ if (total <= budget) break;
81
+ if (swarm.length - closing.length <= 1) break;
82
+ closing.push(pair);
83
+ total -= cost(pair[1]);
84
+ }
85
+ return closing;
86
+ }
87
+
52
88
  /**
53
89
  * Opens archives on demand and reads tiles out of them.
54
90
  */
@@ -439,10 +475,13 @@ export class TileStore {
439
475
  // local archives reopening files it had just closed.
440
476
  const tiles = this.#config.tiles ?? {};
441
477
  await this.#evict(() => true, tiles.maxOpenArchives ?? 128);
442
- await this.#evict(
443
- (one) => one.mode === 'swarm',
444
- tiles.maxOpenSwarmArchives ?? 16,
445
- );
478
+ if (tiles.maxOpenSwarmArchives !== undefined) {
479
+ await this.#evict(
480
+ (one) => one.mode === 'swarm',
481
+ tiles.maxOpenSwarmArchives,
482
+ );
483
+ }
484
+ await this.#evictSwarmMemory(tiles.swarmCacheBytes ?? 1024 * 1024 * 1024);
446
485
  return handle;
447
486
  }
448
487
 
@@ -465,6 +504,19 @@ export class TileStore {
465
504
  }
466
505
  }
467
506
 
507
+ /**
508
+ * Closes swarm-read archives until their piece caches fit a byte budget.
509
+ * @param {number} budget - Bytes the piece caches may hold between them.
510
+ * @returns {Promise<void>} - When the closing is done.
511
+ */
512
+ async #evictSwarmMemory(budget) {
513
+ for (const [key] of overSwarmBudget([...this.#open], budget)) {
514
+ const victim = this.#open.get(key);
515
+ this.#open.delete(key);
516
+ await this.#release(victim);
517
+ }
518
+ }
519
+
468
520
  /**
469
521
  * Opens an archive, choosing between the local file and the swarm.
470
522
  * @param {object} entry - Catalog entry.
@@ -5289,16 +5289,21 @@ Every piece is hashed against the ` +
5289
5289
  'stops doing anything.',
5290
5290
  },
5291
5291
  {
5292
- key: 'tiles.maxOpenSwarmArchives',
5293
- label: 'Cache-mode archives kept open',
5292
+ key: 'tiles.swarmCacheBytes',
5293
+ label: 'Piece cache memory, all swarm reads',
5294
5294
  type: 'number',
5295
- placeholder: '16',
5295
+ placeholder: '1073741824',
5296
5296
  help:
5297
- 'Counted apart from the limit above, because this is the ' +
5298
- 'expensive kind: a cache-mode reader holds a piece cache ' +
5299
- 'sized from the torrent’s piece length, so with 16 MiB pieces ' +
5300
- 'a hundred of them is gigabytes. That is why the one limit ' +
5301
- 'above could not simply be raised.',
5297
+ 'RAM, not disk. An archive read through the swarm keeps whole ' +
5298
+ 'pieces in memory, because the swarm hands out pieces and a ' +
5299
+ 'tile is a few kilobytes of one β€” without it, every tile pays ' +
5300
+ 'for a 4 or 16 MiB fetch. Each reader allows itself ' +
5301
+ '<code>max(64 MiB, 8 Γ— piece length)</code>, so this is the ' +
5302
+ 'number that decides how many stay open: lower ' +
5303
+ '<b>Piece cache bytes</b> below and more of them fit. A ' +
5304
+ 'budget, not an allocation β€” a reader that has served three ' +
5305
+ 'tiles holds three pieces. Complete archives are unaffected: ' +
5306
+ 'they hold a file descriptor and no cache.',
5302
5307
  },
5303
5308
  {
5304
5309
  key: 'tiles.maxOpenRemoteArchives',
@@ -7769,6 +7774,12 @@ Every piece is hashed against the ` +
7769
7774
  : `<button type="button" data-stack-feed="${escapeHtml(stack.id)}"
7770
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>`
7771
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
+ }
7772
7783
  <button type="button" data-stack-edit="${escapeHtml(stack.id)}">Edit</button>
7773
7784
  <button type="button" data-stack-copy="${escapeHtml(stack.id)}"
7774
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>
@@ -9168,6 +9179,7 @@ Every piece is hashed against the ` +
9168
9179
  const drop = event.target.dataset.stackDelete;
9169
9180
  const bake = event.target.dataset.stackBake;
9170
9181
  const stopBake = event.target.dataset.stackBakeStop;
9182
+ const uncache = event.target.dataset.stackUncache;
9171
9183
  if (bake !== undefined) {
9172
9184
  const { stacks } = await api('/api/stacks');
9173
9185
  await openBakeDialog(
@@ -9219,6 +9231,31 @@ Every piece is hashed against the ` +
9219
9231
  },
9220
9232
  { copy: true, taken },
9221
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
+ }
9222
9259
  } else if (drop !== undefined) {
9223
9260
  if (!confirm(`Delete the stack "${drop}"?`)) return;
9224
9261
  try {