pmtiles-swarm 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,64 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.8.0
11
+ ### ✨ Features and improvements
12
+ - **`GET /health`**, for a load balancer: 200 when this node can serve, 503 when its engine
13
+ cannot, no credential and nothing to parse. It asks the engine rather than itself, which is the
14
+ distinction that makes it worth having — a feed is built from the catalogue and never touches
15
+ the swarm, so a balancer checking `/feed.xml`, the nearest thing that existed, gets 200 from a
16
+ node whose engine is dead and keeps sending it traffic. The answer is cached for two seconds,
17
+ because a balancer asks often and each check is an inter-process round trip, and it is sent
18
+ `no-store`: a stale health check keeps a dead node in rotation for as long as whatever cached
19
+ it says so.
20
+ - **`GET /archives/<infohash>/ready`**, which answers a different question — whether a *particular*
21
+ archive has become servable on this node. 200 once its header and, for vector, its layers have
22
+ been read; 503 with which half is missing; **415** for an archive that can never be served,
23
+ since MBTiles is distributed here but cannot be read a byte range at a time and polling it would
24
+ be polling for ever; 404 when it is not here. It reports rather than acts, starting no read and
25
+ waiting for nothing — a probe that does work on demand is a probe that can be used to make a
26
+ node do work on demand.
27
+ - **[docs/haproxy.md](docs/haproxy.md)**, written against the OPNsense plugin: the health monitor
28
+ field by field, how the check interval trades against failover time, timeouts long enough for a
29
+ web seed to finish, `X-Forwarded-Proto`, gating a deployment on `/ready` — and a table of what a
30
+ reverse proxy in front of a BitTorrent node simply cannot carry.
31
+
32
+ ### 🐞 Bug fixes
33
+ - **Head-warming no longer tries to read a PMTiles header out of a `.osm.pbf`.** The guard was
34
+ `entry.kind && entry.kind !== 'pmtiles'`, and `guessKind` answers `undefined` for anything it
35
+ does not recognise — so it never fired for exactly the archives it existed to exclude. Every
36
+ planet dump being mirrored, and every MBTiles archive, was read as though it had a header,
37
+ failed, and came back on the backoff for ever. The test is positive now: the kind has to *be*
38
+ PMTiles, taken from the entry where it is known and from the file name where it is not.
39
+
40
+ ## 0.7.1
41
+ ### 🐞 Bug fixes
42
+ - **A feed no longer deletes its only complete copy.** Retention was written for a watched folder
43
+ and a scheduled source, where the archive it is handed is already whole — the file was there, or
44
+ the fetch finished. A subscription is not like that: it joins a torrent, and the data arrives
45
+ hours later. So `keep: 1` on a feed removed last week's complete copy the moment this week's
46
+ torrent was announced, leaving nothing complete at all for the length of an 88 GiB download.
47
+ `keepDays` had the same exposure, a copy ageing out while its replacement was still arriving.
48
+
49
+ Retention on a subscription now waits for the newest copy to be whole, which makes `keep: 1`
50
+ mean *the last complete copy* — the only reading of it that is safe there. It also runs on every
51
+ poll rather than only on polls that took something, because what is being waited for is a
52
+ download finishing rather than a poll happening. Watched folders and scheduled sources are
53
+ unchanged: they hand over a finished archive, and asking them for a completion marker they never
54
+ set would have stopped their retention working.
55
+
56
+ ### 📚 Documentation
57
+ - **`prune` does not apply to an RSS subscription**, which the documentation did not say and a
58
+ reader would reasonably have assumed otherwise — it is accepted there and quietly does nothing.
59
+ Absence from a bounded feed is not evidence that anything was withdrawn, so pruning needs a
60
+ catalogue. The two questions are now separated where they are described: whether the publisher
61
+ still offers an archive, and whether you still want it on your disk.
62
+ - **Feed retention is documented**, along with the claim it replaces. The subscribing guide said a
63
+ node following a feed "accumulates and never sheds", which was true when it was written and is
64
+ what `keep` and `keepDays` on a subscription now answer.
65
+ - **The README describes the two settings sections** rather than the single table they replaced,
66
+ and covers `newest`, `keep` and `keepDays` — none of which it mentioned.
67
+
10
68
  ## 0.7.0
11
69
  ### ✨ Features and improvements
12
70
  - **A feed can be told how long to keep what it brings in.** `keep` and `keepDays` now work on a
package/README.md CHANGED
@@ -33,6 +33,9 @@ node src/index.js --config swarm.config.json
33
33
  address.
34
34
  - **[docs/running-as-a-service.md](docs/running-as-a-service.md)** — a systemd unit, why
