pmtiles-swarm 0.4.0 → 0.4.2
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 +44 -0
- package/docs/running-as-a-service.md +68 -0
- package/package.json +1 -1
- package/src/engines/composite.js +26 -0
- package/src/hooks.js +59 -27
- package/src/subscriptions.js +29 -4
- package/src/web/index.html +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.4.2
|
|
11
|
+
### 🐞 Bug fixes
|
|
12
|
+
- **Resume data is saved on a node running more than one engine.** The periodic save is only
|
|
13
|
+
scheduled if the engine offers `saveResume`, and the composite engine — the one in use
|
|
14
|
+
whenever `secondaryEngines` is set — did not, so it was never scheduled at all. The only
|
|
15
|
+
writes left were at shutdown, and those hit the second half of this: the sidecar asked
|
|
16
|
+
`need_save_resume_data()` first, which answers "has anything changed since the last save"
|
|
17
|
+
rather than "does a resume file exist". An archive that had been seeding since it was added
|
|
18
|
+
answers no, so nothing was written for it and it re-hashed its whole store on every start —
|
|
19
|
+
half an hour of disk, for 800 GB, before it serves anything. Both halves are fixed; the
|
|
20
|
+
sidecar half ships in `pmtiles-torrent`.
|
|
21
|
+
- **A hook is no longer killed for being talkative.** Its output was collected whole into a
|
|
22
|
+
buffer, and past that buffer's size the child is killed — so a hook that generates a planet
|
|
23
|
+
could die hours in for the offence of saying too much, and the output that would have
|
|
24
|
+
explained it was the thing that overflowed. Output is streamed now and only the last twenty
|
|
25
|
+
lines are kept, so how much a hook says cannot decide whether it survives.
|
|
26
|
+
|
|
27
|
+
### 📚 Documentation
|
|
28
|
+
- **Sharing a folder with another service**, in the service guide: group membership is only the
|
|
29
|
+
first of three steps, and a folder at 0755 gives that group `r-x` — enough to hash and seed an
|
|
30
|
+
archive and not enough for `latestLink`, retention or a hook, so it looks like it worked until
|
|
31
|
+
the first thing that writes.
|
|
32
|
+
- **The read-only hooks panel says to restart.** Setting `allowHooksFromApi` in the config file
|
|
33
|
+
unlocks nothing until the node reads it, which it does once, at startup.
|
|
34
|
+
|
|
35
|
+
## 0.4.1
|
|
36
|
+
### 🐞 Bug fixes
|
|
37
|
+
- **A feed no longer walks backwards through its own history.** An item already taken was
|
|
38
|
+
skipped and the loop carried on to the older one below it, and the cap counts what was
|
|
39
|
+
*added* — so every poll took exactly one archive and every poll took a different one, until
|
|
40
|
+
the whole backlog was on disk. Against planet.openstreetmap.org that is five 88 GiB dumps
|
|
41
|
+
arriving a quarter of an hour apart from a subscription asking for one. Items run newest
|
|
42
|
+
first, so reaching one already held now stops the pass: everything after it is older than
|
|
43
|
+
something already on disk. A build that could not be fetched stops it too — one bad fetch is
|
|
44
|
+
a reason to retry shortly, not to take last week's instead.
|
|
45
|
+
- **A subscription's `mode` had no effect.** It reached the add as `paused`, which nothing
|
|
46
|
+
reads — not the library and not the engine — so every item a feed brought in arrived as a
|
|
47
|
+
cache whatever the subscription said, and a `"mode": "mirror"` feed quietly fetched nothing.
|
|
48
|
+
A cache subscription only looked correct because cache is the default. It is now passed as
|
|
49
|
+
`mode`, the name the library actually reads.
|
|
50
|
+
- **The console says what an empty availability bar means.** It counts connected peers and not
|
|
51
|
+
this node, so an archive only this node holds shows nothing — the truth about the swarm rather
|
|
52
|
+
than about the file, but worth saying beside a Downloaded bar that is full.
|
|
53
|
+
|
|
10
54
|
## 0.4.0
|
|
11
55
|
### ✨ Features and improvements
|
|
12
56
|
- **A watched folder can set the torrent comment**, which is where attribution and licence belong —
|
|
@@ -214,6 +214,74 @@ anything that holds data:
|
|
|
214
214
|
`ReadWritePaths` names, so every one of those has to be listed. An archive
|
|
215
215
|
directory on another mount needs its own entry.
|
|
216
216
|
|
|
217
|
+
## Sharing a folder with another service
|
|
218
|
+
|
|
219
|
+
A folder produced by something else — a generation script, or a directory a
|
|
220
|
+
torrent client already owns — needs three things, and group membership is only
|
|
221
|
+
the first of them.
|
|
222
|
+
|
|
223
|
+
```sh
|
|
224
|
+
# 1. Put the service account in the owning group.
|
|
225
|
+
sudo usermod -aG qbittorrent-nox pmtiles-swarm
|
|
226
|
+
|
|
227
|
+
# 2. Give that group write, and setgid so new entries inherit it.
|
|
228
|
+
sudo find /mnt/hd-16TB/store/generated -type d -exec chmod 2775 {} +
|
|
229
|
+
sudo find /mnt/hd-16TB/store/generated -type f -exec chmod 664 {} +
|
|
230
|
+
|
|
231
|
+
# 3. Make what the service creates group-writable too.
|
|
232
|
+
sudo systemctl edit pmtiles-swarm
|
|
233
|
+
sudo systemctl restart pmtiles-swarm
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Step 3 opens an override; the two lines to put in it are:
|
|
237
|
+
|
|
238
|
+
```ini
|
|
239
|
+
[Service]
|
|
240
|
+
UMask=0002
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**A folder at 0755 gives the group `r-x`.** Membership alone buys read access
|
|
244
|
+
and nothing else, which is enough to hash and seed an archive and not enough to
|
|
245
|
+
do anything else with the folder — so this looks like it worked until the first
|
|
246
|
+
thing that writes.
|
|
247
|
+
|
|
248
|
+
Three features want write, and it is worth knowing which, because a read-only
|
|
249
|
+
folder is a perfectly reasonable way to run:
|
|
250
|
+
|
|
251
|
+
| | |
|
|
252
|
+
| --- | --- |
|
|
253
|
+
| `latestLink` | Creates and replaces a name in the folder |
|
|
254
|
+
| `keep`, `keepDays` | Deletes retired builds |
|
|
255
|
+
| `onComplete` | Whatever the script does, since it runs as this account |
|
|
256
|
+
|
|
257
|
+
Renaming and deleting need write on the **directory**, not on the file, which is
|
|
258
|
+
why the directory bits are the ones that matter. `UMask=0002` matters for the
|
|
259
|
+
other direction: without it a file the service creates is `0644`, and the other
|
|
260
|
+
service can delete it but not modify it.
|
|
261
|
+
|
|
262
|
+
**Group membership is read when a process starts**, so the restart is not
|
|
263
|
+
optional. Neither is `ReadWritePaths`: `ProtectSystem=strict` presents the rest
|
|
264
|
+
of the filesystem as read-only inside the unit's namespace, and the write is
|
|
265
|
+
refused there before the permission bits are consulted. Every folder outside
|
|
266
|
+
`/var/lib/pmtiles-swarm` has to be named, in a drop-in from
|
|
267
|
+
`systemctl edit pmtiles-swarm`:
|
|
268
|
+
|
|
269
|
+
```ini
|
|
270
|
+
[Service]
|
|
271
|
+
ReadWritePaths=/mnt/store/generated /mnt/work/planetiler
|
|
272
|
+
UMask=0002
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`ReadWritePaths=` accumulates, so a drop-in adds to what the unit already lists
|
|
276
|
+
rather than replacing it.
|
|
277
|
+
|
|
278
|
+
Two things that look like checks and are not. `id pmtiles-swarm` reads
|
|
279
|
+
`/etc/group` and shows the new group the instant `usermod` returns, whether or
|
|
280
|
+
not the running process has it — read `/proc/$(systemctl show -p MainPID --value
|
|
281
|
+
pmtiles-swarm)/status` instead. And `sudo -u pmtiles-swarm touch …` runs outside
|
|
282
|
+
the unit's namespace, so it succeeds on permissions alone while the service is
|
|
283
|
+
still being refused.
|
|
284
|
+
|
|
217
285
|
## The sidecar
|
|
218
286
|
|
|
219
287
|
The libtorrent engine runs Python as a child process, so the service user needs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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/engines/composite.js
CHANGED
|
@@ -340,6 +340,32 @@ export class CompositeEngine {
|
|
|
340
340
|
}
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Persists resume data on every engine that keeps any.
|
|
345
|
+
*
|
|
346
|
+
* Missing entirely until now, and the caller checks for it before setting
|
|
347
|
+
* its timer — so on any node with a secondary engine the periodic save was
|
|
348
|
+
* never scheduled, and the only resume data ever written was whatever the
|
|
349
|
+
* shutdown path managed. An archive that had been seeding since it was added
|
|
350
|
+
* therefore re-hashed its whole store on every start, which for 800 GB is
|
|
351
|
+
* half an hour of disk before it serves anything.
|
|
352
|
+
* @param {string} [infoHash] - One archive, or all of them when omitted.
|
|
353
|
+
* @returns {Promise<void>} - Resolves once every engine has been asked.
|
|
354
|
+
*/
|
|
355
|
+
async saveResume(infoHash) {
|
|
356
|
+
for (const engine of [this.#primary, ...this.#secondaries]) {
|
|
357
|
+
// WebTorrent keeps none, and says so by not offering the method.
|
|
358
|
+
if (!engine.saveResume) continue;
|
|
359
|
+
try {
|
|
360
|
+
await engine.saveResume(infoHash);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
console.warn(
|
|
363
|
+
`[composite] ${engine.name} could not save resume data: ${error.message}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
343
369
|
/**
|
|
344
370
|
* Peers from every engine, labelled with which one found them.
|
|
345
371
|
* @param {string} infoHash - The archive.
|
package/src/hooks.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
/** How much of a hook's output to repeat into this node's own log. */
|
|
5
|
+
const TAIL_LINES = 20;
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Running something when an archive arrives, and when it finishes.
|
|
6
9
|
*
|
|
@@ -200,32 +203,61 @@ export class ProgramHooks {
|
|
|
200
203
|
console.log(`[${label}] ${entry.name}: ${command} ${filled.join(' ')}`);
|
|
201
204
|
|
|
202
205
|
return new Promise((resolve) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
206
|
+
const timeout = (timeoutSeconds ?? 0) * 1000;
|
|
207
|
+
const child = spawn(command, filled, {
|
|
208
|
+
// A tile build runs for hours. Nothing here should assume otherwise,
|
|
209
|
+
// so the default is no timeout at all.
|
|
210
|
+
...(timeout > 0 ? { timeout } : {}),
|
|
211
|
+
cwd: hook.cwd,
|
|
212
|
+
env: { ...process.env, ...(hook.env ?? {}) },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Streamed, and only the tail is kept.
|
|
216
|
+
//
|
|
217
|
+
// This used to collect the whole of stdout and stderr into a buffer, and
|
|
218
|
+
// a buffer has a size: past it, execFile kills the child. A hook that
|
|
219
|
+
// generates a planet says far more than any buffer worth holding, so a
|
|
220
|
+
// build could be killed hours in for the offence of being talkative —
|
|
221
|
+
// and the output that would have explained it was the thing that
|
|
222
|
+
// overflowed. Nothing is held now but the last few lines, so how much a
|
|
223
|
+
// hook says cannot decide whether it survives.
|
|
224
|
+
const tail = [];
|
|
225
|
+
let partial = '';
|
|
226
|
+
const collect = (chunk) => {
|
|
227
|
+
partial += chunk;
|
|
228
|
+
const lines = partial.split('\n');
|
|
229
|
+
partial = lines.pop() ?? '';
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
tail.push(line);
|
|
232
|
+
if (tail.length > TAIL_LINES) tail.shift();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
236
|
+
stream?.setEncoding('utf8');
|
|
237
|
+
stream?.on('data', collect);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const report = (problem) => {
|
|
241
|
+
if (problem) {
|
|
242
|
+
console.error(`[${label}] ${entry.name}: ${problem}`);
|
|
243
|
+
} else {
|
|
244
|
+
console.log(`[${label}] ${entry.name}: finished`);
|
|
245
|
+
}
|
|
246
|
+
// A last line with no newline after it is still a line.
|
|
247
|
+
if (partial) collect('\n');
|
|
248
|
+
for (const line of tail) {
|
|
249
|
+
if (line.trim()) console.log(`[${label}] ${line}`);
|
|
250
|
+
}
|
|
251
|
+
resolve();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// A command that could not be started at all — no such file, not
|
|
255
|
+
// executable — never reaches 'close'.
|
|
256
|
+
child.on('error', (error) => report(error.message));
|
|
257
|
+
child.on('close', (code, signal) => {
|
|
258
|
+
if (signal) return report(`killed by ${signal}`);
|
|
259
|
+
report(code === 0 ? undefined : `exited with code ${code}`);
|
|
260
|
+
});
|
|
229
261
|
});
|
|
230
262
|
}
|
|
231
263
|
}
|
package/src/subscriptions.js
CHANGED
|
@@ -141,12 +141,26 @@ export class SubscriptionManager {
|
|
|
141
141
|
continue;
|
|
142
142
|
}
|
|
143
143
|
const marker = item.infoHash ?? item.magnet ?? item.torrentUrl;
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
|
|
145
|
+
// Stop, rather than skip past it to the next one.
|
|
146
|
+
//
|
|
147
|
+
// Items run newest first, so reaching one already taken means
|
|
148
|
+
// everything after it is older than something on disk. Skipping to the
|
|
149
|
+
// next instead takes one more archive every poll and walks backwards
|
|
150
|
+
// through the feed until the whole backlog is held — which for the
|
|
151
|
+
// planet feed is five 88 GiB dumps nobody asked for, arriving one a
|
|
152
|
+
// quarter of an hour apart. The cap counts what was added, so it does
|
|
153
|
+
// not stop this: each poll adds exactly one, and each poll adds a
|
|
154
|
+
// different one.
|
|
155
|
+
if (this.#seen.has(marker)) break;
|
|
146
156
|
|
|
147
157
|
try {
|
|
148
158
|
const entry = await this.#add(item, subscription);
|
|
149
159
|
if (entry) {
|
|
160
|
+
// Marked only once it worked. A .torrent that 404s for an hour
|
|
161
|
+
// should be retried on the next poll rather than passed over for
|
|
162
|
+
// good on the strength of one bad fetch.
|
|
163
|
+
this.#seen.add(marker);
|
|
150
164
|
added.push(entry);
|
|
151
165
|
console.log(
|
|
152
166
|
`[feed] ${subscription.mode ?? 'cache'} ${entry.name} from ${subscription.url}`,
|
|
@@ -154,6 +168,10 @@ export class SubscriptionManager {
|
|
|
154
168
|
}
|
|
155
169
|
} catch (error) {
|
|
156
170
|
console.error(`[feed] could not add "${item.title}": ${error.message}`);
|
|
171
|
+
// And stop here too. The newest build being briefly unreachable is a
|
|
172
|
+
// reason to try again shortly, not a reason to fetch last week's
|
|
173
|
+
// instead — which is the same backwards walk by another route.
|
|
174
|
+
break;
|
|
157
175
|
}
|
|
158
176
|
}
|
|
159
177
|
return added;
|
|
@@ -340,8 +358,15 @@ export class SubscriptionManager {
|
|
|
340
358
|
// from one built here or added by hand, and would happily delete both.
|
|
341
359
|
subscriptionUrl: subscription.url,
|
|
342
360
|
// Cache mode joins the swarm without pulling the whole archive; the
|
|
343
|
-
// pieces it does hold are still served to other peers.
|
|
344
|
-
|
|
361
|
+
// pieces it does hold are still served to other peers. Mirror commits to
|
|
362
|
+
// a whole copy.
|
|
363
|
+
//
|
|
364
|
+
// Passed as `mode`, which is the name the library reads. It had been
|
|
365
|
+
// passed as `paused`, which nothing reads — so a feed's mode did not
|
|
366
|
+
// reach the add at all, and every item arrived as cache whatever the
|
|
367
|
+
// subscription said. Defaulted here as well as there, so the two cannot
|
|
368
|
+
// drift apart into disagreeing about what an unset mode means.
|
|
369
|
+
mode: subscription.mode ?? 'cache',
|
|
345
370
|
};
|
|
346
371
|
|
|
347
372
|
// The .torrent is preferred where there is one: it carries the trackers
|
package/src/web/index.html
CHANGED
|
@@ -1190,7 +1190,9 @@
|
|
|
1190
1190
|
shown as held when <i>all</i> of them are. Availability is the
|
|
1191
1191
|
<i>rarest</i> piece in each column: one piece nobody has is the
|
|
1192
1192
|
answer to "can this still be completed", however well supplied
|
|
1193
|
-
its neighbours are.
|
|
1193
|
+
its neighbours are. It counts <i>connected peers</i> and not
|
|
1194
|
+
this node, so an archive only this node holds shows an empty
|
|
1195
|
+
bar — which is the truth about the swarm, not about the file.
|
|
1194
1196
|
</div>
|
|
1195
1197
|
${
|
|
1196
1198
|
(info.peers ?? []).length > 0
|
|
@@ -2600,7 +2602,11 @@
|
|
|
2600
2602
|
token that manages maps — it would be one that runs code.
|
|
2601
2603
|
Edit them in the config file, or set
|
|
2602
2604
|
<code>"allowHooksFromApi": true</code> there to take that
|
|
2603
|
-
trade deliberately and unlock this panel
|
|
2605
|
+
trade deliberately and unlock this panel —
|
|
2606
|
+
<b>then restart</b>, since the config file is read once at
|
|
2607
|
+
startup and nothing re-reads it. This panel is still
|
|
2608
|
+
read-only because the running node has not seen that
|
|
2609
|
+
setting, whatever the file now says.
|
|
2604
2610
|
</p>`
|
|
2605
2611
|
}
|
|
2606
2612
|
</div>`;
|