@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
|
@@ -703,7 +703,22 @@ export function buildRunCommand({
|
|
|
703
703
|
"-segment_list_flags",
|
|
704
704
|
"+live",
|
|
705
705
|
...explicitTimes,
|
|
706
|
-
|
|
706
|
+
// UNDER A WORKING NAME, not the one it is served as. A piece under its
|
|
707
|
+
// served name is complete by construction then, whoever else is writing
|
|
708
|
+
// into the same directory — and the name arrives on the channel above the
|
|
709
|
+
// instant ffmpeg closes it, which is what turns it into the served one.
|
|
710
|
+
//
|
|
711
|
+
// The other branch needs none of this: the HLS muxer writes through a
|
|
712
|
+
// temporary name of its own (`+temp_file`), so its files appear under
|
|
713
|
+
// their final name whole.
|
|
714
|
+
//
|
|
715
|
+
// TAGGED WITH THE STRETCH IT WAS GIVEN, which names the run without any
|
|
716
|
+
// counter to keep: intervals never overlap, so two live runs of one output
|
|
717
|
+
// begin at different numbers by construction. That is what makes clearing
|
|
718
|
+
// up after a dead run a well-formed question — its unfinished pieces are
|
|
719
|
+
// the ones carrying its own tag — where before it was answered by taking
|
|
720
|
+
// the highest SERVED name inside its stretch and judging the bytes.
|
|
721
|
+
segmentFormat.makingFileNameTemplate(String(safeIndex))
|
|
707
722
|
);
|
|
708
723
|
} else {
|
|
709
724
|
args.push(
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createReadStream, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { access,
|
|
11
|
+
import { access, readFile, rm, stat, unlink } from "node:fs/promises";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
@@ -70,7 +70,6 @@ import { Output, Outputs } from "./output/Output.js";
|
|
|
70
70
|
import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./output/playlists.js";
|
|
71
71
|
import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
|
|
72
72
|
import { ProducedIndex } from "./produced-index.js";
|
|
73
|
-
import { discardOpenPiece } from "./encode/open-piece.js";
|
|
74
73
|
import { SegmentStore } from "./encode/SegmentStore.js";
|
|
75
74
|
import { EncodeCost } from "./quality/EncodeCost.js";
|
|
76
75
|
import {
|
|
@@ -89,11 +88,13 @@ import {
|
|
|
89
88
|
// — did not move when the code did.
|
|
90
89
|
export { ffmpegSeconds, onKeyframeGridFor, seekLandingOffsetFor, segmentCutTimesFrom };
|
|
91
90
|
import { viewersOf } from "./viewer/Viewer.js";
|
|
91
|
+
import { viewerSegmentsOn } from "./viewer/positions.js";
|
|
92
92
|
import { Viewers } from "./viewer/Viewers.js";
|
|
93
93
|
import { LiveOutputs } from "./output/LiveOutputs.js";
|
|
94
94
|
import { variantHeightsFor } from "./output/ladder.js";
|
|
95
95
|
import { EncodeOrchestrator } from "./orchestrators/EncodeOrchestrator.js";
|
|
96
96
|
import { readDiskFree } from "./memory-report.js";
|
|
97
|
+
import { wireDiskSpace } from "./disk/wire.js";
|
|
97
98
|
|
|
98
99
|
/**
|
|
99
100
|
* Whether an encoder run died because its INPUT went away, rather than because
|
|
@@ -425,10 +426,6 @@ const TRUE_START_MEMORY = 200;
|
|
|
425
426
|
// twice. It runs on the restart path and a session an hour in has thousands of
|
|
426
427
|
// segments; the figure is for a comparison, not an inventory.
|
|
427
428
|
const BACKWARD_RESTART_SCAN_SEGMENTS = 300;
|
|
428
|
-
// Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
|
|
429
|
-
// escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
|
|
430
|
-
// the same session directory. See #startEncodeRun.
|
|
431
|
-
const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
|
|
432
429
|
// A seek-restart run that exits this fast never did real work — it failed at
|
|
433
430
|
// the seek/open step itself (container demux error, bad audio frame boundary,
|
|
434
431
|
// etc.), not mid-stream. Used to tell a genuine seek failure apart from a
|
|
@@ -478,18 +475,6 @@ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
|
478
475
|
* there for the life of the process.
|
|
479
476
|
*/
|
|
480
477
|
const SEGMENT_STORE_IDLE_MS = 6 * 60 * 60 * 1000;
|
|
481
|
-
/**
|
|
482
|
-
* The share of FREE disk the produced segments may take.
|
|
483
|
-
*
|
|
484
|
-
* A share of what is free NOW, re-read on every sweep, for the same reason the
|
|
485
|
-
* piece store re-derives its memory allowance every minute: a machine that
|
|
486
|
-
* fills up after this proxy started would otherwise go on spending an allowance
|
|
487
|
-
* taken when it was empty. A Home Assistant install often runs from a 32 GB
|
|
488
|
-
* card carrying everything else in the house.
|
|
489
|
-
*/
|
|
490
|
-
const SEGMENT_STORE_FREE_SHARE = 0.25;
|
|
491
|
-
/** What the store may hold where the free space cannot be read at all. */
|
|
492
|
-
const SEGMENT_STORE_FALLBACK_BYTES = 2 * 1024 * 1024 * 1024;
|
|
493
478
|
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
494
479
|
// Realtime budget — runtime downswitch (software encoder only). Periodically
|
|
495
480
|
// check each active software-transcode session's ffmpeg `speed`; when it stays
|
|
@@ -1609,6 +1594,13 @@ export class HlsSessionManager {
|
|
|
1609
1594
|
void this.cleanupExpired();
|
|
1610
1595
|
}, CLEANUP_INTERVAL_MS);
|
|
1611
1596
|
this.cleanupTimer.unref();
|
|
1597
|
+
// One owner of the disk, and the list of what takes it lives with the owner.
|
|
1598
|
+
this.diskSpace = wireDiskSpace({
|
|
1599
|
+
segmentStore: this.segmentStore,
|
|
1600
|
+
torrentPool: this.torrentPool,
|
|
1601
|
+
readFree: readDiskFree,
|
|
1602
|
+
logger
|
|
1603
|
+
});
|
|
1612
1604
|
// Realtime-budget monitor: only meaningful for the software encoder with a
|
|
1613
1605
|
// benchmark (the only path that can pick/step resolution). Cheap no-op scan
|
|
1614
1606
|
// otherwise.
|
|
@@ -5535,7 +5527,10 @@ export class HlsSessionManager {
|
|
|
5535
5527
|
inputUnavailable: (message) => isInputUnavailable(message),
|
|
5536
5528
|
onProgress: (report) => this.#noteRunProgress(session, run, report),
|
|
5537
5529
|
indexOfName: (name) => session.segmentFormat.segmentIndexFromName(name),
|
|
5538
|
-
|
|
5530
|
+
// Why this encoder exists, recorded with its argument list. It used to be
|
|
5531
|
+
// handed to a separate `start` call; there is no separate call now.
|
|
5532
|
+
because,
|
|
5533
|
+
onClosed: (name) => this.segmentStore.publish(session.outputKey ?? "", name, session.segmentFormat),
|
|
5539
5534
|
onEnded: (ended) => this.noteRunEnded(session, run, ended)
|
|
5540
5535
|
});
|
|
5541
5536
|
session.runs.add(run);
|
|
@@ -5548,8 +5543,6 @@ export class HlsSessionManager {
|
|
|
5548
5543
|
// premature downscale.
|
|
5549
5544
|
session.budgetSlowSince = 0;
|
|
5550
5545
|
|
|
5551
|
-
run.start(because);
|
|
5552
|
-
|
|
5553
5546
|
logger.info(
|
|
5554
5547
|
`transcode ${session.id} encode-run #${safeIndex}..#${runEnd} from segment #${safeIndex} ` +
|
|
5555
5548
|
`(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
|
|
@@ -7692,31 +7685,15 @@ export class HlsSessionManager {
|
|
|
7692
7685
|
// disposed. A run that had already finished or failed is not among them,
|
|
7693
7686
|
// which is what keeps a stop from erasing how it actually ended.
|
|
7694
7687
|
for (const run of running) {
|
|
7695
|
-
const ffmpeg = run.process;
|
|
7696
|
-
// The stretch it was given, read now rather than when the process finally
|
|
7697
|
-
// exits: by then the session may have started another run with another
|
|
7698
|
-
// stretch, and the piece to discard belongs to this one.
|
|
7699
|
-
const stoppedSpan = {
|
|
7700
|
-
from: Number.isInteger(run.from) ? run.from : 0,
|
|
7701
|
-
to: Number.isInteger(run.to) ? run.to : -1
|
|
7702
|
-
};
|
|
7703
7688
|
// The run resumes itself if it was suspended — a stopped process does not
|
|
7704
7689
|
// act on SIGTERM until it is continued — records the cause, and answers
|
|
7705
7690
|
// its own exit. Nothing here has to null a field so that the exit is read
|
|
7706
7691
|
// correctly, because there is no shared field left to misread.
|
|
7707
7692
|
run.stop(reason);
|
|
7708
|
-
//
|
|
7709
|
-
//
|
|
7710
|
-
//
|
|
7711
|
-
//
|
|
7712
|
-
// WHAT THE RUN ITSELF NAMED, which is the only thing that can say a piece
|
|
7713
|
-
// is whole — a piece cut short still decodes. Written 2026-09-06 and never
|
|
7714
|
-
// passed from here, so the clearing-up ran with nothing proven and fell
|
|
7715
|
-
// back to bounding itself by the run's declared stretch alone.
|
|
7716
|
-
const provenName = typeof run.provenName === "string" ? run.provenName : null;
|
|
7717
|
-
void waitForChildExit(ffmpeg, ENCODE_RUN_TERMINATE_GRACE_MS).then(() =>
|
|
7718
|
-
this.#discardUnfinishedPiece(session, session.dirPath, stoppedSpan, provenName)
|
|
7719
|
-
);
|
|
7693
|
+
// Nothing is cleared up from here. What this run left open is under its
|
|
7694
|
+
// own working name, and the encoding layer removes it when the run's
|
|
7695
|
+
// ending reaches it — one place, and it needs neither the stretch nor the
|
|
7696
|
+
// init bytes this method used to fetch to judge a file by its contents.
|
|
7720
7697
|
}
|
|
7721
7698
|
logger.info(`transcode ${session.id} ${running.length} encoder(s) stopped: ${reason}`);
|
|
7722
7699
|
}
|
|
@@ -9589,63 +9566,6 @@ export class HlsSessionManager {
|
|
|
9589
9566
|
return this.#producedIndex(session).pathOf(fileName);
|
|
9590
9567
|
}
|
|
9591
9568
|
|
|
9592
|
-
/**
|
|
9593
|
-
* Throw away the piece a run was in the middle of when it ended.
|
|
9594
|
-
*
|
|
9595
|
-
* The `segment` muxer creates its output file when it opens it and writes
|
|
9596
|
-
* into it until the next cut, so at any instant exactly one file in a run's
|
|
9597
|
-
* directory is unfinished: the highest-numbered one. A run that reaches the
|
|
9598
|
-
* end of its work closes that file properly and it is a good piece; a run
|
|
9599
|
-
* killed for a seek does not — measured 2026-09-03, ffmpeg exited 19 ms after
|
|
9600
|
-
* SIGTERM and left `segment-00025.mp4` at zero bytes.
|
|
9601
|
-
*
|
|
9602
|
-
* Leaving it is what created the deadlock this method exists to prevent: an
|
|
9603
|
-
* empty file has a name like any other, so it closed the only hole in the
|
|
9604
|
-
* numbering and the look-ahead kept the encoder stopped for having "produced"
|
|
9605
|
-
* it. Removing it at the moment the run ends means the question never has to
|
|
9606
|
-
* be asked again by anyone.
|
|
9607
|
-
*
|
|
9608
|
-
* A piece is removed only when it is unusable. A run that finished its last
|
|
9609
|
-
* file — the ordinary end of a file, or a stop that arrived between two cuts
|
|
9610
|
-
* — has nothing wrong with it, and deleting good output would cost the work
|
|
9611
|
-
* of making it twice.
|
|
9612
|
-
*
|
|
9613
|
-
* @param {HlsSession} session
|
|
9614
|
-
* @param {string | null | undefined} runDirPath
|
|
9615
|
-
* @returns {Promise<void>}
|
|
9616
|
-
*/
|
|
9617
|
-
async #discardUnfinishedPiece(session, runDirPath, within = null, provenName = null) {
|
|
9618
|
-
const canJudgeTracks =
|
|
9619
|
-
typeof session.segmentFormat?.hasEveryTrack === "function" &&
|
|
9620
|
-
session.initBytes &&
|
|
9621
|
-
session.initBytes.length > 0;
|
|
9622
|
-
const removed = await discardOpenPiece(
|
|
9623
|
-
runDirPath,
|
|
9624
|
-
session.segmentFormat,
|
|
9625
|
-
within,
|
|
9626
|
-
canJudgeTracks
|
|
9627
|
-
? (raw) => {
|
|
9628
|
-
const bytes = cutsAtGivenTimes(session) && session.segmentFormat.stripInit
|
|
9629
|
-
? session.segmentFormat.stripInit(raw)
|
|
9630
|
-
: raw;
|
|
9631
|
-
return session.segmentFormat.hasEveryTrack(bytes, session.initBytes);
|
|
9632
|
-
}
|
|
9633
|
-
: null,
|
|
9634
|
-
provenName
|
|
9635
|
-
);
|
|
9636
|
-
if (removed !== null) {
|
|
9637
|
-
// Removed on purpose, so the index must not go on answering with it.
|
|
9638
|
-
this.#producedIndex(session).invalidate();
|
|
9639
|
-
logger.info(
|
|
9640
|
-
`transcode ${session.id} discarded segment #${removed}: ` +
|
|
9641
|
-
"the run ended while it was open, so it holds no usable piece"
|
|
9642
|
-
);
|
|
9643
|
-
}
|
|
9644
|
-
}
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
9569
|
#holdForProduction(session, fileName, isPlaylist, options) {
|
|
9650
9570
|
/** @type {{ address: string, rank: number, topRank: number } | null} */
|
|
9651
9571
|
let ranked = null;
|
|
@@ -9745,32 +9665,22 @@ export class HlsSessionManager {
|
|
|
9745
9665
|
// The segments outlive every session on them, so what they cost is decided
|
|
9746
9666
|
// here rather than by anybody's departure: how long ago each output was
|
|
9747
9667
|
// last read, and how much room the disk has for the lot.
|
|
9668
|
+
// The room is the disk owner's to divide; this asks what the share is now.
|
|
9669
|
+
await this.diskSpace.revise();
|
|
9748
9670
|
this.segmentStore.enforce({
|
|
9749
9671
|
idleMs: SEGMENT_STORE_IDLE_MS,
|
|
9750
|
-
maxBytes:
|
|
9672
|
+
maxBytes: this.diskSpace.segmentBytes(),
|
|
9673
|
+
viewersAt: (key) =>
|
|
9674
|
+
viewerSegmentsOn({
|
|
9675
|
+
sessions: this.sessionsById.values(),
|
|
9676
|
+
outputKey: key,
|
|
9677
|
+
segmentAt: (session, seconds) => this.#segmentIndexForTime(session, seconds),
|
|
9678
|
+
now: Date.now(),
|
|
9679
|
+
staleAfterMs: this.presenceStaleAfterMs()
|
|
9680
|
+
})
|
|
9751
9681
|
});
|
|
9752
9682
|
}
|
|
9753
9683
|
|
|
9754
|
-
/**
|
|
9755
|
-
* How much disk the produced segments may hold.
|
|
9756
|
-
*
|
|
9757
|
-
* A share of what is FREE now rather than a figure fixed at startup, for the
|
|
9758
|
-
* same reason the piece store's memory allowance is re-derived every minute:
|
|
9759
|
-
* a machine that fills up after this proxy started would otherwise go on
|
|
9760
|
-
* spending an allowance taken when it was empty. On a Home Assistant install
|
|
9761
|
-
* that disk is often a 32 GB card carrying everything else the household
|
|
9762
|
-
* runs.
|
|
9763
|
-
*
|
|
9764
|
-
* @returns {Promise<number>}
|
|
9765
|
-
*/
|
|
9766
|
-
async #segmentStoreAllowance() {
|
|
9767
|
-
const free = await readDiskFree(this.segmentStore.root);
|
|
9768
|
-
if (!Number.isFinite(free) || free <= 0) {
|
|
9769
|
-
return SEGMENT_STORE_FALLBACK_BYTES;
|
|
9770
|
-
}
|
|
9771
|
-
return Math.max(SEGMENT_STORE_FALLBACK_BYTES, Math.floor(free * SEGMENT_STORE_FREE_SHARE));
|
|
9772
|
-
}
|
|
9773
|
-
|
|
9774
9684
|
/**
|
|
9775
9685
|
* Return a progress snapshot for the given session, or `null` if not found.
|
|
9776
9686
|
* Also refreshes `lastAccessedAt` to prevent the session from expiring.
|
|
@@ -10156,14 +10066,7 @@ export class HlsSessionManager {
|
|
|
10156
10066
|
for (const sessionId of activeIds) {
|
|
10157
10067
|
await this.disposeSession(sessionId);
|
|
10158
10068
|
}
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
const dirs = await readdir(rootDir);
|
|
10162
|
-
if (dirs.length === 0) {
|
|
10163
|
-
await rm(rootDir, { recursive: true, force: true });
|
|
10164
|
-
}
|
|
10165
|
-
} catch (_error) {
|
|
10166
|
-
// Best effort cleanup.
|
|
10167
|
-
}
|
|
10069
|
+
// Everything this process owns, root included. See SegmentStore.dropAll.
|
|
10070
|
+
this.segmentStore.dropAll("the proxy is shutting down");
|
|
10168
10071
|
}
|
|
10169
10072
|
}
|
|
@@ -273,11 +273,10 @@ export class EncodeOrchestrator {
|
|
|
273
273
|
* @param {number} index
|
|
274
274
|
*/
|
|
275
275
|
noteProduced(address, index) {
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
this.segmentStore?.markClosed(address, index);
|
|
276
|
+
// Nothing is told to the store: by the time this is called the piece is
|
|
277
|
+
// ALREADY under its served name, because the rename is what closing it
|
|
278
|
+
// means. A statement kept beside the disk would be a second owner of one
|
|
279
|
+
// fact, which is what item 87 removed from the coverage map.
|
|
281
280
|
this.coverageOf(address).markReady(index);
|
|
282
281
|
for (const run of this.runsOn(address)) {
|
|
283
282
|
run.noteProduced(index);
|
|
@@ -375,6 +374,23 @@ export class EncodeOrchestrator {
|
|
|
375
374
|
// when the reason it cuts the budget changes, so asking it three times in
|
|
376
375
|
// one pass is three chances to say a thing that happened once.
|
|
377
376
|
const maxRuns = this.#affordableOn(address, live);
|
|
377
|
+
// THE TERMS EVERY ARRIVAL IS COMPUTED FROM, named here so the line below can
|
|
378
|
+
// print them. A decision of this plan is `delay + (index - at) / rate +
|
|
379
|
+
// madeBetween * refetch` against a deadline, so without the rate and the two
|
|
380
|
+
// prices no recorded decision can be reproduced — which is what happened
|
|
381
|
+
// with the one-piece intervals of 2026-09-08: the rate was substituted six
|
|
382
|
+
// times from the speeds the session reported elsewhere and none of them gave
|
|
383
|
+
// the answer the plan had given.
|
|
384
|
+
const costs = this.#costs.seconds();
|
|
385
|
+
const refetchSecPerFilmSecond = this.refetchSecPerFilmSecond(address);
|
|
386
|
+
// The best figure this host has: what a run here is doing now, what one was
|
|
387
|
+
// last measured doing, or what the startup benchmark predicted. The first
|
|
388
|
+
// two are this output's own; the third exists before either.
|
|
389
|
+
const speedX = Math.max(
|
|
390
|
+
live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
|
|
391
|
+
this.#lastSpeed.get(address) ?? 0,
|
|
392
|
+
this.startingSpeedFor(address) || 0
|
|
393
|
+
);
|
|
378
394
|
const actions = planEncoders({
|
|
379
395
|
coverage,
|
|
380
396
|
windows,
|
|
@@ -387,26 +403,19 @@ export class EncodeOrchestrator {
|
|
|
387
403
|
// What a start and a kill cost, measured from this host's own runs rather
|
|
388
404
|
// than written into the code from one machine's reading. Zero until
|
|
389
405
|
// something has been measured, which is the same convention as the
|
|
390
|
-
// refetch price
|
|
391
|
-
...
|
|
406
|
+
// refetch price and is stated so the bias is known.
|
|
407
|
+
...costs,
|
|
392
408
|
// What a second of film costs to fetch again, in seconds of swarm time.
|
|
393
409
|
// Answered by whoever measures the film's own byte rate and the swarm's;
|
|
394
410
|
// zero until they have, which makes driving through look cheaper than it
|
|
395
411
|
// is and is stated here so the bias is known.
|
|
396
|
-
refetchSecPerFilmSecond
|
|
412
|
+
refetchSecPerFilmSecond,
|
|
397
413
|
// How much slower one encoder runs beside others, read off this host's own
|
|
398
414
|
// startup measurement. A pure function over a measured table: beyond what
|
|
399
415
|
// was measured it holds the largest reading rather than continuing a curve
|
|
400
416
|
// nothing observed.
|
|
401
417
|
contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
|
|
402
|
-
|
|
403
|
-
// was last measured doing, or what the startup benchmark predicted. The
|
|
404
|
-
// first two are this output's own; the third exists before either.
|
|
405
|
-
speedX: Math.max(
|
|
406
|
-
live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
|
|
407
|
-
this.#lastSpeed.get(address) ?? 0,
|
|
408
|
-
this.startingSpeedFor(address) || 0
|
|
409
|
-
)
|
|
418
|
+
speedX
|
|
410
419
|
});
|
|
411
420
|
|
|
412
421
|
// A move is the plan taking a running encoder away from where it already
|
|
@@ -427,10 +436,21 @@ export class EncodeOrchestrator {
|
|
|
427
436
|
// exiting, twelve of them normally — and the reasons printed beside them
|
|
428
437
|
// read as moves, so the fault was diagnosed three times as something it was
|
|
429
438
|
// not. An interval is what a run is, and it was the one thing missing.
|
|
439
|
+
//
|
|
440
|
+
// AND THE TERMS, for the same reason one step further: an interval says what
|
|
441
|
+
// was decided and the terms say why. `speed` is what every arrival is
|
|
442
|
+
// divided by, so a decision recorded without it can be re-read and not
|
|
443
|
+
// recomputed; `firstByte` and `kill` are what a start and a stop cost here;
|
|
444
|
+
// `refetch` is what a second of film costs to fetch again. A zero in the
|
|
445
|
+
// last three is a measurement nobody has taken, not a free operation, and it
|
|
446
|
+
// is printed so that reading it as free is a choice rather than an accident.
|
|
430
447
|
if (actions.some((action) => action.type !== "keep")) {
|
|
431
448
|
this.logger.info(
|
|
432
449
|
`encode-plan on ${address}: ` +
|
|
433
|
-
`${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}`
|
|
450
|
+
`${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}` +
|
|
451
|
+
` [speed=${speedX.toFixed(2)}x firstByte=${costs.firstByteWaitSec.toFixed(2)}s ` +
|
|
452
|
+
`kill=${costs.killCostSec.toFixed(2)}s refetch=${refetchSecPerFilmSecond.toFixed(3)}s/s ` +
|
|
453
|
+
`maxRuns=${maxRuns} live=${live.length}]`
|
|
434
454
|
);
|
|
435
455
|
}
|
|
436
456
|
if (actions.some((action) => action.type === "move")) {
|
|
@@ -547,18 +567,16 @@ export class EncodeOrchestrator {
|
|
|
547
567
|
const onThisOutput = this.#runs.get(address) ?? [];
|
|
548
568
|
onThisOutput.push(run);
|
|
549
569
|
this.#runs.set(address, onThisOutput);
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
this.
|
|
560
|
-
this.coverageOf(address).claim(run, from, runsTo);
|
|
561
|
-
run.start(because);
|
|
570
|
+
// Nothing has to be un-proved when a run takes a stretch that has already
|
|
571
|
+
// been written. A piece is only ever NAMED as served once it is closed, and
|
|
572
|
+
// a run writing that number again writes under a working name until it
|
|
573
|
+
// closes its own — so the file standing there is a complete piece made by
|
|
574
|
+
// somebody, and serving it is right until the newer one replaces it whole.
|
|
575
|
+
// The claim comes after the run exists, and that is not a race: everything
|
|
576
|
+
// between is synchronous, and a process cannot say a word before the next
|
|
577
|
+
// tick. What it removes is the second act — building a run and starting it
|
|
578
|
+
// were two steps, and two owners each performed the second one.
|
|
579
|
+
this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
|
|
562
580
|
}
|
|
563
581
|
|
|
564
582
|
/**
|
|
@@ -654,15 +672,11 @@ export class EncodeOrchestrator {
|
|
|
654
672
|
this.#costs.note(ended);
|
|
655
673
|
// Exactly one ending is normal — the run reached the end of the stretch it
|
|
656
674
|
// was given and closed its last file. Every other leaves a piece open, and
|
|
657
|
-
// that
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
void this.segmentStore
|
|
663
|
-
.discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
|
|
664
|
-
.catch(() => {});
|
|
665
|
-
}
|
|
675
|
+
// that piece is under this run's OWN working name, so clearing up after it
|
|
676
|
+
// is a name match: no stretch to search and no bytes to judge. Done for
|
|
677
|
+
// every ending, the normal one included, since a run that finished cleanly
|
|
678
|
+
// has nothing under a working name and the sweep then removes nothing.
|
|
679
|
+
this.segmentStore?.clearUpAfter(ended.address, ended.from);
|
|
666
680
|
this.coverageOf(ended.address).release(ended.run);
|
|
667
681
|
const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
|
|
668
682
|
if (remaining.length === 0) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How much of a machine's resource the stores may hold between them.
|
|
3
|
+
*
|
|
4
|
+
* Written once and used twice, because memory and disk are the same question
|
|
5
|
+
* asked of two resources: what is free now, plus what we already hold, less
|
|
6
|
+
* what everything that is not us has recently been seen to need.
|
|
7
|
+
*
|
|
8
|
+
* It used to exist only for memory, inside the memory store, keyed on module
|
|
9
|
+
* state. Disk had no such rule at all — the spill file grew until the machine
|
|
10
|
+
* did, 14.4 GB in a single viewing on 2026-08-31 — and copying the memory rule
|
|
11
|
+
* across would have made two rules to keep in step. One rule, two readings.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How many observations of other processes' demand are kept.
|
|
16
|
+
*
|
|
17
|
+
* A window rather than a high-water: a single spike would otherwise stand for
|
|
18
|
+
* the life of the process and hold the stores down long after whatever caused
|
|
19
|
+
* it had finished.
|
|
20
|
+
*/
|
|
21
|
+
const OTHER_DEMAND_SAMPLES = 60;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What everything that is not us has recently been seen to need.
|
|
25
|
+
*
|
|
26
|
+
* One instance per resource. A fall in what is free that we did not cause is a
|
|
27
|
+
* measurement of somebody else's demand; a fall we did cause is our own doing
|
|
28
|
+
* and says nothing about the machine.
|
|
29
|
+
*/
|
|
30
|
+
export class OtherDemand {
|
|
31
|
+
/** @type {number[]} */
|
|
32
|
+
#falls = [];
|
|
33
|
+
|
|
34
|
+
#lastFreeBytes = 0;
|
|
35
|
+
|
|
36
|
+
#lastHeldBytes = 0;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Take a reading, and answer what to reserve for others.
|
|
40
|
+
*
|
|
41
|
+
* @param {number} freeBytes - What the machine says is free now.
|
|
42
|
+
* @param {number} heldBytes - What the stores hold of this resource now.
|
|
43
|
+
* @returns {number} The reserve, in bytes.
|
|
44
|
+
*/
|
|
45
|
+
note(freeBytes, heldBytes) {
|
|
46
|
+
if (this.#lastFreeBytes > 0) {
|
|
47
|
+
const fell = this.#lastFreeBytes - freeBytes;
|
|
48
|
+
const ours = heldBytes - this.#lastHeldBytes;
|
|
49
|
+
this.#falls.push(Math.max(0, fell - ours));
|
|
50
|
+
if (this.#falls.length > OTHER_DEMAND_SAMPLES) {
|
|
51
|
+
this.#falls.shift();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
this.#lastFreeBytes = freeBytes;
|
|
55
|
+
this.#lastHeldBytes = heldBytes;
|
|
56
|
+
return this.reserve();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @returns {number} */
|
|
60
|
+
reserve() {
|
|
61
|
+
return this.#falls.length === 0 ? 0 : Math.max(...this.#falls);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Forget the readings. For tests, which share a module. */
|
|
65
|
+
forget() {
|
|
66
|
+
this.#falls = [];
|
|
67
|
+
this.#lastFreeBytes = 0;
|
|
68
|
+
this.#lastHeldBytes = 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How much of the resource the stores may hold between them.
|
|
74
|
+
*
|
|
75
|
+
* What the machine reports free is what could be taken ON TOP of what is
|
|
76
|
+
* already held, so the stores' own bytes are added back: the pair is the
|
|
77
|
+
* ceiling the stores could reach.
|
|
78
|
+
*
|
|
79
|
+
* @param {number} freeBytes
|
|
80
|
+
* @param {number} heldBytes
|
|
81
|
+
* @param {number} reserveBytes
|
|
82
|
+
* @returns {number}
|
|
83
|
+
*/
|
|
84
|
+
export function machineAllowanceBytes(freeBytes, heldBytes, reserveBytes) {
|
|
85
|
+
return Math.max(0, Math.max(freeBytes, 0) + Math.max(heldBytes, 0) - Math.max(reserveBytes, 0));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Divide what the machine allows between the stores, by what each is asking
|
|
90
|
+
* for.
|
|
91
|
+
*
|
|
92
|
+
* When everyone's ask fits, everyone gets it and the machine's limit never
|
|
93
|
+
* binds. When the asks do not fit, each store is cut in proportion to what it
|
|
94
|
+
* asked, so a store wanting little is not cut to make room for one wanting
|
|
95
|
+
* much.
|
|
96
|
+
*
|
|
97
|
+
* @param {number[]} wantedBytes - What each store is asking for, in order.
|
|
98
|
+
* @param {number} allowanceBytes
|
|
99
|
+
* @returns {number[]} What each store may hold, in the same order.
|
|
100
|
+
*/
|
|
101
|
+
export function divideAllowance(wantedBytes, allowanceBytes) {
|
|
102
|
+
const total = wantedBytes.reduce((sum, want) => sum + Math.max(0, want), 0);
|
|
103
|
+
if (total <= allowanceBytes || total === 0) {
|
|
104
|
+
return wantedBytes.map((want) => Math.max(0, want));
|
|
105
|
+
}
|
|
106
|
+
return wantedBytes.map((want) => Math.floor(allowanceBytes * (Math.max(0, want) / total)));
|
|
107
|
+
}
|