35
35
  `Restart=always` is required rather than optional, and which ports want a firewall rule.
36
+ - **[docs/haproxy.md](docs/haproxy.md)** — health monitors, what a reverse proxy carries and
37
+ what it cannot, timeouts long enough for a web seed, and gating a deployment on
38
+ `/archives/<infohash>/ready`.
36
39
  - **[docs/architecture-diagram.md](docs/architecture-diagram.md)** — how a publishing node, a
37
40
  serving tier, the swarm and both kinds of client fit together.
38
41
 
@@ -488,16 +491,28 @@ restart.
488
491
 
489
492
  ### Following other nodes
490
493
 
491
- `subscriptions` are the peers this node takes archives from, editable in Settings under **Remote
492
- nodes**. An RSS feed says "here is what is new" and is bounded by the publisher's `feedMaxItems`,
493
- so a node offline long enough misses things permanently; a `/api/catalog` URL says "here is
494
- everything", which is what makes reconciling — and pruning — possible.
494
+ `subscriptions` are what this node takes archives from, editable in Settings as two sections:
495
+ **RSS feeds** and **Remote nodes**. They are not one thing in two costumes. A feed says "here is
496
+ what is new" and is bounded by the publisher's `feedMaxItems`, so a node offline long enough
497
+ misses things permanently, and an absence from one proves nothing; a `/api/catalog` URL says
498
+ "here is everything", which is what makes reconciling — and pruning — possible. A row belongs to
499
+ whichever section it sits in, and is saved with that protocol rather than leaving it to be
500
+ inferred from the URL.
495
501
 
496
502
  `mode` decides what following one costs: `cache` joins the swarm and fetches only what is read,
497
503
  `mirror` commits to a whole copy of every archive the peer lists. `token` is presented to the peer,
498
- which may then publish more than it publishes to the world. `prune` is off unless chosen, only ever
499
- considers archives that peer sent, and never acts on a filtered or partial view — start a new peer
500
- on `"report"` and watch it before trusting it with more.
504
+ which may then publish more than it publishes to the world.
505
+
506
+ `newest` caps how many items one check of a **feed** may take, counting from the newest, and
507
+ defaults to 1 — planet.openstreetmap.org lists five planet dumps, and taking the lot is four
508
+ hundred gigabytes nobody asked for. It means nothing to a catalogue, which lists everything.
509
+
510
+ `keep` and `keepDays` retire what a subscription has brought in, exactly as they do for a watched
511
+ folder or a scheduled source: only after something new has landed, and never the newest copy. They
512
+ answer for your disk. `prune` answers for the publisher — it is off unless chosen, only ever
513
+ considers archives that peer sent, never acts on a filtered or partial view, and **applies to a
514
+ catalogue only**, since absence from a bounded feed is not evidence that anything was withdrawn.
515
+ Start a new peer on `"report"` and watch it before trusting it with more.
501
516
 
502
517
  Peer tokens are redacted from `GET /api/config` like any other credential, and a save that echoes
503
518
  the placeholder back keeps the stored one.
@@ -614,6 +629,8 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
614
629
  | `GET` | `/api/catalog` | The whole catalogue, for a peer keeping itself in step |
615
630
  | `GET` | `/archives/:infoHash/tiles.json` | TileJSON — **public** |
616
631
  | `GET` | `/archives/:infoHash/:z/:x/:y.:ext` | One tile — **public** |
632
+ | `GET` | `/health` | 200 when this node can serve, 503 when its engine cannot — **public**, no credential, for a load balancer |
633
+ | `GET` | `/archives/:infoHash/ready` | Whether this node can serve *this* archive yet: 200 ready, 503 not yet, 415 never — **public** |
617
634
  | `GET` | `/archives/:infoHash/archive.torrent` | The `.torrent` a torrent-aware client joins with — **public** |
618
635
  | `GET` | `/archives/:infoHash/preview` | Map preview for one archive — admin, not public |
619
636
  | `GET` | `/latest/:category/tiles.json`, `/:name.torrent`, `/magnet` | The newest build in a category — **public**. The torrent name is yours to choose, so a link can read `planetiler-openmaptiles-latest.torrent`; it redirects to the immutable URL, which names the download after the build it actually is |
