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 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.0",
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",
@@ -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 { execFile } from 'node:child_process';
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
- execFile(
204
- command,
205
- filled,
206
- {
207
- // A tile build runs for hours. Nothing here should assume otherwise,
208
- // so the default is no timeout at all.
209
- timeout: (timeoutSeconds ?? 0) * 1000,
210
- maxBuffer: 4 * 1024 * 1024,
211
- cwd: hook.cwd,
212
- env: { ...process.env, ...(hook.env ?? {}) },
213
- },
214
- (error, stdout, stderr) => {
215
- if (error) {
216
- console.error(`[${label}] ${entry.name}: ${error.message}`);
217
- } else {
218
- console.log(`[${label}] ${entry.name}: finished`);
219
- }
220
- const output = `${stdout ?? ''}${stderr ?? ''}`.trim();
221
- if (output) {
222
- for (const line of output.split('\n').slice(-20)) {
223
- console.log(`[${label}] ${line}`);
224
- }
225
- }
226
- resolve();
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
  }
@@ -141,12 +141,26 @@ export class SubscriptionManager {
141
141
  continue;
142
142
  }
143
143
  const marker = item.infoHash ?? item.magnet ?? item.torrentUrl;
144
- if (this.#seen.has(marker)) continue;
145
- this.#seen.add(marker);
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
- paused: (subscription.mode ?? 'cache') === 'cache',
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
@@ -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>`;