@torrent-tv/proxy 2.96.14 → 2.97.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/bin/cli.js +2 -0
- package/package.json +1 -1
- package/routes/encode-input/get.js +13 -0
- package/server.js +25 -3
- package/services/encode/EncodeInputs.js +60 -47
- package/services/encode/EncodeOrchestrator.js +29 -38
- package/services/encode/EncodeRun.js +26 -5
- package/services/encode/EncodeRuns.js +88 -48
- package/services/encode/OriginalInput.js +45 -1
- package/services/encode/RunClock.js +27 -0
- package/services/encode/run-costs.js +47 -2
- package/services/server/wire-outputs.js +3 -0
- package/services/storage/MachineBudget.js +19 -1
- package/services/storage/cpu-profile.js +68 -0
- package/services/storage/processor-report.js +186 -0
- package/services/torrent/worker/client.js +3 -2
- package/services/torrent/worker/pool-adapter.js +2 -2
- package/services/torrent/worker/worker.js +6 -2
- package/services/viewer/DownloadMaps.js +53 -6
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where this machine's processor goes: the proxy's own threads and every
|
|
3
|
+
* encoder it runs (torrent-tv/meta#177).
|
|
4
|
+
*
|
|
5
|
+
* The encoders are priced in seconds of work per second of film, and the
|
|
6
|
+
* torrent per megabyte moved. What neither price carries was seen only from
|
|
7
|
+
* outside: at a cold start on the addon host (#166 run 12, 2026-10-10) the main
|
|
8
|
+
* thread ran at 83-108 % of a core for thirty seconds, measured by a shell loop
|
|
9
|
+
* reading `/proc`, while the proxy's own log said nothing for 10.7 s.
|
|
10
|
+
*
|
|
11
|
+
* Read once a second, said once a minute as each part's mean and highest
|
|
12
|
+
* second, and every second while a cold start is being profiled. The Node
|
|
13
|
+
* process is counted ONCE, as `process.cpuUsage()` reports it; the split into
|
|
14
|
+
* threads only says where that time went. Each ffmpeg is its own process and
|
|
15
|
+
* is added beside it. Linux only for the split and for ffmpeg; elsewhere the
|
|
16
|
+
* process figure alone is said.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
20
|
+
|
|
21
|
+
/** Clock ticks per second of `/proc/<pid>/stat`, as in `encode/host-load.js`. */
|
|
22
|
+
const CLOCK_TICKS_PER_SECOND = 100;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The CPU seconds and the name in one `/proc/.../stat` text.
|
|
26
|
+
*
|
|
27
|
+
* The name can hold spaces and brackets, so it is everything between the first
|
|
28
|
+
* `(` and the LAST `)`, and the fields are counted from there.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @returns {{ name: string, seconds: number } | null}
|
|
32
|
+
*/
|
|
33
|
+
export function parseStat(text) {
|
|
34
|
+
const open = text.indexOf("(");
|
|
35
|
+
const close = text.lastIndexOf(")");
|
|
36
|
+
if (open < 0 || close < open) return null;
|
|
37
|
+
const after = text.slice(close + 2).trim().split(/\s+/);
|
|
38
|
+
// After the name and the state, utime is the 12th field and stime the 13th.
|
|
39
|
+
const utime = Number(after[11]);
|
|
40
|
+
const stime = Number(after[12]);
|
|
41
|
+
if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
|
|
42
|
+
return { name: text.slice(open + 1, close), seconds: (utime + stime) / CLOCK_TICKS_PER_SECOND };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which part of the proxy a thread belongs to: the main thread, the torrent
|
|
47
|
+
* thread (Node names a worker's thread `WorkerThread`), or the rest — V8's
|
|
48
|
+
* helpers, libuv's pool, the inspector — counted together.
|
|
49
|
+
*
|
|
50
|
+
* @param {number} tid
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @param {number} pid
|
|
53
|
+
* @returns {"main" | "worker" | "other"}
|
|
54
|
+
*/
|
|
55
|
+
export function partOfThread(tid, name, pid) {
|
|
56
|
+
if (tid === pid) return "main";
|
|
57
|
+
if (name === "WorkerThread") return "worker";
|
|
58
|
+
return "other";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* CPU seconds of this process's threads, by part, or null where `/proc` cannot
|
|
63
|
+
* be read.
|
|
64
|
+
*
|
|
65
|
+
* @param {number} [pid]
|
|
66
|
+
* @returns {Promise<{ main: number, worker: number, other: number } | null>}
|
|
67
|
+
*/
|
|
68
|
+
export async function readThreadSeconds(pid = process.pid) {
|
|
69
|
+
let tids;
|
|
70
|
+
try {
|
|
71
|
+
tids = await readdir(`/proc/${pid}/task`);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const parts = { main: 0, worker: 0, other: 0 };
|
|
76
|
+
await Promise.all(tids.map(async (entry) => {
|
|
77
|
+
const tid = Number(entry);
|
|
78
|
+
const stat = await readFile(`/proc/${pid}/task/${entry}/stat`, "utf8").then(parseStat, () => null);
|
|
79
|
+
if (stat) parts[partOfThread(tid, stat.name, pid)] += stat.seconds;
|
|
80
|
+
}));
|
|
81
|
+
return parts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* CPU seconds of each of these processes that can be read now.
|
|
86
|
+
*
|
|
87
|
+
* @param {number[]} pids
|
|
88
|
+
* @returns {Promise<Map<number, number>>}
|
|
89
|
+
*/
|
|
90
|
+
export async function readProcessesSeconds(pids) {
|
|
91
|
+
const seconds = new Map();
|
|
92
|
+
await Promise.all(pids.map(async (pid) => {
|
|
93
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8").then(parseStat, () => null);
|
|
94
|
+
if (stat) seconds.set(pid, stat.seconds);
|
|
95
|
+
}));
|
|
96
|
+
return seconds;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Shares of one core between two readings, per part. A thread or a process
|
|
101
|
+
* present in only one of the two readings contributes nothing, so a process
|
|
102
|
+
* that came or went in between is not read as a jump.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ at: number, process: number, threads: object | null, ffmpeg: Map<number, number> }} before
|
|
105
|
+
* @param {{ at: number, process: number, threads: object | null, ffmpeg: Map<number, number> }} after
|
|
106
|
+
* @returns {{ seconds: number, process: number, main: number | null, worker: number | null, other: number | null, ffmpeg: number | null } | null}
|
|
107
|
+
*/
|
|
108
|
+
export function sharesBetween(before, after) {
|
|
109
|
+
const seconds = (after.at - before.at) / 1000;
|
|
110
|
+
if (!(seconds > 0)) return null;
|
|
111
|
+
const share = (from, to) => (Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, to - from) / seconds : null);
|
|
112
|
+
let ffmpeg = null;
|
|
113
|
+
for (const [pid, value] of after.ffmpeg) {
|
|
114
|
+
const earlier = before.ffmpeg.get(pid);
|
|
115
|
+
if (earlier !== undefined && value >= earlier) ffmpeg = (ffmpeg ?? 0) + (value - earlier) / seconds;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
seconds,
|
|
119
|
+
process: share(before.process, after.process),
|
|
120
|
+
main: share(before.threads?.main, after.threads?.main),
|
|
121
|
+
worker: share(before.threads?.worker, after.threads?.worker),
|
|
122
|
+
other: share(before.threads?.other, after.threads?.other),
|
|
123
|
+
ffmpeg
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One line: per part, the mean over the interval and its highest second, as a
|
|
129
|
+
* percentage of one core.
|
|
130
|
+
*
|
|
131
|
+
* @param {object[]} seconds - Per-second shares from `sharesBetween`.
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function describeShares(seconds) {
|
|
135
|
+
const parts = ["process", "main", "worker", "other", "ffmpeg"];
|
|
136
|
+
const said = parts.map((part) => {
|
|
137
|
+
const values = seconds.map((one) => one[part]).filter((value) => Number.isFinite(value));
|
|
138
|
+
if (values.length === 0) return `${part} n/a`;
|
|
139
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
140
|
+
return `${part} ${Math.round(mean * 100)}% (highest second ${Math.round(Math.max(...values) * 100)}%)`;
|
|
141
|
+
});
|
|
142
|
+
return `${said.join(", ")} of a core over ${seconds.length}s`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read every second, say once a minute, and every second while `detailed()`
|
|
147
|
+
* is true.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} params
|
|
150
|
+
* @param {(line: string) => void} params.log
|
|
151
|
+
* @param {() => number[]} params.ffmpegPids - The encoders running now.
|
|
152
|
+
* @param {() => boolean} [params.detailed]
|
|
153
|
+
* @param {number} [params.everyMs]
|
|
154
|
+
* @param {number} [params.sayEveryMs]
|
|
155
|
+
* @returns {{ stop: () => void }}
|
|
156
|
+
*/
|
|
157
|
+
export function startProcessorReport({ log, ffmpegPids, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
|
|
158
|
+
let previous = null;
|
|
159
|
+
let series = [];
|
|
160
|
+
let since = Date.now();
|
|
161
|
+
let reading = false;
|
|
162
|
+
const tick = async () => {
|
|
163
|
+
if (reading) return;
|
|
164
|
+
reading = true;
|
|
165
|
+
try {
|
|
166
|
+
const usage = process.cpuUsage();
|
|
167
|
+
const [threads, ffmpeg] = await Promise.all([readThreadSeconds(), readProcessesSeconds(ffmpegPids())]);
|
|
168
|
+
const now = { at: Date.now(), process: (usage.user + usage.system) / 1e6, threads, ffmpeg };
|
|
169
|
+
const shares = previous ? sharesBetween(previous, now) : null;
|
|
170
|
+
previous = now;
|
|
171
|
+
if (!shares) return;
|
|
172
|
+
series.push(shares);
|
|
173
|
+
if (detailed()) log(`processor: ${describeShares([shares])}`);
|
|
174
|
+
if (now.at - since >= sayEveryMs) {
|
|
175
|
+
log(`processor: ${describeShares(series)}`);
|
|
176
|
+
series = [];
|
|
177
|
+
since = now.at;
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
reading = false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const timer = setInterval(() => { void tick(); }, everyMs);
|
|
184
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
185
|
+
return { stop: () => clearInterval(timer) };
|
|
186
|
+
}
|
|
@@ -475,12 +475,13 @@ export class TorrentWorkerClient {
|
|
|
475
475
|
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: { from: number, to: number, priority: number }[] }} params
|
|
476
476
|
* @returns {Promise<void>}
|
|
477
477
|
*/
|
|
478
|
-
async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds }) {
|
|
478
|
+
async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
|
|
479
479
|
return this.#caller.call(Command.PRIORITY_MAP, {
|
|
480
480
|
sourceKey,
|
|
481
481
|
fileIndex,
|
|
482
482
|
zones,
|
|
483
|
-
durationSeconds
|
|
483
|
+
durationSeconds,
|
|
484
|
+
postedAt
|
|
484
485
|
});
|
|
485
486
|
}
|
|
486
487
|
|
|
@@ -164,11 +164,11 @@ export class WorkerTorrentPool {
|
|
|
164
164
|
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
|
|
165
165
|
* @returns {Promise<void>}
|
|
166
166
|
*/
|
|
167
|
-
async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
167
|
+
async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
|
|
168
168
|
if (!sourceKey) {
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
|
-
return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
|
|
171
|
+
return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt });
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
@@ -454,13 +454,17 @@ async function runCommand(command, params, id) {
|
|
|
454
454
|
}
|
|
455
455
|
|
|
456
456
|
case Command.PRIORITY_MAP: {
|
|
457
|
+
// When this handler began, on the clock the main thread posted by: the
|
|
458
|
+
// post to here holds the transfer and this thread's queue together
|
|
459
|
+
// (torrent-tv/meta#177).
|
|
460
|
+
const receivedAt = performance.timeOrigin + performance.now();
|
|
457
461
|
// Source preparation owns adding the source. A delayed publication may
|
|
458
462
|
// only update an existing source, never revive one after its viewer left.
|
|
459
463
|
const zones = Array.isArray(params.zones) ? params.zones : [];
|
|
460
464
|
// Nor may one for a file held whole, which has nothing left to fetch.
|
|
461
465
|
const torrent = await knownTorrent(params.sourceKey);
|
|
462
466
|
if (!torrent) {
|
|
463
|
-
return { appliedMs: null };
|
|
467
|
+
return { appliedMs: null, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
|
|
464
468
|
}
|
|
465
469
|
// What applying a map costs this thread, told to whoever sent it: every
|
|
466
470
|
// zone becomes a window of the register and the swarm is told again
|
|
@@ -469,7 +473,7 @@ async function runCommand(command, params, id) {
|
|
|
469
473
|
ensureArrivalsWired(params.sourceKey, torrent);
|
|
470
474
|
pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
|
|
471
475
|
torrent.emit("priority-map-changed", params.fileIndex);
|
|
472
|
-
return { appliedMs: performance.now() - startedAt };
|
|
476
|
+
return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
|
|
473
477
|
}
|
|
474
478
|
|
|
475
479
|
case Command.READ_RANGE: {
|
|
@@ -12,13 +12,21 @@ export class DownloadMaps {
|
|
|
12
12
|
#nextEpoch = 0;
|
|
13
13
|
#log;
|
|
14
14
|
#now;
|
|
15
|
+
/**
|
|
16
|
+
* Milliseconds on the clock both threads read the same way, to the
|
|
17
|
+
* microsecond: what a publication's stages are measured with, the torrent
|
|
18
|
+
* thread's receipt included (torrent-tv/meta#177).
|
|
19
|
+
*/
|
|
20
|
+
#clock;
|
|
15
21
|
|
|
16
|
-
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now
|
|
22
|
+
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
|
|
23
|
+
clock = () => performance.timeOrigin + performance.now() }) {
|
|
17
24
|
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
18
25
|
this.#publish = publish;
|
|
19
26
|
this.#resolvePlayback = resolvePlayback;
|
|
20
27
|
this.#log = typeof log === "function" ? log : null;
|
|
21
28
|
this.#now = now;
|
|
29
|
+
this.#clock = clock;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
@@ -200,6 +208,7 @@ export class DownloadMaps {
|
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
#emit(file) {
|
|
211
|
+
const builtFrom = this.#clock();
|
|
203
212
|
const zones = [...file.zones, ...file.nativeZones];
|
|
204
213
|
for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
|
|
205
214
|
for (const [byteStart, byteEnd] of ranges) {
|
|
@@ -217,16 +226,32 @@ export class DownloadMaps {
|
|
|
217
226
|
// orders nothing differently, so the deadlines' order is compared and not
|
|
218
227
|
// their values.
|
|
219
228
|
const shape = shapeOf(publication);
|
|
229
|
+
// WHAT A PUBLICATION COSTS, stage by stage (torrent-tv/meta#177): building
|
|
230
|
+
// and comparing it here, every time something is stated, sent or not;
|
|
231
|
+
// waiting behind the previous one; the synchronous post, which drops the
|
|
232
|
+
// zones repeated for the torrent and copies the rest for the other thread;
|
|
233
|
+
// from the post to the torrent thread's
|
|
234
|
+
// handler, which holds the transfer and that thread's queue together; and
|
|
235
|
+
// applying it there. A cold minute of #166 run 12 built 10 408 maps and
|
|
236
|
+
// sent 439, and nothing said what that cost.
|
|
237
|
+
const builtMs = this.#clock() - builtFrom;
|
|
220
238
|
if (file.shape && sameShape(file.shape, shape)) {
|
|
221
|
-
this.#statsOf(file)
|
|
239
|
+
const stats = this.#statsOf(file);
|
|
240
|
+
stats.unchanged += 1;
|
|
241
|
+
stats.unsentBuildMs += builtMs;
|
|
222
242
|
this.#report(file);
|
|
223
243
|
return Promise.resolve();
|
|
224
244
|
}
|
|
225
245
|
file.shape = shape;
|
|
246
|
+
const askedAt = this.#clock();
|
|
226
247
|
const pending = file.pending.then(() => {
|
|
227
248
|
if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) !== file) return undefined;
|
|
228
249
|
const sentAt = this.#now();
|
|
229
|
-
|
|
250
|
+
const postedAt = this.#clock();
|
|
251
|
+
const sent = this.#publish({ ...publication, postedAt });
|
|
252
|
+
const postMs = this.#clock() - postedAt;
|
|
253
|
+
return Promise.resolve(sent).then(reply => {
|
|
254
|
+
const answeredAt = this.#clock();
|
|
230
255
|
// A torrent not yet in the thread applies nothing, so this map was not
|
|
231
256
|
// delivered and the next one, however alike, has to be.
|
|
232
257
|
if (reply?.appliedMs === null && file.shape === shape) file.shape = null;
|
|
@@ -235,6 +260,11 @@ export class DownloadMaps {
|
|
|
235
260
|
stats.sent += 1;
|
|
236
261
|
stats.zones.push(zones.length);
|
|
237
262
|
stats.deliveredMs.push(this.#now() - sentAt);
|
|
263
|
+
stats.sentBuildMs.push(builtMs);
|
|
264
|
+
stats.waitedMs.push(postedAt - askedAt);
|
|
265
|
+
stats.postMs.push(postMs);
|
|
266
|
+
if (Number.isFinite(reply?.receivedAt)) stats.toHandlerMs.push(Math.max(0, reply.receivedAt - postedAt - postMs));
|
|
267
|
+
if (Number.isFinite(reply?.repliedAt)) stats.replyMs.push(Math.max(0, answeredAt - reply.repliedAt));
|
|
238
268
|
if (Number.isFinite(reply?.appliedMs)) stats.appliedMs.push(reply.appliedMs);
|
|
239
269
|
this.#report(file);
|
|
240
270
|
return reply;
|
|
@@ -249,7 +279,7 @@ export class DownloadMaps {
|
|
|
249
279
|
}
|
|
250
280
|
|
|
251
281
|
#statsOf(file) {
|
|
252
|
-
file.stats ??=
|
|
282
|
+
file.stats ??= emptyStats(this.#now());
|
|
253
283
|
return file.stats;
|
|
254
284
|
}
|
|
255
285
|
|
|
@@ -264,11 +294,28 @@ export class DownloadMaps {
|
|
|
264
294
|
`zones median ${middle(stats.zones)} max ${largest(stats.zones)}; ` +
|
|
265
295
|
`delivered median ${middle(stats.deliveredMs)}ms max ${largest(stats.deliveredMs)}ms (${total(stats.deliveredMs)}ms in all), ` +
|
|
266
296
|
`applied in the torrent thread median ${middle(stats.appliedMs)}ms max ${largest(stats.appliedMs)}ms ` +
|
|
267
|
-
`(${total(stats.appliedMs)}ms in all)`
|
|
268
|
-
|
|
297
|
+
`(${total(stats.appliedMs)}ms in all); ` +
|
|
298
|
+
`stages of a sent map — built ${stage(stats.sentBuildMs)}, waited behind the previous ${stage(stats.waitedMs)}, ` +
|
|
299
|
+
`posted ${stage(stats.postMs)}, post to the torrent thread's handler ${stage(stats.toHandlerMs)}, ` +
|
|
300
|
+
`reply back ${stage(stats.replyMs)}; maps built and not sent took ${total([stats.unsentBuildMs])}ms here`);
|
|
301
|
+
file.stats = emptyStats(now);
|
|
269
302
|
}
|
|
270
303
|
}
|
|
271
304
|
|
|
305
|
+
/** @param {number} since */
|
|
306
|
+
function emptyStats(since) {
|
|
307
|
+
return { since, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [],
|
|
308
|
+
sentBuildMs: [], waitedMs: [], postMs: [], toHandlerMs: [], replyMs: [], unsentBuildMs: 0 };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** One stage's median, largest and sum, in milliseconds with tenths. @param {number[]} values */
|
|
312
|
+
function stage(values) {
|
|
313
|
+
if (values.length === 0) return "n/a";
|
|
314
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
315
|
+
const sum = values.reduce((total, value) => total + value, 0);
|
|
316
|
+
return `median ${sorted[Math.floor(sorted.length / 2)].toFixed(1)}ms max ${sorted.at(-1).toFixed(1)}ms sum ${Math.round(sum)}ms`;
|
|
317
|
+
}
|
|
318
|
+
|
|
272
319
|
/** How many numbers describe one zone's part in what the torrent does. */
|
|
273
320
|
const ZONE_FIELDS = 7;
|
|
274
321
|
|