@@ -0,0 +1,194 @@
1
+ # Behind HAProxy
2
+
3
+ Written against the **OPNsense HAProxy plugin**, which is configured through a
4
+ form rather than a file. The field names below are that plugin's; the generated
5
+ configuration is shown alongside so the same thing can be written by hand.
6
+
7
+ ## What HAProxy carries, and what it cannot
8
+
9
+ This is the first thing to get straight, because a reverse proxy in front of a
10
+ BitTorrent node carries less than it looks like it does.
11
+
12
+ | | Through HAProxy? |
13
+ | --- | --- |
14
+ | Tiles, TileJSON, feeds, `.torrent` files, health checks | **Yes** — ordinary HTTP on 8090 |
15
+ | The console and API on 8091 | **No.** Do not publish it |
16
+ | BitTorrent peers on 6881 | **No.** Not HTTP; needs its own forward |
17
+ | WebRTC to browser peers | **No.** Outbound only, and needs nothing |
18
+
19
+ A node reachable *only* through HAProxy has no incoming peers at all. It will
20
+ still seed to peers it connects to, and still serve tiles, but it is half a
21
+ member of the swarm. 6881 wants a firewall rule of its own, straight to the
22
+ node, TCP **and** UDP.
23
+
24
+ ## The health monitor
25
+
26
+ **Services → HAProxy → Settings → Health Monitors → Add**
27
+
28
+ | Field | Value |
29
+ | --- | --- |
30
+ | Name | `pmtiles-swarm` |
31
+ | Check type | `HTTP` |
32
+ | Check interval | `2s` or `5s` — see below |
33
+ | Port to check | *empty*, so it uses the real server's port |
34
+ | HTTP method | `GET` |
35
+ | Request URI | `/health` |
36
+ | HTTP version | `HTTP/1.1` |
37
+ | HTTP host | anything; `localhost` is fine |
38
+ | Custom HTTP check → Expression | exact string match for the HTTP status code |
39
+ | Custom HTTP check → Value | `200` |
40
+
41
+ `HTTP/1.1` requires a `Host` header, which is why the field is there. The node
42
+ does not route by it, so its value only matters if something in front of it
43
+ does.
44
+
45
+ Which produces:
46
+
47
+ ```
48
+ backend pmtiles-swarm
49
+ option httpchk GET /health HTTP/1.1
50
+ http-check expect status 200
51
+ server node1 172.16.1.49:8090 check inter 5s
52
+ server node2 172.16.1.41:8090 check inter 5s
53
+ ```
54
+
55
+ **Do not point the monitor at `/` or at `/feed.xml`.** Both answer 200 from a
56
+ node whose engine is dead — a feed is built from the catalogue and never
57
+ touches the swarm — so the balancer would keep sending traffic to a node that
58
+ cannot serve a tile. `/health` asks the engine and answers 503 when it does not
59
+ reply.
60
+
61
+ ### How often to check
62
+
63
+ `/health` caches its answer for two seconds, so anything faster than that asks
64
+ the same question twice and gets the same answer. At two seconds, near enough
65
+ every check is a real round trip to the engine — which enumerates every torrent
66
+ the node holds, so the cost grows with the catalogue.
67
+
68
+ What the interval buys is failover speed, and it is the interval multiplied by
69
+ HAProxy's `fall` count, which defaults to 3:
70
+
71
+ | Check interval | Marked down after |
72
+ | --- | --- |
73
+ | `2s` | about 6 seconds |
74
+ | `5s` | about 15 seconds |
75
+
76
+ Neither is wrong. Six seconds is worth having if a node dying mid-request
77
+ matters; fifteen is worth having if it does not, and costs the node less. What
78
+ is not worth having is a sub-second interval, which only asks a cached answer
79
+ more often.
80
+
81
+ Note also that the node comes *back* on `rise` successful checks — 2 by
82
+ default — so a flapping node re-enters rotation quickly whichever you choose.
83
+
84
+ ## Real servers
85
+
86
+ **Settings → Real Servers → Add**, one per node, port **8090**.
87
+
88
+ The admin port has no place in a public backend. It carries the console and the
89
+ API, and while both are behind authentication, an internet-facing sign-in page
90
+ is a thing to decide on deliberately rather than to acquire by copying a
91
+ backend.
92
+
93
+ ## Tell the node it is behind a proxy
94
+
95
+ HAProxy terminates TLS, so without being told, the node believes every request
96
+ arrived over plain HTTP and advertises `http://` tile URLs. A browser that
97
+ loaded the map over HTTPS then blocks every one of them as mixed content, which
98
+ looks like an empty map rather than a configuration mistake.
99
+
100
+ Two ways, and they are alternatives rather than a pair:
101
+
102
+ ```json
103
+ { "publicUrl": "https://swarm.example.org" }
104
+ ```
105
+
106
+ One canonical URL whatever the request said. Simple, and right when the node is
107
+ only ever reached one way.
108
+
109
+ ```json
110
+ { "trustProxy": "172.16.1.0/24" }
111
+ ```
112
+
113
+ Derive it per request from `X-Forwarded-Proto` and the `Host`, so the same node
114
+ answers correctly on its LAN address and through the proxy. **Name the proxy
115
+ rather than saying `true`**: the node also listens on its LAN address directly,
116
+ and `true` would let anything that can reach that port claim any protocol and
117
+ host it likes, which decides what URLs your feed hands out.
118
+
119
+ For that to work HAProxy has to send the header. In the plugin it is
120
+ **Public Service → Advanced → Option pass-through**, or in configuration:
121
+
122
+ ```
123
+ frontend https-in
124
+ http-request set-header X-Forwarded-Proto https
125
+ ```
126
+
127
+ `Host` is passed through by default.
128
+
129
+ ## Timeouts
130
+
131
+ The defaults are wrong for this. A web seed is an HTTP range request for part of
132
+ an archive, and a peer pulling a large one holds the connection for as long as
133
+ the transfer takes.
134
+
135
+ ```
136
+ defaults
137
+ timeout connect 5s
138
+ timeout client 1h
139
+ timeout server 1h
140
+ ```
141
+
142
+ An hour is not generous here; a client fetching tens of gigabytes over a slow
143
+ link exceeds anything shorter, and the failure looks like a corrupt download
144
+ rather than a timeout.
145
+
146
+ ## Deploying a new build
147
+
148
+ `/health` says whether a node should be sent traffic. It says nothing about
149
+ whether a *particular* archive has become servable there, which is the question
150
+ worth asking after a build lands.
151
+
152
+ ```sh
153
+ INFOHASH=5e1c143c400d15aaacfb1c748d4ab6d1b46c5df5
154
+ for node in 172.16.1.49 172.16.1.41; do
155
+ until curl -fsS "http://$node:8090/archives/$INFOHASH/ready" >/dev/null; do
156
+ sleep 10
157
+ done
158
+ echo "$node is ready"
159
+ done
160
+ ```
161
+
162
+ Ask each node **directly**, not through the balancer — through it you learn that
163
+ *some* node is ready, which is not the same thing and is exactly the wrong
164
+ answer when deciding whether to move traffic.
165
+
166
+ `/ready` answers 415 for an archive that can never be served, so a loop like the
167
+ one above would never end if an MBTiles archive reached it. Treat 415 as a
168
+ separate case if that is possible.
169
+
170
+ ## Cloudflare in front
171
+
172
+ Two rules worth setting deliberately.
173
+
174
+ **Never cache `/health` or `/archives/*/ready`.** Both send `no-store` and
175
+ Cloudflare honours it, but a page rule that caches everything can override that.
176
+ A cached health check keeps a dead node in rotation for as long as the cache
177
+ says so, which is worse than having no check at all.
178
+
179
+ **Cache tiles by infohash aggressively.** `/archives/<infohash>/…` is immutable
180
+ by construction — an infohash names those bytes and no others — and is served
181
+ with `max-age=31536000, immutable`. `/latest/<category>/…` is the opposite: it
182
+ moves on every build and is served with `max-age=300`.
183
+
184
+ WebRTC does not pass through Cloudflare, and neither does BitTorrent. Browser
185
+ peers reach the node over ICE, and a `wss://` tracker is the only part of that
186
+ which is HTTP at all.
187
+
188
+ ## When it looks like the swarm is empty
189
+
190
+ Almost always the peer port rather than the proxy. HAProxy carries none of it,
191
+ so a node behind one with no forward for 6881 shows connected peers only where
192
+ it made the connection itself. See
193
+ [docs/engines.md](engines.md#ports-and-reachability) for which ports want
194
+ forwarding and which want nothing.
@@ -200,6 +200,85 @@ For an archive published as a mutable torrent, the block also carries
200
200
  rather than pinning to the version the document was generated from. See
201
201
  [publishing](publishing.md).
202
202
 
203
+ ## Health checks
204
+
205
+ ```
206
+ GET /health
207
+ ```
208
+
209
+ 200 when this node can serve, 503 when it cannot, no credential and no body
210
+ worth parsing — which is what a load balancer needs. It is on the public
211
+ surface, so it answers on the same port the tiles do.
212
+
213
+ **It asks the engine, not just itself.** A reply means the engine answered a
214
+ round trip, which is the difference worth reporting: a feed is built from the
215
+ catalog and never touches the swarm, so a balancer checking `/feed.xml` gets
216
+ 200 from a node whose engine is dead and keeps sending it traffic.
217
+
218
+ The answer is cached for a couple of seconds, because a balancer asks often and
219
+ each check costs an inter-process round trip. A node that has just died leaves
220
+ rotation one check later than it otherwise would.
221
+
222
+ It sends `cache-control: no-store`. A stale health check is worse than none —
223
+ it keeps a dead node in rotation for as long as whatever cached it says so.
224
+
225
+ ```
226
+ backend tiles
227
+ option httpchk GET /health
228
+ http-check expect status 200
229
+ server node1 10.0.0.11:8090 check inter 5s
230
+ ```
231
+
232
+ Configured through a form rather than a file, and with the rest of what a proxy
233
+ in front of this needs — timeouts, `X-Forwarded-Proto`, and the ports it cannot
234
+ carry — in [docs/haproxy.md](haproxy.md).
235
+
236
+ ### Whether one archive is servable yet
237
+
238
+ ```
239
+ GET /archives/<infohash>/ready
240
+ ```
241
+
242
+ A different question, and worth keeping apart from the one above. `/health`
243
+ decides whether a node should be sent traffic at all; this says whether a
244
+ newly published archive has become servable *here* — which is what you want
245
+ after a build lands and before pointing anything at it.
246
+
247
+ | | |
248
+ | --- | --- |
249
+ | **200** | Ready. Its header has been read, and a vector archive has its layers |
250
+ | **503** | Not yet — ask again. The body says which half is missing |
251
+ | **415** | Never. MBTiles is distributed here but cannot be read a byte range at a time, so waiting would be waiting for ever |
252
+ | **404** | Not on this node |
253
+
254
+ The codes differ because the responses differ: one is "poll me", one is "stop
255
+ polling", and a script that treats them alike either gives up too early or
256
+ waits for something that will never happen.
257
+
258
+ It **reports rather than acts** — it starts no read and waits for nothing. A
259
+ probe that does work on demand is a probe that can be used to make a node do
260
+ work on demand. Reading the head is the head warmer's job; this only says
261
+ whether it has happened.
262
+
263
+ So the shape of a deployment check across a serving tier is: publish, then poll
264
+ every node until each answers 200, then move `latest`.
265
+
266
+ ```sh
267
+ for node in 10.0.0.11 10.0.0.12; do
268
+ until curl -fsS "http://$node:8090/archives/$INFOHASH/ready" >/dev/null; do
269
+ sleep 10
270
+ done
271
+ done
272
+ ```
273
+
274
+ `curl -f` fails on 503 and on 415 alike, so treat 415 separately if an MBTiles
275
+ archive could ever reach that loop — otherwise it never ends.
276
+
277
+ An archive this node holds *completely* can still be read from disk with the
278
+ engine down, so 503 is a statement about the node rather than about every
279
+ request it could answer. That is the right way round for a balancer with
280
+ somewhere else to send the traffic.
281
+
203
282
  ## Caching
204
283
 
205
284
  Tiles are served `Cache-Control: public, max-age=31536000, immutable`.
@@ -117,9 +117,16 @@ poor trade for the archive itself.
117
117
 
118
118
  ## Removing what a peer no longer offers
119
119
 
120
- A feed can only ever say "here is something new". Following one, a node
121
- accumulates and never sheds — remove an archive at the source and every
122
- subscriber keeps seeding it indefinitely.
120
+ Two different questions, with two different answers, and it is worth keeping
121
+ them apart: whether the **publisher** still offers an archive, and whether
122
+ **you** still want it on your disk.
123
+
124
+ ### The publisher stopped offering it
125
+
126
+ A feed can only ever say "here is something new". Nothing about it distinguishes
127
+ "withdrawn" from "further down a list that only carries five items", so absence
128
+ from a feed is not evidence of anything and **`prune` does not apply to an RSS
129
+ subscription at all** — setting it there is quietly inert.
123
130
 
124
131
  The catalogue API can say "here is everything", which is the only way a
125
132
  consumer can notice an absence. `prune` acts on that, and it is deliberately
@@ -289,6 +296,15 @@ OpenStreetMap publishes for the planet dumps:
289
296
  it is **1 by default**. That feed lists five planet dumps, so without a cap the
290
297
  first poll is roughly four hundred gigabytes. `0` takes everything it lists.
291
298
 
299
+ `keepDays` is how long to keep what it brings in. A feed publishing weekly
300
+ leaves a copy behind every week and the publisher goes on listing all of them,
301
+ so without it a subscription grows for ever — which is the case pruning cannot
302
+ help with, because nothing was ever withdrawn. `keep` says the same thing as a
303
+ count. Both are the rules a watched folder and a scheduled source retire under,
304
+ applied by the same code and with the same guards: **only after something new
305
+ has landed**, and **never the newest copy**, so a feed that goes quiet cannot
306
+ empty your disk.
307
+
292
308
  The items are `.osm.pbf`, not map archives, and that is fine: joining an
293
309
  existing torrent does not require the content to be anything in particular —
294
310
  only *creating* one does. The archive simply is not servable as tiles, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.7.0",
3
+ "version": "0.8.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
@@ -13,6 +13,7 @@ import {
13
13
  isPublicSurface,
14
14
  } from './auth.js';
15
15
  import { normalizeCategories } from './catalog.js';
16
+ import { guessKind } from './library.js';
16
17
  import { QBittorrentEngine } from './engines/qbittorrent.js';
17
18
  import { RESTART_REQUIRED, redactConfig, saveConfig } from './config.js';
18
19
  import { freeSpace, listLocations } from './locations.js';
@@ -397,6 +398,59 @@ export function createApp({
397
398
  }),
398
399
  );
