pmtiles-swarm 0.96.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- 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/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/web/index.html +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.97.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A stack can be told to forget what it has merged, and forgets it by itself when its recipe
|
|
13
|
+
changes.** Editing a stack never served a stale tile — the cache key covers the recipe's revision,
|
|
14
|
+
so the old tiles simply stop being asked for — but they stayed on the disk, spending the budget
|
|
15
|
+
until eviction happened to reach them. Under a stack nobody is actively panning, that is a long
|
|
16
|
+
time, and a node whose stacks are edited often could end up holding mostly tiles no request can
|
|
17
|
+
reach.
|
|
18
|
+
|
|
19
|
+
Merged tiles now carry the stack they belong to in their filename, after the digest so the
|
|
20
|
+
sharding still spreads and in the name so it survives a restart. `StackStore` compares revisions
|
|
21
|
+
on every reload and says which recipes are no longer what they were; the node clears those. It
|
|
22
|
+
covers every way a recipe moves — the console's Save, `PUT /api/stacks/<id>`, an import, a delete,
|
|
23
|
+
a stack feed, and an operator editing `stacks.json` by hand — and announces nothing when a
|
|
24
|
+
rewrite leaves the recipes the same, so a restart does not read as an edit and throw away the last
|
|
25
|
+
run's work.
|
|
26
|
+
|
|
27
|
+
Alongside it, a **Clear cache** button on each stack in the console, showing what it would free,
|
|
28
|
+
and `DELETE /api/stacks/<id>/cache` behind it. That is for what a revision cannot see: an archive
|
|
29
|
+
rewritten under an address that did not change, a cutline redrawn, a codec upgraded, or simply
|
|
30
|
+
wanting the disk back now. `/api/stacks` reports `cache: {entries, bytes}` per stack.
|
|
31
|
+
|
|
32
|
+
Both take the stacks **nesting** the cleared one with them, however many levels up. An outer
|
|
33
|
+
stack's tiles were merged from the inner one's, so clearing one level and not the other leaves the
|
|
34
|
+
older answer being served from above. Tiles written by an earlier version have no stack in their
|
|
35
|
+
name; they are evicted as before and `DELETE /api/storage/merged-tiles` still empties everything.
|
|
36
|
+
See [tile-stacks.md](docs/tile-stacks.md) — "Clearing what a stack has merged".
|
|
37
|
+
|
|
38
|
+
### 🐞 Bug fixes
|
|
39
|
+
- **A directory the configuration named but nobody had made stopped the unit dead.**
|
|
40
|
+
`status=226/NAMESPACE`, six milliseconds of CPU, and nothing in the journal — because the program
|
|
41
|
+
never ran: with `ProtectSystem=strict`, systemd builds the mount namespace first, and a
|
|
42
|
+
`ReadWritePaths=` entry that does not exist makes that fail. Every path is now prefixed with `-`,
|
|
43
|
+
systemd's "ignore this if it does not exist", so the node starts and a write that has nowhere to
|
|
44
|
+
go fails where it is attempted, naming the path. A missing directory should be a message, not a
|
|
45
|
+
silence.
|
|
46
|
+
|
|
47
|
+
`init --systemd` also lists the directories the configuration names that are not there yet, with
|
|
48
|
+
the `install -d` line for each. See [running-as-a-service.md](docs/running-as-a-service.md) —
|
|
49
|
+
"status=226/NAMESPACE".
|
|
50
|
+
|
|
10
51
|
## 0.96.0
|
|
11
52
|
### ✨ Features and improvements
|
|
12
53
|
- **The swarm-read limit is memory now, not a count.** `tiles.maxOpenSwarmArchives: 16` becomes
|
|
@@ -189,6 +189,39 @@ The unit below is what it generates, with your paths in it. Worth reading
|
|
|
189
189
|
either way — a generated file you do not understand is a hand-written one you
|
|
190
190
|
have not written yet.
|
|
191
191
|
|
|
192
|
+
## `status=226/NAMESPACE`
|
|
193
|
+
|
|
194
|
+
The unit exits in six milliseconds, having produced nothing at all:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
Process: 991182 ExecStart=... (code=exited, status=226/NAMESPACE)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Nothing is wrong with the program; it never ran. With `ProtectSystem=strict`,
|
|
201
|
+
systemd builds a mount namespace before starting anything, and a directory
|
|
202
|
+
named in `ReadWritePaths=` that **does not exist** makes that fail. There is no
|
|
203
|
+
log from the node because there was no node.
|
|
204
|
+
|
|
205
|
+
Find it:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
systemctl show -p ReadWritePaths --value pmtiles-swarm | tr ' ' '
|
|
209
|
+
' | sed 's/^-//' | while read -r p; do [ -d "$p" ] || echo "missing: $p"; done
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Then create what is missing and restart:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
sudo install -d -o pmtiles-swarm -g pmtiles-swarm -m 0775 /the/missing/path
|
|
216
|
+
sudo systemctl restart pmtiles-swarm
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Units generated from version 0.97.0 onwards prefix every path with `-`, which
|
|
220
|
+
is systemd for "ignore this if it does not exist" — so a missing directory
|
|
221
|
+
becomes a write that fails later, naming the path, rather than a unit that will
|
|
222
|
+
not start and says nothing about why. `init --systemd` also lists the
|
|
223
|
+
directories the configuration names that are not there yet.
|
|
224
|
+
|
|
192
225
|
## Regenerating the unit on a node that already runs
|
|
193
226
|
|
|
194
227
|
The unit is derived from two things that move: the configuration, and how many
|
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/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/web/index.html
CHANGED
|
@@ -7774,6 +7774,12 @@ Every piece is hashed against the ` +
|
|
|
7774
7774
|
: `<button type="button" data-stack-feed="${escapeHtml(stack.id)}"
|
|
7775
7775
|
title="The address another node follows to keep its copy of this recipe level with this one. Add it there under Settings → Feeds → Stack feeds.">RSS</button>`
|
|
7776
7776
|
}
|
|
7777
|
+
${
|
|
7778
|
+
stack.cache && stack.cache.bytes > 0
|
|
7779
|
+
? `<button type="button" data-stack-uncache="${escapeHtml(stack.id)}"
|
|
7780
|
+
title="Throws away the ${stack.cache.entries.toLocaleString()} tiles this stack has already merged, so the next request for each merges it again. Saving an edit does this by itself — this is for a source whose bytes changed under an address that did not.">Clear cache (${bytes(stack.cache.bytes)})</button>`
|
|
7781
|
+
: ''
|
|
7782
|
+
}
|
|
7777
7783
|
<button type="button" data-stack-edit="${escapeHtml(stack.id)}">Edit</button>
|
|
7778
7784
|
<button type="button" data-stack-copy="${escapeHtml(stack.id)}"
|
|
7779
7785
|
title="Opens the editor on a copy of this recipe, under a name of its own. Nothing is saved until you save it.">Duplicate</button>
|
|
@@ -9173,6 +9179,7 @@ Every piece is hashed against the ` +
|
|
|
9173
9179
|
const drop = event.target.dataset.stackDelete;
|
|
9174
9180
|
const bake = event.target.dataset.stackBake;
|
|
9175
9181
|
const stopBake = event.target.dataset.stackBakeStop;
|
|
9182
|
+
const uncache = event.target.dataset.stackUncache;
|
|
9176
9183
|
if (bake !== undefined) {
|
|
9177
9184
|
const { stacks } = await api('/api/stacks');
|
|
9178
9185
|
await openBakeDialog(
|
|
@@ -9224,6 +9231,31 @@ Every piece is hashed against the ` +
|
|
|
9224
9231
|
},
|
|
9225
9232
|
{ copy: true, taken },
|
|
9226
9233
|
);
|
|
9234
|
+
} else if (uncache !== undefined) {
|
|
9235
|
+
// No confirmation, for the reason the Storage panel gives: a merged
|
|
9236
|
+
// tile can be merged again. What it costs is the merge, and the row
|
|
9237
|
+
// says how much disk it is getting back.
|
|
9238
|
+
try {
|
|
9239
|
+
const gone = await api(
|
|
9240
|
+
`/api/stacks/${encodeURIComponent(uncache)}/cache`,
|
|
9241
|
+
{ method: 'DELETE' },
|
|
9242
|
+
);
|
|
9243
|
+
// The others are named rather than counted: pressing one
|
|
9244
|
+
// button and clearing three stacks is surprising until you can
|
|
9245
|
+
// see that the other two are built on this one.
|
|
9246
|
+
const also = gone.stacks.filter((id) => id !== uncache);
|
|
9247
|
+
// Nothing to free is a real answer, not a failure: eviction may
|
|
9248
|
+
// have reached these tiles between the row being drawn and the
|
|
9249
|
+
// button being pressed.
|
|
9250
|
+
toast(
|
|
9251
|
+
`${gone.bytes > 0 ? `Freed ${bytes(gone.bytes)} from` : 'Cleared'} ${uncache}${
|
|
9252
|
+
also.length ? ` and ${also.join(', ')}` : ''
|
|
9253
|
+
}`,
|
|
9254
|
+
);
|
|
9255
|
+
loadStacks().catch((e) => toast(e.message));
|
|
9256
|
+
} catch (error) {
|
|
9257
|
+
toast(error.message);
|
|
9258
|
+
}
|
|
9227
9259
|
} else if (drop !== undefined) {
|
|
9228
9260
|
if (!confirm(`Delete the stack "${drop}"?`)) return;
|
|
9229
9261
|
try {
|