@torrent-tv/proxy 2.80.18 → 2.81.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 +22 -0
- package/docs/encode-architecture.md +51 -2
- package/package.json +1 -1
- package/research/double-spawn-2026-09-10.md +171 -0
- package/services/disk/DiskSpace.js +146 -0
- package/services/disk/wire.js +60 -0
- package/services/encode/EncodeRun.js +37 -9
- package/services/encode/SegmentStore.js +284 -232
- package/services/encode/run-command.js +16 -1
- package/services/hls-session-manager.js +31 -128
- package/services/orchestrators/EncodeOrchestrator.js +52 -38
- package/services/piece-store/allowance.js +107 -0
- package/services/piece-store/piece-disk-store.js +365 -0
- package/services/piece-store/shared-piece-store.js +1549 -1535
- package/services/segment-formats/fmp4.js +54 -0
- package/services/segment-formats/mpegts.js +54 -0
- package/services/torrent-worker/client.js +32 -0
- package/services/torrent-worker/pool-adapter.js +15 -0
- package/services/torrent-worker/protocol.js +9 -0
- package/services/torrent-worker/worker.js +8 -1
- package/services/viewer/positions.js +48 -0
- package/test/audio-inventory.test.js +176 -176
- package/test/auto-quality-step.test.js +514 -514
- package/test/concurrent-cost.test.js +138 -138
- package/test/coverage-follows-the-disk.test.js +191 -187
- package/test/coverage-map.test.js +195 -195
- package/test/declared-tracks.test.js +35 -35
- package/test/disk-space.test.js +138 -0
- package/test/encode-orchestrator.test.js +0 -3
- package/test/encode-run.test.js +5 -12
- package/test/held-request-width.test.js +155 -155
- package/test/helpers/encode-run.js +2 -2
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +281 -220
- package/test/orchestrator-wired.test.js +199 -195
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +267 -0
- package/test/piece-reader.test.js +4 -4
- package/test/piece-store-eviction.test.js +17 -17
- package/test/piece-store-reservations.test.js +20 -1
- package/test/piece-store-slow-disk.test.js +16 -1
- package/test/produced-copy-choice.test.js +258 -358
- package/test/read-window.test.js +6 -6
- package/test/run-intervals.test.js +100 -100
- package/test/seek-landing.test.js +109 -109
- package/test/segment-serve-wiring.test.js +8 -9
- package/test/segment-store-eviction.test.js +232 -0
- package/test/segment-store.test.js +238 -216
- package/test/segments-are-shared.test.js +1 -1
- package/test/shared-piece-store.test.js +12 -12
- package/test/sidecar-naming.test.js +142 -142
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-walk.test.js +369 -369
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-track-numbering.test.js +370 -370
- package/test/tail-duplication.test.js +167 -167
- package/test/tracks-begin-together.test.js +195 -195
- package/test/two-viewers-one-picture.test.js +374 -374
- package/test/video-facts.test.js +102 -102
- package/test/wedge-certainty.test.js +131 -131
- package/services/encode/open-piece.js +0 -135
- package/services/piece-store/disk-tier.js +0 -151
- package/test/open-piece.test.js +0 -152
|
@@ -19,28 +19,32 @@
|
|
|
19
19
|
* killed, work out what it is looking at — without it, everything on disk after
|
|
20
20
|
* a kill is unidentifiable and can only be thrown away.
|
|
21
21
|
*
|
|
22
|
-
* **What proves a segment is closed.**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
22
|
+
* **What proves a segment is closed: its NAME.** A piece being written is called
|
|
23
|
+
* something else — `making-40-00057.mp4`, tagged with the run writing it — and
|
|
24
|
+
* takes its served name only when the
|
|
25
|
+
* encoder has said it is closed, which it does on a channel of its own
|
|
26
|
+
* (`-segment_list pipe:3`). The `hls` branch needs nothing extra: its muxer
|
|
27
|
+
* writes through a temporary name of its own, so its files appear under their
|
|
28
|
+
* final name whole. One rule for both, and true whether or not this process is
|
|
29
|
+
* alive: **a file under its served name is complete.**
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* It was "a segment is closed when the NEXT number exists". That is true of one
|
|
32
|
+
* writer walking forward and false the moment two runs share an output, because
|
|
33
|
+
* the next file is then written by another process while this one is still open
|
|
34
|
+
* — and two runs on one output is not a rare state, it is what the plan gives an
|
|
35
|
+
* output whenever it places a second encoder. Field 2026-09-08:
|
|
36
|
+
* `segment-00057.mp4` served at 2 268 361 bytes and then at 4 510 940, exactly
|
|
37
|
+
* half; the browser appended the half and refused the whole for the rest of the
|
|
38
|
+
* session, `bufferAppendError` fourteen times with the picture frozen at
|
|
39
|
+
* 319.66 s. `segment-00055.mp4` the same, 211 957 against 2 620 617.
|
|
34
40
|
*/
|
|
35
41
|
|
|
36
42
|
import { createHash } from "node:crypto";
|
|
37
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
43
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
38
44
|
import { rmSync } from "node:fs";
|
|
39
45
|
import os from "node:os";
|
|
40
46
|
import path from "node:path";
|
|
41
47
|
|
|
42
|
-
import { discardOpenPiece } from "./open-piece.js";
|
|
43
|
-
|
|
44
48
|
/** Where every output's segments live. One root for the process. */
|
|
45
49
|
export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
|
|
46
50
|
|
|
@@ -86,22 +90,6 @@ export class SegmentStore {
|
|
|
86
90
|
/** Output key → when it was last asked for. @type {Map<string, number>} */
|
|
87
91
|
#touched = new Map();
|
|
88
92
|
|
|
89
|
-
/**
|
|
90
|
-
* Numbers known closed for a reason other than a successor on the disk.
|
|
91
|
-
*
|
|
92
|
-
* Two things fill it. A live run says what it has finished as it finishes it.
|
|
93
|
-
* And adoption records what the successor rule proved BEFORE it removes the
|
|
94
|
-
* unproven piece — otherwise removing that piece would un-prove the segment
|
|
95
|
-
* below it, which is a file that was demonstrably closed a moment earlier.
|
|
96
|
-
*
|
|
97
|
-
* @type {Map<string, Set<number>>}
|
|
98
|
-
*/
|
|
99
|
-
#closed = new Map();
|
|
100
|
-
|
|
101
|
-
/** Pieces already reported as taken on the successor rule, so one is said
|
|
102
|
-
* once. @type {Map<string, Set<number>>} */
|
|
103
|
-
#unreportedSaid = new Map();
|
|
104
|
-
|
|
105
93
|
/** @type {{ info: Function, warn: Function }} */
|
|
106
94
|
#logger;
|
|
107
95
|
|
|
@@ -250,42 +238,28 @@ export class SegmentStore {
|
|
|
250
238
|
}
|
|
251
239
|
|
|
252
240
|
/**
|
|
253
|
-
* The segment numbers this output holds that are
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* a LIVE RUN IS REWRITING. Several runs share one directory, so a file left by
|
|
270
|
-
* an earlier one is a successor to a name the run working now has just
|
|
271
|
-
* reopened, and the disk cannot know the difference. Whether that has ever
|
|
272
|
-
* moved a decision is not established from any log we hold, and every remedy
|
|
273
|
-
* for it changes what "ready" means for five readers with different questions
|
|
274
|
-
* — so it waits for a session that shows it, rather than being guessed at.
|
|
241
|
+
* The segment numbers this output holds that are finished.
|
|
242
|
+
*
|
|
243
|
+
* ONE PROOF, AND IT IS THE PIECE'S OWN NAME. A piece being written is called
|
|
244
|
+
* `making-40-00042.mp4`; it is renamed to `segment-00042.mp4` when its writer
|
|
245
|
+
* says it has closed it, and on the `hls` branch — which has no such channel —
|
|
246
|
+
* the muxer's own `+temp_file` does the same rename for the same reason. So a
|
|
247
|
+
* file under the served name is complete, whoever made it and whenever.
|
|
248
|
+
*
|
|
249
|
+
* WHAT THIS REPLACED, because the difference is what a viewer felt. Closure
|
|
250
|
+
* used to be inferred from the NEXT number existing, which is sound for one
|
|
251
|
+
* writer walking forward and false the moment two runs share an output — and
|
|
252
|
+
* one-piece intervals guarantee that. Field 2026-09-08:
|
|
253
|
+
* `segment-00057.mp4` was served at 2 268 361 bytes and then at 4 510 940, the
|
|
254
|
+
* browser appended the truncated body, and `bufferAppendError` repeated to the
|
|
255
|
+
* end of the log with the picture frozen at 319.66 s. It also left the last
|
|
256
|
+
* piece of every run unprovable for ever, since nothing follows it.
|
|
275
257
|
*
|
|
276
258
|
* @param {string} key
|
|
277
259
|
* @returns {number[]}
|
|
278
260
|
*/
|
|
279
261
|
provenNumbers(key) {
|
|
280
|
-
|
|
281
|
-
const stated = this.#closed.get(key);
|
|
282
|
-
const proven = [];
|
|
283
|
-
for (const index of contents.byNumber.keys()) {
|
|
284
|
-
if (contents.byNumber.has(index + 1) || stated?.has(index)) {
|
|
285
|
-
proven.push(index);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return proven.sort((left, right) => left - right);
|
|
262
|
+
return [...this.refresh(key).byNumber.keys()].sort((left, right) => left - right);
|
|
289
263
|
}
|
|
290
264
|
|
|
291
265
|
/**
|
|
@@ -317,189 +291,151 @@ export class SegmentStore {
|
|
|
317
291
|
return this.refresh(key).largest ?? { index: -1, size: 0 };
|
|
318
292
|
}
|
|
319
293
|
|
|
320
|
-
/**
|
|
321
|
-
* A run is about to write these numbers again: forget that they were closed.
|
|
322
|
-
*
|
|
323
|
-
* A number closed once is not closed for ever. An encoder started at #N
|
|
324
|
-
* rewrites #N and everything after it, and while it is doing so the file
|
|
325
|
-
* under that name is half a segment — but the store remembered the earlier
|
|
326
|
-
* closing and would call it whole. Field 2026-09-05: seventeen runs were
|
|
327
|
-
* stopped and none ended normally, so numbers were being rewritten
|
|
328
|
-
* constantly, and the player met a fatal append error it never recovered
|
|
329
|
-
* from — an empty picture for the six minutes that followed.
|
|
330
|
-
*
|
|
331
|
-
* @param {string} key
|
|
332
|
-
* @param {number} from - First number the run will write.
|
|
333
|
-
* @param {number} [to] - Last one, inclusive. Infinite for a run given no end,
|
|
334
|
-
* which does walk to the end of the film.
|
|
335
|
-
*/
|
|
336
|
-
forgetClosed(key, from, to = Number.POSITIVE_INFINITY) {
|
|
337
|
-
const known = this.#closed.get(key);
|
|
338
|
-
if (!known || !Number.isInteger(from)) {
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
// BOUNDED BY THE RUN'S OWN STRETCH, because that is what it will rewrite.
|
|
342
|
-
//
|
|
343
|
-
// It used to forget everything from `from` upwards, on the reading that a
|
|
344
|
-
// run has no end — which was true until runs were given intervals. A run of
|
|
345
|
-
// #0..#0 then unproved the whole rest of the film, and with readiness a
|
|
346
|
-
// projection of what is proven that is an output declaring itself unmade
|
|
347
|
-
// every time an encoder starts anywhere near the beginning.
|
|
348
|
-
const last = Number.isFinite(to) ? Math.max(from, Math.trunc(to)) : Number.POSITIVE_INFINITY;
|
|
349
|
-
for (const index of known) {
|
|
350
|
-
if (index >= from && index <= last) {
|
|
351
|
-
known.delete(index);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
// What the directory says has to be read again too, so that the size of a
|
|
355
|
-
// reopened piece is the size it has now and not the one it had before.
|
|
356
|
-
this.#held.delete(key);
|
|
357
|
-
}
|
|
358
294
|
|
|
359
295
|
/**
|
|
360
296
|
* Whether this piece is finished, and may therefore be served.
|
|
361
297
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* 2. **the next file exists** — the only proof available on the `hls` branch,
|
|
368
|
-
* which has no such channel, and for pieces left by an earlier life of this
|
|
369
|
-
* process. On that branch it is sound: the muxer renames into place on
|
|
370
|
-
* close, so a file that exists is finished.
|
|
371
|
-
*
|
|
372
|
-
* KNOWN AND LEFT ALONE HERE: on the `segment` branch this second proof can
|
|
373
|
-
* still pass a piece a run is halfway through rewriting, which is how 110 698
|
|
374
|
-
* bytes came to be served under a name whose neighbours are 12 MB (field
|
|
375
|
-
* 2026-09-06). Telling the two branches apart is a fact of how a run writes,
|
|
376
|
-
* it needs a field session of its own to verify, and it is not what stopped
|
|
377
|
-
* playback on 2026-09-07 — so it stays open rather than being changed blind in
|
|
378
|
-
* the path that hands bytes to a player. What the PLAN believes is a different
|
|
379
|
-
* question and is answered: a live run's claim outranks readiness there.
|
|
298
|
+
* Its NAME is the proof, and there is no second one: a piece being written is
|
|
299
|
+
* called something else until whoever writes it says it is closed. That holds
|
|
300
|
+
* for a piece a live run is rewriting — the file standing there was closed by
|
|
301
|
+
* somebody, and it is replaced whole or not at all — and for a piece left by an
|
|
302
|
+
* earlier life of this process, which the startup sweep answers the same way.
|
|
380
303
|
*
|
|
381
304
|
* @param {string} key
|
|
382
305
|
* @param {number} index
|
|
383
306
|
* @returns {boolean}
|
|
384
307
|
*/
|
|
385
308
|
isClosed(key, index) {
|
|
386
|
-
|
|
387
|
-
return true;
|
|
388
|
-
}
|
|
389
|
-
const bySuccessor = this.refresh(key).byNumber.has(index + 1);
|
|
390
|
-
if (bySuccessor) {
|
|
391
|
-
this.#noteUnreported(key, index);
|
|
392
|
-
}
|
|
393
|
-
return bySuccessor;
|
|
309
|
+
return this.refresh(key).byNumber.has(index);
|
|
394
310
|
}
|
|
395
311
|
|
|
396
312
|
/**
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* anything wrote one, and then its name promises a whole span while it holds
|
|
404
|
-
* a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
|
|
405
|
-
* neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
|
|
406
|
-
* the player jumped the hole it left.
|
|
407
|
-
*
|
|
408
|
-
* That cannot arise from two encoders any more — their stretches no longer
|
|
409
|
-
* overlap — so what is left is a piece from a process that died without
|
|
410
|
-
* clearing up. Said once per piece, with its size, so a return of it is
|
|
411
|
-
* visible rather than inferred.
|
|
313
|
+
* Remove the pieces an output was in the middle of writing.
|
|
314
|
+
*
|
|
315
|
+
* They are under working names, so they were never servable and nothing has
|
|
316
|
+
* to be un-proven — this is disk, not correctness. A process killed by the
|
|
317
|
+
* kernel leaves one per live run, and the kernel takes this process often
|
|
318
|
+
* enough for that to matter.
|
|
412
319
|
*
|
|
413
320
|
* @param {string} key
|
|
414
|
-
* @param {
|
|
321
|
+
* @param {string} dir
|
|
322
|
+
* @param {{ makingTagOf?: (name: string) => string | null }} format
|
|
323
|
+
* @param {string | null} [tag] - One run's own tag, or null for every run's.
|
|
324
|
+
* @returns {number} How many were removed.
|
|
415
325
|
*/
|
|
416
|
-
#
|
|
417
|
-
let
|
|
418
|
-
|
|
419
|
-
said = new Set();
|
|
420
|
-
this.#unreportedSaid.set(key, said);
|
|
421
|
-
}
|
|
422
|
-
if (said.has(index)) {
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
said.add(index);
|
|
426
|
-
let bytes = -1;
|
|
326
|
+
#sweepUnfinished(key, dir, format, tag = null) {
|
|
327
|
+
let removed = 0;
|
|
328
|
+
let names = [];
|
|
427
329
|
try {
|
|
428
|
-
|
|
330
|
+
names = readdirSync(dir);
|
|
429
331
|
} catch {
|
|
430
|
-
|
|
431
|
-
return;
|
|
332
|
+
return 0;
|
|
432
333
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
334
|
+
for (const name of names) {
|
|
335
|
+
const wroteIt = format?.makingTagOf?.(name) ?? null;
|
|
336
|
+
if (wroteIt === null || (tag !== null && wroteIt !== tag)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
rmSync(path.join(dir, name), { force: true });
|
|
341
|
+
removed += 1;
|
|
342
|
+
} catch {
|
|
343
|
+
// Then it stays, costing disk and nothing else.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (removed > 0) {
|
|
347
|
+
this.#held.delete(key);
|
|
348
|
+
}
|
|
349
|
+
return removed;
|
|
438
350
|
}
|
|
439
351
|
|
|
440
352
|
/**
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
353
|
+
* The encoder has closed a piece: give it the name it is served under.
|
|
354
|
+
*
|
|
355
|
+
* One rename inside the output's own directory — one filesystem operation, and
|
|
356
|
+
* atomic there. Before it the file is not a segment and no request can reach
|
|
357
|
+
* it; after it, its existence IS the proof that it is whole, and that is one
|
|
358
|
+
* rule for every branch whether or not our own process is alive.
|
|
359
|
+
*
|
|
360
|
+
* It replaced a rule that served half a segment: a piece was taken as finished
|
|
361
|
+
* when the NEXT file existed. That is true of one writer walking forward and
|
|
362
|
+
* false the moment two runs share an output, because the next file is then
|
|
363
|
+
* written by another process while this one is still open — and two runs on one
|
|
364
|
+
* output is not a rare state, it is what the plan gives an output whenever it
|
|
365
|
+
* places a second encoder.
|
|
366
|
+
*
|
|
367
|
+
* Field 2026-09-08: `segment-00057.mp4` was served at 2 268 361 bytes and then
|
|
368
|
+
* at 4 510 940 — exactly half of it. The browser appended the half and refused
|
|
369
|
+
* the whole for the rest of the session, `bufferAppendError` fourteen times
|
|
370
|
+
* over with the picture frozen at 319.66 s. `segment-00055.mp4` went the same
|
|
371
|
+
* way, 211 957 against 2 620 617.
|
|
445
372
|
*
|
|
446
373
|
* @param {string} key
|
|
447
|
-
* @param {
|
|
374
|
+
* @param {string} makingName - What the encoder called it while writing.
|
|
375
|
+
* @param {{ servedNameOf?: (name: string) => string | null }} format
|
|
376
|
+
* @returns {string | null} The served name, or null where nothing was renamed.
|
|
448
377
|
*/
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
378
|
+
publish(key, makingName, format) {
|
|
379
|
+
const served = format?.servedNameOf?.(makingName) ?? null;
|
|
380
|
+
if (!served) {
|
|
381
|
+
return null;
|
|
452
382
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
383
|
+
const dir = path.join(this.#root, directoryNameFor(key));
|
|
384
|
+
try {
|
|
385
|
+
renameSync(path.join(dir, makingName), path.join(dir, served));
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// The file may already be gone — a process killed between closing the
|
|
388
|
+
// piece and this line. Said rather than swallowed: a piece the encoder
|
|
389
|
+
// reported and the disk does not have is worth knowing about.
|
|
390
|
+
this.#logger?.warn?.(
|
|
391
|
+
`segment store: could not publish ${makingName} of ${key.slice(0, 60)}: ` +
|
|
392
|
+
`${error instanceof Error ? error.message : String(error)}`
|
|
393
|
+
);
|
|
394
|
+
return null;
|
|
457
395
|
}
|
|
458
|
-
|
|
396
|
+
// What the directory holds has changed, so the memory of it is stale.
|
|
397
|
+
this.#held.delete(key);
|
|
398
|
+
return served;
|
|
459
399
|
}
|
|
460
400
|
|
|
461
401
|
/**
|
|
462
|
-
*
|
|
402
|
+
* Clear up after a run that has ended: remove what it left unfinished.
|
|
403
|
+
*
|
|
404
|
+
* Every file it left open carries its own tag, so this is a name match and
|
|
405
|
+
* nothing else — no stretch to search, no bytes to judge, and no chance of
|
|
406
|
+
* removing a piece somebody else closed.
|
|
463
407
|
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
* whose name then read as a segment made).
|
|
408
|
+
* WHAT IT REPLACED, because the difference is the whole of the rename design.
|
|
409
|
+
* It used to take the highest SERVED name inside the stretch the ended run was
|
|
410
|
+
* given and judge whether its bytes looked usable — a guess, needed only
|
|
411
|
+
* because an unfinished piece was indistinguishable from a finished one. Under
|
|
412
|
+
* the naming rule it would now remove a complete segment: the highest served
|
|
413
|
+
* name in a dead run's stretch is a piece it closed.
|
|
471
414
|
*
|
|
472
415
|
* @param {string} key
|
|
473
|
-
* @param {
|
|
474
|
-
*
|
|
475
|
-
* has to be looked for inside the stretch the ended run was given.
|
|
476
|
-
* @param {((raw: Buffer) => boolean) | null} [judgeUsable]
|
|
477
|
-
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
416
|
+
* @param {number} startedAt - The run's first segment number, which is its tag.
|
|
417
|
+
* @returns {number} How many unfinished pieces were removed.
|
|
478
418
|
*/
|
|
479
|
-
|
|
419
|
+
clearUpAfter(key, startedAt) {
|
|
480
420
|
const format = this.#formats.get(key);
|
|
481
421
|
if (!format) {
|
|
482
|
-
return
|
|
422
|
+
return 0;
|
|
483
423
|
}
|
|
484
|
-
const removed =
|
|
485
|
-
|
|
486
|
-
this
|
|
424
|
+
const removed = this.#sweepUnfinished(
|
|
425
|
+
key,
|
|
426
|
+
this.directoryFor(key),
|
|
427
|
+
format,
|
|
428
|
+
String(Number.isInteger(startedAt) && startedAt > 0 ? startedAt : 0)
|
|
429
|
+
);
|
|
430
|
+
if (removed > 0) {
|
|
487
431
|
this.#logger?.info?.(
|
|
488
|
-
`segment store:
|
|
432
|
+
`segment store: cleared up ${removed} unfinished piece(s) of the run at ` +
|
|
433
|
+
`#${startedAt} on ${key.slice(0, 60)}`
|
|
489
434
|
);
|
|
490
435
|
}
|
|
491
436
|
return removed;
|
|
492
437
|
}
|
|
493
438
|
|
|
494
|
-
/**
|
|
495
|
-
* The one number in this output whose closure nothing on disk proves.
|
|
496
|
-
*
|
|
497
|
-
* @param {string} key
|
|
498
|
-
* @returns {number} -1 when the directory holds no segments.
|
|
499
|
-
*/
|
|
500
|
-
unprovenNumber(key) {
|
|
501
|
-
return this.refresh(key).unproven;
|
|
502
|
-
}
|
|
503
439
|
|
|
504
440
|
/**
|
|
505
441
|
* Where a segment is, or null when this output does not hold it.
|
|
@@ -542,10 +478,34 @@ export class SegmentStore {
|
|
|
542
478
|
this.#held.delete(key);
|
|
543
479
|
this.#formats.delete(key);
|
|
544
480
|
this.#touched.delete(key);
|
|
545
|
-
this.#closed.delete(key);
|
|
546
481
|
this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
|
|
547
482
|
}
|
|
548
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Throw away everything this store owns, and the root with it.
|
|
486
|
+
*
|
|
487
|
+
* For a clean exit. What is left on disk afterwards is by definition from a
|
|
488
|
+
* kill, which is the case the startup sweep exists for — and without this the
|
|
489
|
+
* sweep adopts, the exit leaves, and the next start adopts again, for ever.
|
|
490
|
+
*
|
|
491
|
+
* @param {string} because
|
|
492
|
+
* @returns {number} How many outputs went.
|
|
493
|
+
*/
|
|
494
|
+
dropAll(because) {
|
|
495
|
+
let dropped = 0;
|
|
496
|
+
for (const key of [...this.#formats.keys()]) {
|
|
497
|
+
this.drop(key, because);
|
|
498
|
+
dropped += 1;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
rmSync(this.#root, { recursive: true, force: true });
|
|
502
|
+
} catch {
|
|
503
|
+
// Another process may share the root and hold a directory open. What is
|
|
504
|
+
// ours is gone either way.
|
|
505
|
+
}
|
|
506
|
+
return dropped;
|
|
507
|
+
}
|
|
508
|
+
|
|
549
509
|
/**
|
|
550
510
|
* Keep only what is still being read, and only as much of it as there is room
|
|
551
511
|
* for.
|
|
@@ -561,13 +521,34 @@ export class SegmentStore {
|
|
|
561
521
|
* that is the cap's — but to stop an output nobody has touched in hours from
|
|
562
522
|
* sitting there for the life of the process.
|
|
563
523
|
*
|
|
524
|
+
* TWO RULES, ANSWERING TWO QUESTIONS. Kept apart because they were briefly
|
|
525
|
+
* proposed as one and that was wrong: material nobody needs should not sit on
|
|
526
|
+
* the owner's disk merely because there is room for it, and material everyone
|
|
527
|
+
* needs must still go when there is no room. The first is time, the second is
|
|
528
|
+
* space.
|
|
529
|
+
*
|
|
530
|
+
* WHAT GOES FIRST WHEN THERE IS NO ROOM is decided by where the viewers are,
|
|
531
|
+
* not by when a directory was last read. Behind every viewer of an output is
|
|
532
|
+
* material that has been played and will not be asked for again unless
|
|
533
|
+
* somebody seeks back; ahead of the furthest viewer is material that will be
|
|
534
|
+
* asked for, eventually. So the order is: outputs nobody is watching at all,
|
|
535
|
+
* then what lies behind the earliest viewer, furthest behind first, then what
|
|
536
|
+
* lies ahead of the furthest viewer, furthest ahead first. It is the priority
|
|
537
|
+
* map's own order read from the other end.
|
|
538
|
+
*
|
|
539
|
+
* A segment a viewer is standing on is never a victim.
|
|
540
|
+
*
|
|
564
541
|
* @param {object} params
|
|
565
542
|
* @param {number} params.idleMs - Untouched for longer than this, and it goes.
|
|
566
|
-
* @param {number} params.maxBytes - The most the whole store may hold.
|
|
567
|
-
*
|
|
568
|
-
*
|
|
543
|
+
* @param {number} params.maxBytes - The most the whole store may hold.
|
|
544
|
+
* @param {(key: string) => number[]} [params.viewersAt] - Where the viewers of
|
|
545
|
+
* an output stand, as segment numbers. An empty answer means nobody is
|
|
546
|
+
* watching it, which is what makes its segments the first to go. Absent, the
|
|
547
|
+
* store has nothing to order by and falls back to the oldest directory —
|
|
548
|
+
* which is what it did before it could be told.
|
|
549
|
+
* @returns {{ droppedIdle: number, droppedForRoom: number, segmentsRemoved: number, bytes: number }}
|
|
569
550
|
*/
|
|
570
|
-
enforce({ idleMs, maxBytes }) {
|
|
551
|
+
enforce({ idleMs, maxBytes, viewersAt = null }) {
|
|
571
552
|
const now = this.#now();
|
|
572
553
|
let droppedIdle = 0;
|
|
573
554
|
for (const [key, touchedAt] of [...this.#touched]) {
|
|
@@ -576,11 +557,12 @@ export class SegmentStore {
|
|
|
576
557
|
droppedIdle += 1;
|
|
577
558
|
}
|
|
578
559
|
}
|
|
579
|
-
let droppedForRoom = 0;
|
|
580
560
|
let held = this.stats().bytes;
|
|
581
|
-
if (Number.isFinite(maxBytes)
|
|
582
|
-
|
|
583
|
-
|
|
561
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0 || held <= maxBytes) {
|
|
562
|
+
return { droppedIdle, droppedForRoom: 0, segmentsRemoved: 0, bytes: held };
|
|
563
|
+
}
|
|
564
|
+
if (typeof viewersAt !== "function") {
|
|
565
|
+
let droppedForRoom = 0;
|
|
584
566
|
const byAge = [...this.#touched.entries()].sort((left, right) => left[1] - right[1]);
|
|
585
567
|
for (const [key] of byAge) {
|
|
586
568
|
if (held <= maxBytes) {
|
|
@@ -591,8 +573,81 @@ export class SegmentStore {
|
|
|
591
573
|
held -= size;
|
|
592
574
|
droppedForRoom += 1;
|
|
593
575
|
}
|
|
576
|
+
return { droppedIdle, droppedForRoom, segmentsRemoved: 0, bytes: held };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
let segmentsRemoved = 0;
|
|
580
|
+
for (const victim of this.#leastWantedFirst(viewersAt)) {
|
|
581
|
+
if (held <= maxBytes) {
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
held -= this.#removeSegment(victim.key, victim.index);
|
|
585
|
+
segmentsRemoved += 1;
|
|
586
|
+
}
|
|
587
|
+
if (segmentsRemoved > 0) {
|
|
588
|
+
this.#logger.info(
|
|
589
|
+
`segment-store removed ${segmentsRemoved} segment(s) for room: ` +
|
|
590
|
+
`${megabytes(held)} of ${megabytes(maxBytes)} allowed`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
return { droppedIdle, droppedForRoom: 0, segmentsRemoved, bytes: held };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Every segment in the store, least wanted first.
|
|
598
|
+
*
|
|
599
|
+
* @param {(key: string) => number[]} viewersAt
|
|
600
|
+
* @returns {{ key: string, index: number }[]}
|
|
601
|
+
*/
|
|
602
|
+
#leastWantedFirst(viewersAt) {
|
|
603
|
+
const candidates = [];
|
|
604
|
+
for (const key of this.#formats.keys()) {
|
|
605
|
+
const positions = (viewersAt(key) ?? []).filter((at) => Number.isInteger(at));
|
|
606
|
+
const earliest = positions.length > 0 ? Math.min(...positions) : null;
|
|
607
|
+
const furthest = positions.length > 0 ? Math.max(...positions) : null;
|
|
608
|
+
for (const index of this.refresh(key).byNumber.keys()) {
|
|
609
|
+
if (earliest === null) {
|
|
610
|
+
// Nobody is watching this output at all. Everything it holds is worth
|
|
611
|
+
// less than anything somebody is on their way to.
|
|
612
|
+
candidates.push({ key, index, rank: 0, distance: index });
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (positions.includes(index)) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (index < earliest) {
|
|
619
|
+
candidates.push({ key, index, rank: 1, distance: earliest - index });
|
|
620
|
+
} else {
|
|
621
|
+
candidates.push({ key, index, rank: 2, distance: index - /** @type {number} */ (furthest) });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
594
624
|
}
|
|
595
|
-
return
|
|
625
|
+
return candidates
|
|
626
|
+
.sort((left, right) => (left.rank !== right.rank ? left.rank - right.rank : right.distance - left.distance))
|
|
627
|
+
.map(({ key, index }) => ({ key, index }));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Take one segment off the disk.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} key
|
|
634
|
+
* @param {number} index
|
|
635
|
+
* @returns {number} What it weighed.
|
|
636
|
+
*/
|
|
637
|
+
#removeSegment(key, index) {
|
|
638
|
+
const full = this.refresh(key).byNumber.get(index);
|
|
639
|
+
if (!full) {
|
|
640
|
+
return 0;
|
|
641
|
+
}
|
|
642
|
+
let size = 0;
|
|
643
|
+
try {
|
|
644
|
+
size = statSync(full, { throwIfNoEntry: false })?.size ?? 0;
|
|
645
|
+
rmSync(full, { force: true });
|
|
646
|
+
} catch {
|
|
647
|
+
// Gone already, or refused. The next refresh reports what is really there.
|
|
648
|
+
}
|
|
649
|
+
this.#held.delete(key);
|
|
650
|
+
return size;
|
|
596
651
|
}
|
|
597
652
|
|
|
598
653
|
/**
|
|
@@ -712,32 +767,29 @@ export class SegmentStore {
|
|
|
712
767
|
continue;
|
|
713
768
|
}
|
|
714
769
|
this.#formats.set(entry.key, format);
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const filePath = held.byNumber.get(unproven);
|
|
725
|
-
if (filePath) {
|
|
726
|
-
try {
|
|
727
|
-
rmSync(filePath, { force: true });
|
|
728
|
-
unprovenRemoved += 1;
|
|
729
|
-
} catch {
|
|
730
|
-
// Then it stays unproven and is simply never served.
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
this.#held.delete(entry.key);
|
|
734
|
-
}
|
|
770
|
+
// EVERY SEGMENT FOUND IS COMPLETE, because a piece is given its served
|
|
771
|
+
// name only once the encoder has said it is closed. So there is nothing to
|
|
772
|
+
// prove here and nothing to un-prove: what the directory holds under
|
|
773
|
+
// served names is what a killed process finished.
|
|
774
|
+
//
|
|
775
|
+
// What it may also hold is pieces it was in the middle of, under their
|
|
776
|
+
// working names, and those are swept — the file a run was writing when the
|
|
777
|
+
// kernel took the process is exactly this.
|
|
778
|
+
unprovenRemoved += this.#sweepUnfinished(entry.key, entry.dir, format);
|
|
735
779
|
adopted += 1;
|
|
736
780
|
this.#logger.info(
|
|
737
781
|
`segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
|
|
738
|
-
|
|
782
|
+
"segments a killed process had already finished"
|
|
739
783
|
);
|
|
740
784
|
}
|
|
741
785
|
return { adopted, dropped, unprovenRemoved };
|
|
742
786
|
}
|
|
743
787
|
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* @param {number} bytes
|
|
791
|
+
* @returns {string}
|
|
792
|
+
*/
|
|
793
|
+
function megabytes(bytes) {
|
|
794
|
+
return `${Math.round(Math.max(0, bytes) / (1024 * 1024))}MB`;
|
|
795
|
+
}
|