399
400
 
401
+ /**
402
+ * Whether this node should be sent traffic.
403
+ *
404
+ * For a load balancer, which needs three things this did not have: no
405
+ * credential, a cheap answer, and a status code rather than a body to parse.
406
+ * A balancer checking `/feed.xml` instead — the nearest thing that existed —
407
+ * gets 200 from a node whose engine is dead, because a feed is built from
408
+ * the catalog and never touches the swarm.
409
+ *
410
+ * Readiness rather than liveness. `engine.list()` is a round trip to the
411
+ * engine, so a reply means the sidecar is answering and not merely that Node
412
+ * is; that is the difference worth reporting, since everything that makes
413
+ * this node useful to a swarm goes through it.
414
+ *
415
+ * Cached, because a balancer asks every couple of seconds and an IPC round
416
+ * trip per check is a cost with nothing to show for it. The window is short
417
+ * enough that a node which has just died is out of rotation within one more
418
+ * check than it would have been.
419
+ */
420
+ let healthChecked = 0;
421
+ let healthOk = true;
422
+ let healthError;
423
+ const HEALTH_TTL_MS = 2000;
424
+
425
+ app.get(
426
+ '/health',
427
+ route(async (_req, res) => {
428
+ const now = Date.now();
429
+ if (now - healthChecked >= HEALTH_TTL_MS) {
430
+ healthChecked = now;
431
+ try {
432
+ await engine.list();
433
+ healthOk = true;
434
+ healthError = undefined;
435
+ } catch (error) {
436
+ healthOk = false;
437
+ healthError = error.message;
438
+ }
439
+ }
440
+
441
+ // Never cached anywhere. A stale health check is worse than none: it
442
+ // keeps a dead node in rotation for as long as whatever cached it says.
443
+ res.setHeader('cache-control', 'no-store');
444
+ res.setHeader('access-control-allow-origin', '*');
445
+ res.status(healthOk ? 200 : 503).json({
446
+ status: healthOk ? 'ok' : 'unavailable',
447
+ engine: engine.name,
448
+ version: VERSION,
449
+ ...(healthOk ? {} : { error: healthError }),
450
+ });
451
+ }),
452
+ );
453
+
400
454
  app.get(
401
455
  '/api/status',
402
456
  route(async (_req, res) => {
@@ -1828,6 +1882,95 @@ export function createApp({
1828
1882
  // with authentication configured this answered 401 — to the very callers it
1829
1883
  // exists for, since this is the URL the TileJSON torrent block advertises and
1830
1884
  // the one a syncing peer follows.
1885
+ /**
1886
+ * Whether this node can serve *this* archive yet.
1887
+ *
1888
+ * A different question from `/health`, and answered separately because
1889
+ * nobody asks it per request. `/health` decides whether a node should be
1890
+ * sent traffic at all; this decides whether a newly published archive has
1891
+ * become servable here — which is what you want to know after a build lands
1892
+ * and before pointing anything at it.
1893
+ *
1894
+ * Reports rather than acts. It starts no read and waits for nothing: a probe
1895
+ * that does work on demand is a probe that can be used to make a node do
1896
+ * work on demand. The head warmer is what makes an archive ready; this only
1897
+ * says whether it has.
1898
+ *
1899
+ * The three answers are deliberately different codes, because they call for
1900
+ * different responses from whoever asked. 503 is "not yet, ask again". 415
1901
+ * is "never" — an MBTiles archive is distributed here and cannot be read a
1902
+ * byte range at a time, so waiting for it would be waiting for ever. 404 is
1903
+ * "not here at all".
1904
+ */
1905
+ app.get(
1906
+ '/archives/:infoHash/ready',
1907
+ route(async (req, res) => {
1908
+ const entry = catalog.get(req.params.infoHash);
1909
+ // Never cached. The whole value of this is that it changes.
1910
+ res.setHeader('cache-control', 'no-store');
1911
+ res.setHeader('access-control-allow-origin', '*');
1912
+
1913
+ if (!entry) {
1914
+ return res.status(404).json({ ready: false, reason: 'unknown archive' });
1915
+ }
1916
+
1917
+ const kind = entry.kind ?? guessKind(entry.name ?? '');
1918
+ const shape = {
1919
+ infoHash: entry.infoHash,
1920
+ name: entry.name,
1921
+ kind: kind ?? 'unknown',
1922
+ complete: entry.complete === true,
1923
+ };
1924
+
1925
+ if (kind !== 'pmtiles') {
1926
+ return res.status(415).json({
1927
+ ...shape,
1928
+ ready: false,
1929
+ reason:
1930
+ `this is ${kind ? `a ${kind}` : 'not a PMTiles'} archive, and only ` +
1931
+ 'PMTiles can be read a byte range at a time — it will not become ' +
1932
+ 'servable by waiting',
1933
+ });
1934
+ }
1935
+
1936
+ const summary = entry.pmtiles;
1937
+ // A summary that names a format is one a header was actually read for.
1938
+ // Anything else is a partial left by a read that did not finish.
1939
+ if (!summary?.format) {
1940
+ return res.status(503).json({
1941
+ ...shape,
1942
+ ready: false,
1943
+ reason: 'its header has not been read yet',
1944
+ });
1945
+ }
1946
+
1947
+ // Vector tiles without their layer list can be served, but nothing can
1948
+ // be styled from them: the TileJSON a map asks for would carry no
1949
+ // vector_layers. The metadata sits wherever the writer put it, which for
1950
+ // planetiler is after every tile, so it routinely arrives long after the
1951
+ // header.
1952
+ if (summary.format === 'pbf' && !summary.vectorLayers) {
1953
+ return res.status(503).json({
1954
+ ...shape,
1955
+ ready: false,
1956
+ format: summary.format,
1957
+ reason: 'its metadata has not been read yet, so it carries no vector layers',
1958
+ });
1959
+ }
1960
+
1961
+ res.json({
1962
+ ...shape,
1963
+ ready: true,
1964
+ format: summary.format,
1965
+ minZoom: summary.minZoom,
1966
+ maxZoom: summary.maxZoom,
1967
+ ...(summary.vectorLayers
1968
+ ? { vectorLayers: summary.vectorLayers.length }
1969
+ : {}),
1970
+ });
1971
+ }),
1972
+ );
1973
+
1831
1974
  app.get(
1832
1975
  '/archives/:infoHash/archive.torrent',
1833
1976
  route(async (req, res) => {
package/src/auth.js CHANGED
@@ -144,6 +144,8 @@ export function isPublicSurface(path) {
144
144
  if (/^\/archives\/[^/]+\/preview\/?$/.test(path)) return false;
145
145
 
146
146
  return (
147
+ // A load balancer checks this, and it checks the public port.
148
+ path === '/health' ||
147
149
  path === '/api/catalog' ||
148
150
  path === '/api/catalog/' ||
149
151
  path === '/feed.xml' ||
package/src/prewarm.js CHANGED
@@ -21,6 +21,8 @@
21
21
  * the first few seconds if the right few kilobytes are asked for first.
22
22
  */
23
23
 
24
+ import { guessKind } from './library.js';
25
+
24
26
  /** The first wait after an attempt that did not finish the job. */
25
27
  const DEFAULT_BACKOFF_SECONDS = 15;
26
28
 
@@ -91,9 +93,20 @@ export class HeadWarmer {
91
93
  * @returns {boolean} - True to attempt a read.
92
94
  */
93
95
  due(entry) {
94
- // Only PMTiles has a head worth reading. A .osm.pbf from a feed is not an
95
- // archive this can say anything about.
96
- if (entry.kind && entry.kind !== 'pmtiles') return false;
96
+ // Only PMTiles has a head worth reading, and this has to be a positive
97
+ // test rather than the absence of a negative one.
98
+ //
99
+ // `guessKind` answers `undefined` for anything it does not recognise — a
100
+ // .osm.pbf from a feed, for instance — so `entry.kind && entry.kind !==
101
+ // 'pmtiles'` never fired for exactly the archives it was meant to exclude.
102
+ // Every planet dump being mirrored was read as though it had a PMTiles
103
+ // header, failed, and came back on the backoff for ever.
104
+ //
105
+ // Taken from the entry where it is known and from the name where it is
106
+ // not, since an archive joined by magnet has no kind until its metadata
107
+ // arrives.
108
+ const kind = entry.kind ?? guessKind(entry.name ?? '');
109
+ if (kind !== 'pmtiles') return false;
97
110
 
98
111
  // A summary that names a format is one a header was actually read for.
99
112
  // Anything else — an empty object, or one left behind by a read that raced
package/src/retention.js CHANGED
@@ -99,6 +99,7 @@ export function expired({ family, keep, keepDays, now = Date.now() }) {
99
99
  * @param {number} [options.keep] - How many of the newest to hold.
100
100
  * @param {number} [options.keepDays] - How old a build may get, in days.
101
101
  * @param {string} options.label - How to name this family in the log.
102
+ * @param {boolean} [options.requireComplete] - Wait for the newest to be whole.
102
103
  * @param {number} [options.now] - The current time, for testing.
103
104
  * @returns {Promise<string[]>} - The infohashes removed.
104
105
  */
@@ -109,8 +110,23 @@ export async function retire({
109
110
  keep,
110
111
  keepDays,
111
112
  label,
113
+ requireComplete = false,
112
114
  now,
113
115
  }) {
116
+ // Where the new copy is a download rather than a file that already exists.
117
+ //
118
+ // A watched folder and a scheduled source hand over an archive that is
119
+ // whole: the file was there, or the fetch finished. A subscription does not
120
+ // — it joins a torrent, and the data arrives hours later. Retiring on the
121
+ // join would delete last week's complete copy the moment this week's was
122
+ // announced, leaving nothing complete for the length of the download.
123
+ //
124
+ // So `keep: 1` here means "the last complete copy", which is the only
125
+ // reading of it that is safe.
126
+ if (requireComplete && entry?.complete !== true) {
127
+ return [];
128
+ }
129
+
114
130
  const doomed = expired({ family, keep, keepDays, now });
115
131
  if (doomed.length === 0) return [];
116
132
 
@@ -180,7 +180,7 @@ export class SubscriptionManager {
180
180
  // from a bounded feed proves nothing — but age applies whatever the list
181
181
  // is, which is what makes this the answer for a feed that publishes
182
182
  // weekly for ever.
183
- await this.#retire(subscription, added);
183
+ await this.#retire(subscription);
184
184
  return added;
185
185
  }
186
186
 
@@ -259,7 +259,7 @@ export class SubscriptionManager {
259
259
  }
260
260
 
261
261
  await this.#prune(subscription, document, archives);
262
- await this.#retire(subscription, added);
262
+ await this.#retire(subscription);
263
263
  return added;
264
264
  }
265
265
 
@@ -279,23 +279,33 @@ export class SubscriptionManager {
279
279
  * Only after something new has landed, and never the newest copy, which are
280
280
  * the same guards a scheduled source retires under. See `retire`.
281
281
  * @param {object} subscription - The subscription.
282
- * @param {object[]} added - What this pass took, newest first.
283
282
  * @returns {Promise<void>} - Resolves once anything due has gone.
284
283
  */
285
- async #retire(subscription, added) {
286
- if (added.length === 0 || !retains(subscription)) return;
287
-
284
+ async #retire(subscription) {
285
+ if (!retains(subscription)) return;
286
+
287
+ // Only what this feed brought in. An archive built here, added by hand, or
288
+ // taken from another peer is not this subscription's to remove.
289
+ const family = this.#library.catalog
290
+ .list()
291
+ .filter((entry) => entry.source?.subscription === subscription.url);
292
+ if (family.length === 0) return;
293
+
294
+ // Every poll, not only the polls that took something. What is being waited
295
+ // for is the newest copy *finishing*, which happens hours after the poll
296
+ // that started it — so retiring only on the way in would either act far
297
+ // too early or never act at all.
288
298
  await retire({
289
299
  library: this.#library,
290
- // Only what this feed brought in. An archive built here, added by hand,
291
- // or taken from another peer is not this subscription's to remove.
292
- family: this.#library.catalog
293
- .list()
294
- .filter((entry) => entry.source?.subscription === subscription.url),
295
- entry: added[0],
300
+ family,
301
+ entry: family[0],
296
302
  keep: subscription.keep,
297
303
  keepDays: subscription.keepDays,
298
304
  label: `[sync] ${subscription.url}`,
305
+ // The newest copy here is a download rather than a file that was already
306
+ // there, so nothing goes until it is whole. That is what makes `keep: 1`
307
+ // mean "the last complete copy".
308
+ requireComplete: true,
299
309
  });
300
310
  }
301
311