pmtiles-swarm 0.26.0 → 0.26.1

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,25 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.26.1
11
+ ### 🐞 Bug fixes
12
+ - **A download that finished was fetched all over again.** Resuming looked only at the
13
+ `.incomplete` path. A run that transferred the whole archive, had the marker removed, and then
14
+ stopped during the hashing left the file under its final name — so the restart found nothing to
15
+ resume and re-fetched every byte, with the finished copy sitting beside it the entire time.
16
+ Reported after 700 GB was transferred twice.
17
+
18
+ A file already under the final name is now checked against the length the source reports, and
19
+ hashed where it matches. Where it does not match it is not the archive being asked for, whatever
20
+ its name says, and the fetch proceeds — hashing it would publish the wrong bytes under the right
21
+ name, which is worse than transferring it again.
22
+ - **Hashing looked like a hang.** Progress was reported for the download and then nothing at all,
23
+ while `createTorrentFromFile` read the whole archive to build the piece hashes — twice, with
24
+ `md5` on. For a planet archive that is the longer half of the work and it was completely silent,
25
+ so a fetch reaching 100% and going quiet read as a stall. Reported as "it completed and never
26
+ started making the torrent", when it had been making it for some time. It now says what it is
27
+ doing when it starts, reports a heartbeat every minute while it runs, and says how long it took.
28
+
10
29
  ## 0.26.0
11
30
  ### 🐞 Bug fixes
