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 +67 -1
- package/docs/configuration.md +34 -6
- package/docs/running-as-a-service.md +33 -0
- package/docs/tile-stacks.md +45 -0
- package/package.json +1 -1
- package/src/api.js +45 -0
- package/src/config.js +29 -6
- package/src/index.js +21 -9
- package/src/init-command.js +27 -0
- package/src/stack-cache.js +138 -19
- package/src/stack-tile.js +3 -1
- package/src/stacks.js +88 -1
- package/src/systemd.js +13 -1
- package/src/tiles.js +56 -4
- package/src/web/index.html +45 -8
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
|
-
-
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
742
|
-
|
|
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
|
|
756
|
-
|
|
757
|
-
|
|
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
|
package/docs/tile-stacks.md
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
382
|
+
* Memory the piece caches of swarm-read archives may hold between them.
|
|
383
383
|
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/init-command.js
CHANGED
|
@@ -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
|
|
package/src/stack-cache.js
CHANGED
|
@@ -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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
|
286
|
+
for (const [key] of oldest) {
|
|
203
287
|
if (this.#bytes <= this.#maxBytes) break;
|
|
204
|
-
this.#
|
|
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
|
|
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 =
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
(
|
|
444
|
-
|
|
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.
|
package/src/web/index.html
CHANGED
|
@@ -5289,16 +5289,21 @@ Every piece is hashed against the ` +
|
|
|
5289
5289
|
'stops doing anything.',
|
|
5290
5290
|
},
|
|
5291
5291
|
{
|
|
5292
|
-
key: 'tiles.
|
|
5293
|
-
label: '
|
|
5292
|
+
key: 'tiles.swarmCacheBytes',
|
|
5293
|
+
label: 'Piece cache memory, all swarm reads',
|
|
5294
5294
|
type: 'number',
|
|
5295
|
-
placeholder: '
|
|
5295
|
+
placeholder: '1073741824',
|
|
5296
5296
|
help:
|
|
5297
|
-
'
|
|
5298
|
-
'
|
|
5299
|
-
'
|
|
5300
|
-
'a
|
|
5301
|
-
'
|
|
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 {
|