pmtiles-swarm 0.95.0 β†’ 0.96.0

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