12
31
  - **The add dialog stayed on screen for the length of a download.** `POST /api/torrents` awaited
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
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",
@@ -49,13 +49,48 @@ export async function createTorrentFromFile(filePath, options = {}) {
49
49
  if (!stat.isFile() || stat.size === 0) {
50
50
  throw new Error(`not a usable file: ${filePath}`);
51
51
  }
52
- // A second read of the archive, which is why it is opt-in. Nothing else here
53
- // touches these bytes again once the piece hashes are done.
54
- const md5Digest = options.md5 ? await md5File(filePath) : undefined;
55
- return buildTorrent(filePath, path.basename(filePath), stat.size, {
56
- ...options,
57
- md5Digest,
52
+
53
+ // Says that it started, and keeps saying it is going.
54
+ //
55
+ // Hashing reads the whole archive, and with md5 on it reads it twice — for a
56
+ // planet archive that is the longer half of the job, and it emitted nothing
57
+ // at all. The download reports every second and then stops dead, so the
58
+ // symptom is a fetch that reaches 100% and appears to hang; reported from the
59
+ // field as "it completed and never started making the torrent", when it had
60
+ // in fact been making it for some time.
61
+ const what = options.sourceUrl ?? filePath;
62
+ const passes = options.md5 ? 'twice, once of them for the MD5' : 'once';
63
+ console.log(
64
+ `[hash] ${what}: reading ${stat.size} bytes ${passes} to build the torrent`,
65
+ );
66
+ const startedAt = Date.now();
67
+ const heartbeat = setInterval(() => {
68
+ const minutes = Math.round((Date.now() - startedAt) / 60000);
69
+ console.log(`[hash] ${what}: still hashing after ${minutes}m`);
70
+ }, 60000);
71
+ heartbeat.unref?.();
72
+ options.onProgress?.({
73
+ phase: 'hashing',
74
+ received: stat.size,
75
+ total: stat.size,
58
76
  });
77
+
78
+ try {
79
+ // A second read of the archive, which is why it is opt-in. Nothing else
80
+ // here touches these bytes again once the piece hashes are done.
81
+ const md5Digest = options.md5 ? await md5File(filePath) : undefined;
82
+ const built = await buildTorrent(
83
+ filePath,
84
+ path.basename(filePath),
85
+ stat.size,
86
+ { ...options, md5Digest },
87
+ );
88
+ const seconds = Math.round((Date.now() - startedAt) / 1000);
89
+ console.log(`[hash] ${what}: torrent built in ${seconds}s`);
90
+ return built;
91
+ } finally {
92
+ clearInterval(heartbeat);
93
+ }
59
94
  }
60
95
 
61
96
  /**
@@ -91,21 +126,53 @@ export async function createTorrentFromUrl(url, options = {}) {
91
126
  // verify. The marker means the URL 404s until the moment it is real.
92
127
  const target = path.join(options.retainPath, name);
93
128
  const marker = options.incompleteSuffix ?? DEFAULT_SUFFIX;
94
- await downloadTo(
95
- url,
96
- `${target}${marker}`,
97
- options.onProgress,
98
- options.signal,
99
- {
129
+ const partial = `${target}${marker}`;
130
+
131
+ // A run that got all the way through the download and stopped during the
132
+ // hashing leaves the archive under its final name, the marker already
133
+ // removed. Resuming looked only at the marker path, so it saw nothing to
134
+ // continue, and fetched the whole thing again with the finished copy
135
+ // sitting beside it -- 700 GB re-transferred to arrive at a file that was
136
+ // already there.
137
+ //
138
+ // Hashing is the longer half for a large archive and reports nothing while
139
+ // it runs, so being interrupted in it is not unlikely.
140
+ const finished = await bytesOnDisk(target);
141
+ const resuming = await bytesOnDisk(partial);
142
+ let haveIt = false;
143
+ if (finished > 0 && resuming === 0) {
144
+ const expected = await remoteLength(url, options.signal);
145
+ if (expected && finished === expected) {
146
+ haveIt = true;
147
+ console.log(
148
+ `[fetch] ${url} is already downloaded (${finished} bytes); ` +
149
+ 'hashing what is on disk rather than fetching it again',
150
+ );
151
+ } else {
152
+ // Same name, different length: whatever this is, it is not the archive
153
+ // being asked for, and hashing it would publish the wrong bytes under
154
+ // the right name. The download proceeds to the marker path as usual.
155
+ console.warn(
156
+ `[fetch] ${target} exists but is ${finished} bytes against ` +
157
+ `${expected ?? 'an unknown length'} at the source; fetching again`,
158
+ );
159
+ }
160
+ }
161
+
162
+ if (!haveIt) {
163
+ await downloadTo(url, partial, options.onProgress, options.signal, {
100
164
  attempts: options.fetchAttempts,
101
165
  retryDelayMs: options.fetchRetryDelayMs,
102
- },
103
- );
104
- if (marker) await fs.rename(`${target}${marker}`, target);
166
+ });
167
+ if (marker) await fs.rename(partial, target);
168
+ }
169
+
105
170
  const created = await createTorrentFromFile(target, {
106
171
  ...options,
107
172
  name,
108
173
  webSeeds,
174
+ onProgress: options.onProgress,
175
+ sourceUrl: url,
109
176
  });
110
177
  return { ...created, retainedAt: target };
111
178
  }
@@ -157,6 +224,30 @@ export async function createTorrentFromUrl(url, options = {}) {
157
224
  * @param {string} target - The partial file.
158
225
  * @returns {Promise<number>} - Bytes on disk.
159
226
  */
227
+ /**
228
+ * What the source says the archive is, in bytes, or 0 when it will not say.
229
+ *
230
+ * Used to decide whether a file already under its final name is the archive
231
+ * being asked for. A HEAD is enough and costs nothing next to the alternative,
232
+ * which is transferring the whole thing a second time to find out.
233
+ *
234
+ * @param {string} url - The archive's URL.
235
+ * @param {AbortSignal} [signal] - Cancels the probe.
236
+ * @returns {Promise<number>} - Length, or 0.
237
+ */
238
+ async function remoteLength(url, signal) {
239
+ try {
240
+ const response = await fetch(url, { method: 'HEAD', signal });
241
+ if (!response.ok) return 0;
242
+ return Number(response.headers.get('content-length') ?? 0) || 0;
243
+ } catch {
244
+ // A source that refuses HEAD tells us nothing, which is not the same as
245
+ // telling us the file is wrong. The caller re-fetches, which is what it
246
+ // would have done anyway.
247
+ return 0;
248
+ }
249
+ }
250
+
160
251
  async function bytesOnDisk(target) {
161
252
  const stat = await fs.stat(target).catch(() => null);
162
253
  return stat?.isFile() ? stat.size : 0;