@torrent-tv/proxy 2.80.9 → 2.80.10

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.
@@ -1,726 +1,726 @@
1
- /**
2
- * @file How many encoders run on this proxy, on which outputs, and over which
3
- * stretches.
4
- *
5
- * The application layer over `encode/`. It holds four things and owns none of
6
- * their rules:
7
- *
8
- * 1. what viewers want (`SegmentDemand`), stated once each and read as a union;
9
- * 2. what has been made and what is being made, one `CoverageMap` per output;
10
- * 3. the encoders that are running (`EncodeRun`), each over a stretch;
11
- * 4. a tally of how every run ended, which is what makes "abnormal endings do
12
- * not happen" a number rather than an impression.
13
- *
14
- * The decision itself is `EncodePlan.planEncoders`, from numbers alone. This
15
- * carries it out, and everything it cannot know is injected: how many encoders
16
- * this machine can afford, how a run is built for a given stretch, and which
17
- * segments already exist.
18
- *
19
- * **No viewer reaches the decision.** A viewer states a window and is forgotten
20
- * as a name; what the plan sees is a union of windows. That is the rule the
21
- * layer exists for, stated by the user 2026-09-04: requests come from any
22
- * viewers in any number, encoders are managed to suit them, and viewers get the
23
- * result when it is ready.
24
- */
25
-
26
- import { CoverageMap } from "../encode/CoverageMap.js";
27
- import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
28
- import { endOfRun } from "../encode/EncodeRun.js";
29
- import { ENCODE_EXIT } from "../encode/encode-exit.js";
30
- import { affordableRuns } from "../encode/run-budget.js";
31
- import { RunCosts } from "../encode/run-costs.js";
32
- import { contentionPenalty } from "../contention.js";
33
- import { SegmentDemand } from "../encode/SegmentDemand.js";
34
-
35
- export class EncodeOrchestrator {
36
- /** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
37
- #coverage = new Map();
38
-
39
- /** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
40
- #runs = new Map();
41
-
42
-
43
- /** The fastest speed measured on one output, kept across restarts. @type {Map<string, number>} */
44
- #lastSpeed = new Map();
45
-
46
- /** How runs have ended, by cause. @type {Map<string, number>} */
47
- #endings = new Map();
48
-
49
- /** The last state said out loud, so an unchanged state is not repeated. */
50
- #lastDescribed = "";
51
-
52
- /** What a stop and a start have cost on this host. */
53
- #costs = new RunCosts();
54
-
55
- /** The last reason a budget was cut, so the same one is not said twice. */
56
- #lastBudgetReason = new Map();
57
-
58
- /** The last unmet want said out loud, so a stuck one is said once. */
59
- #lastUnmet = new Map();
60
-
61
- /**
62
- * @param {object} params
63
- * @param {(address: string) => number} params.maxRunsFor - How many encoders
64
- * this machine can afford on one output. The same arithmetic that decides
65
- * the quality offer; measured per host, never chosen here.
66
- * @param {(params: { address: string, from: number, to: number }) =>
67
- * import("../encode/EncodeRun.js").EncodeRun} params.makeRun - Build a run
68
- * for a stretch. What to read, what to map and how to cut belong to whoever
69
- * knows the source.
70
- * @param {number} params.segmentSeconds
71
- * @param {import("../contention.js").ContentionPenalties | null}
72
- * [params.contentionPenalties] - How much slower one encoder runs beside
73
- * others, MEASURED on this host at startup and keyed by how many others
74
- * there are. Null until something has measured it, and then the penalty is
75
- * 1 — a number invented here would be the same mistake as an invented
76
- * encoding speed.
77
- * @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
78
- * @param {() => number} [params.now]
79
- */
80
- constructor({
81
- maxRunsFor,
82
- makeRun,
83
- segmentSeconds,
84
- contentionPenalties = null,
85
- refetchSecPerFilmSecond = () => 0,
86
- startingSpeedFor = () => 0,
87
- segmentStore = null,
88
- logger,
89
- now
90
- }) {
91
- // The store of produced segments — the layer below this one. It is asked to
92
- // clean up after a run that ended other than by reaching the end of its
93
- // stretch, which is the one thing an ending must not leave behind: a file
94
- // under a name that promises a whole segment.
95
- this.segmentStore = segmentStore;
96
- this.demand = new SegmentDemand();
97
- this.maxRunsFor = maxRunsFor;
98
- // Seconds of swarm time per second of film: what re-encoding material that
99
- // already exists costs the download, over and above the encoder's own time.
100
- // Injected, because the film's byte rate and the swarm's are measured
101
- // elsewhere and this class must not reach for them.
102
- this.refetchSecPerFilmSecond = refetchSecPerFilmSecond;
103
- // Measured per host: what a second encoder costs the first. Unmeasured is 1,
104
- // and then only the budget bounds how many there are.
105
- this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
106
- // WHAT THIS HOST ENCODES AT BEFORE ANY RUN HAS REPORTED. The startup
107
- // benchmark measures it — a real pipeline over real clips, before a viewer
108
- // exists — so the plan is never asked to compare arrivals with no speed to
109
- // compute them from. Every run that then works refines it.
110
- this.startingSpeedFor = startingSpeedFor;
111
- this.makeRun = makeRun;
112
- this.segmentSeconds = segmentSeconds;
113
- this.logger = logger;
114
- this.now = typeof now === "function" ? now : Date.now;
115
- }
116
-
117
- /**
118
- * The map of one output, made on first mention.
119
- *
120
- * @param {string} address
121
- * @returns {CoverageMap}
122
- */
123
- coverageOf(address) {
124
- let map = this.#coverage.get(address);
125
- if (!map) {
126
- map = new CoverageMap();
127
- this.#coverage.set(address, map);
128
- }
129
- return map;
130
- }
131
-
132
- /**
133
- * Put the map's picture of what is ready back in step with the disk.
134
- *
135
- * ONE AUTHORITY ON WHAT EXISTS, AND IT IS THE STORE. The map holds no memory
136
- * of readiness between calls: it is handed the whole answer, replacing
137
- * whatever it had, immediately before anything is decided from it. So a
138
- * segment whose file was discarded with the run that had it open, dropped to
139
- * make room, or reopened by a run restarting on it stops being ready in the
140
- * same breath — without anything having to notice and say so.
141
- *
142
- * The map used to be filled from outside, by the session manager, with a
143
- * method that only ever added. Nothing anywhere took a number back. Field
144
- * 2026-09-07: the map claimed all 482 segments of a film while the directory
145
- * held nothing, so every arrangement scored perfect, the only encoder was
146
- * stopped as unnecessary and none was placed again — two sessions in a row
147
- * with no picture at all.
148
- *
149
- * A store is optional here only in the sense that an authority which was not
150
- * supplied cannot be consulted: without one the map keeps what it was told
151
- * directly, which is how this class is exercised with plain numbers.
152
- *
153
- * @param {string} address
154
- * @returns {CoverageMap}
155
- */
156
- #upToDateCoverage(address) {
157
- const coverage = this.coverageOf(address);
158
- if (this.segmentStore) {
159
- coverage.setReady(this.segmentStore.provenNumbers(address));
160
- }
161
- return coverage;
162
- }
163
-
164
- /**
165
- * Where a run started here must stop: the free stretch in front of it.
166
- *
167
- * Asked of the one map, brought up to date first. It used to be worked out by
168
- * the session manager, which reached into this layer for the map and into the
169
- * store for what was on the disk and put the two together itself — one fact
170
- * with two owners and a third party carrying it between them, which is how the
171
- * two came to disagree.
172
- *
173
- * @param {object} params
174
- * @param {string} params.address
175
- * @param {number} params.from - Where the run will start.
176
- * @param {object | null} [params.exceptRun] - The run being replaced, whose
177
- * own claim is not somebody else's material.
178
- * @param {number} [params.segmentCount] - The output's length, when known.
179
- * @returns {number} The last number to work through, or `-1` for the end of
180
- * the film.
181
- */
182
- freeStretchEnd({ address, from, exceptRun = null, segmentCount = 0 }) {
183
- if (!address) {
184
- return -1;
185
- }
186
- if (segmentCount > 0) {
187
- this.coverageOf(address).setSegmentCount(segmentCount);
188
- }
189
- const coverage = this.#upToDateCoverage(address);
190
- const start = Math.max(0, from);
191
- const free = coverage.freeRunFrom(start, exceptRun);
192
- if (!Number.isFinite(free)) {
193
- return -1;
194
- }
195
- const end = start + Math.max(1, free) - 1;
196
- return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
197
- }
198
-
199
- /**
200
- * @param {string} address
201
- * @returns {import("../encode/EncodeRun.js").EncodeRun[]}
202
- */
203
- runsOn(address) {
204
- return this.#runs.get(address) ?? [];
205
- }
206
-
207
- /**
208
- * How long an output is, once its playlist is known.
209
- *
210
- * @param {string} address
211
- * @param {number} segmentCount
212
- */
213
- setSegmentCount(address, segmentCount) {
214
- this.coverageOf(address).setSegmentCount(segmentCount);
215
- }
216
-
217
- /**
218
- * Segments that already exist — from a previous life of this process, or
219
- * because somebody else made them. Told to the map, which is what stops an
220
- * encoder being started to make them again.
221
- *
222
- * @param {string} address
223
- * @param {Iterable<number>} indexes
224
- */
225
- noteAlreadyMade(address, indexes) {
226
- for (const index of indexes) {
227
- this.noteProduced(address, index);
228
- }
229
- }
230
-
231
- /**
232
- * A viewer states what it is waiting for. Replaces whatever it said before.
233
- *
234
- * @param {object} params
235
- * @param {string} params.claimant
236
- * @param {string} params.address
237
- * @param {number} params.from
238
- * @param {number} params.to
239
- * @param {number} [params.priority] - Higher is sooner. One viewer states
240
- * several stretches at once — what must be ready before they set off, what
241
- * is reachable while they watch it, the rest of the track — and the filling
242
- * takes them in this order. Absent means one undifferentiated want, which
243
- * is what a caller that knows only a position states.
244
- */
245
- /**
246
- * What is wanted of one output, in its own segment numbers.
247
- *
248
- * ONE MAP, ALREADY MERGED, AND WITH NOBODY'S NAME ON IT. It is built once per
249
- * film by the layer that knows where the viewers are; this layer receives it
250
- * converted into an output's own numbering and never asks who is in it.
251
- *
252
- * That replaced a window per viewer per band stated here and merged here,
253
- * which was the same work done twice in two layers, with the viewer's name as
254
- * the key of a claim — against the rule that the encoding and the viewer are
255
- * not connected at all.
256
- *
257
- * An empty map says nobody is coming anywhere in this output, and the plan
258
- * stops its encoders for it. Nothing has to be released when somebody leaves:
259
- * the map that arrives next simply does not have them in it.
260
- *
261
- * @param {string} address
262
- * @param {{ from: number, to: number, priority: number, withinSeconds: number }[]} zones
263
- */
264
- notePriorityMap(address, zones) {
265
- this.demand.state(address, zones);
266
- }
267
-
268
- /**
269
- * A segment has been finished, by whichever run made it.
270
- *
271
- * @param {string} address
272
- * @param {number} index
273
- */
274
- noteProduced(address, index) {
275
- // TOLD TO THE AUTHORITY, not only to the map. A piece being closed is a fact
276
- // about the disk, and the store is what holds those; told to the map alone
277
- // it would survive exactly until the next time the map is brought back into
278
- // step, and then be gone with no file to show for it.
279
- this.segmentStore?.markClosed(address, index);
280
- this.coverageOf(address).markReady(index);
281
- for (const run of this.runsOn(address)) {
282
- run.noteProduced(index);
283
- }
284
- }
285
-
286
- /**
287
- * @param {string} address
288
- * @param {object} run
289
- * @param {number} speedX
290
- */
291
- noteSpeed(address, wanted, speedX) {
292
- for (const run of this.runsOn(address)) {
293
- if (run === wanted) {
294
- run.noteSpeed(speedX);
295
- }
296
- }
297
- // HOW FAST THIS MACHINE ENCODES THIS OUTPUT is a property of the machine and
298
- // the material, not of one process. Read off `run.speedX` alone it was lost
299
- // at every restart: a moved encoder is a new object that has measured
300
- // nothing, so the plan fell back to "nothing is known" and stopped comparing
301
- // arrivals at all — which is every decision in this layer.
302
- if (speedX > 0 && speedX > (this.#lastSpeed.get(address) ?? 0)) {
303
- this.#lastSpeed.set(address, speedX);
304
- }
305
- }
306
-
307
- /**
308
- * Decide and act, for every output anybody wants anything of and every output
309
- * that still has an encoder on it.
310
- *
311
- * Safe to call as often as anything changes: the plan is a function of the
312
- * state, so a pass that finds nothing to change does nothing.
313
- */
314
- reconcile() {
315
- const addresses = new Set([...this.demand.addresses(), ...this.#runs.keys()]);
316
- for (const address of addresses) {
317
- this.#reconcileOne(address);
318
- }
319
- // WHAT THIS CLASS BELIEVES, said by this class. `describe()` was written
320
- // and called from nowhere, so on 2026-09-05 the question "why did the plan
321
- // not see the gap the viewer was stopped at" had to be answered by
322
- // inference from start and stop lines, and was not answered at all.
323
- //
324
- // Printed on CHANGE rather than on a timer: a quiet session says nothing, a
325
- // session that is deciding something says what it decided, and there is no
326
- // interval to choose.
327
- const state = this.describe();
328
- if (state !== this.#lastDescribed) {
329
- this.#lastDescribed = state;
330
- this.logger.info(state);
331
- }
332
- }
333
-
334
- /**
335
- * @param {string} address
336
- */
337
- #reconcileOne(address) {
338
- // WHAT EXISTS IS ASKED OF THE DISK, HERE, EVERY TIME. The plan is arithmetic
339
- // over what is made, what is being made and what is wanted, and the first of
340
- // those is not this layer's to remember.
341
- const coverage = this.#upToDateCoverage(address);
342
- // A run that has ended and said nothing. One built here reports its own
343
- // ending and is released by `noteEnded`; one ADOPTED from elsewhere — a
344
- // session whose encoder stopped — has no such promise, and its claim would
345
- // otherwise sit in the map for the life of the process, telling the plan
346
- // that a stretch nobody is making is being made. Nothing would ever be
347
- // started there again.
348
- for (const run of this.runsOn(address)) {
349
- if (!run.isAlive && !run.isStopping) {
350
- this.noteEnded({
351
- address,
352
- run,
353
- ending: ENCODE_EXIT.GONE,
354
- because: "it is no longer running, and it did not say so"
355
- });
356
- }
357
- }
358
- // ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
359
- //
360
- // Two viewers a few seconds apart state stretches that overlap, and the plan
361
- // puts one encoder on each stretch it is given — so unmerged windows buy an
362
- // encoder per viewer for film they both want, which is the opposite of what
363
- // sharing the output is for. Merged, the highest rank and the soonest time
364
- // per number win and the stretches do not overlap, so one encoder serves
365
- // everyone standing in front of it.
366
- //
367
- // Asked of the register, which is the thing that holds the windows. This
368
- // used to reach into the layer that STATES them for the same arithmetic,
369
- // which is the coupling the layer rule forbids; the arithmetic itself now
370
- // lives where it belongs to nobody.
371
- const windows = this.demand.mapOn(address);
372
- const live = this.runsOn(address).filter((run) => run.isAlive);
373
- // Asked ONCE. It is arithmetic over measurements, but it also says out loud
374
- // when the reason it cuts the budget changes, so asking it three times in
375
- // one pass is three chances to say a thing that happened once.
376
- const maxRuns = this.#affordableOn(address, live);
377
- const actions = planEncoders({
378
- coverage,
379
- windows,
380
- // The runs themselves. The plan is arithmetic and reads four numbers off
381
- // each; what it hands back names the run by BEING it, so nothing has to
382
- // invent a token to refer to one by.
383
- runs: live,
384
- maxRuns,
385
- segmentSeconds: this.segmentSeconds,
386
- // What a start and a kill cost, measured from this host's own runs rather
387
- // than written into the code from one machine's reading. Zero until
388
- // something has been measured, which is the same convention as the
389
- // refetch price below and is stated so the bias is known.
390
- ...this.#costs.seconds(),
391
- // What a second of film costs to fetch again, in seconds of swarm time.
392
- // Answered by whoever measures the film's own byte rate and the swarm's;
393
- // zero until they have, which makes driving through look cheaper than it
394
- // is and is stated here so the bias is known.
395
- refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
396
- // How much slower one encoder runs beside others, read off this host's own
397
- // startup measurement. A pure function over a measured table: beyond what
398
- // was measured it holds the largest reading rather than continuing a curve
399
- // nothing observed.
400
- contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
401
- // The best figure this host has: what a run here is doing now, what one
402
- // was last measured doing, or what the startup benchmark predicted. The
403
- // first two are this output's own; the third exists before either.
404
- speedX: Math.max(
405
- live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
406
- this.#lastSpeed.get(address) ?? 0,
407
- this.startingSpeedFor(address) || 0
408
- )
409
- });
410
-
411
- // A move is the plan taking a running encoder away from where it already
412
- // stands, which is exactly the decision that was found wandering back and
413
- // forth in the field on 2026-09-07 with no way to see why: the "because"
414
- // line names the comparison in words, never the numbers it was decided
415
- // from. Said here, once per reconcile, and only when a move actually
416
- // happens — everything a rerun of the same decision needs: the windows
417
- // this call saw (priority, the real time, which side of the viewers),
418
- // the budget, and where every live run stood.
419
- if (actions.some((action) => action.type === "move")) {
420
- this.logger.info(
421
- `encode-plan move on ${address}: windows=${JSON.stringify(windows)} ` +
422
- `maxRuns=${maxRuns} ` +
423
- `live=${JSON.stringify(live.map((run) => ({ from: run.from, to: run.to, head: run.head, speedX: run.speedX })))}`
424
- );
425
- }
426
- for (const action of actions) {
427
- if (action.type === "stop") {
428
- this.#stop(action.run, action.because);
429
- continue;
430
- }
431
- if (action.type === "move") {
432
- // A running encoder's position cannot be changed — it is fixed when the
433
- // process starts — so a move is this one ending and another beginning
434
- // where the material is missing. Both halves are recorded as what they
435
- // are, which is why the ending of a moved run is not called normal.
436
- this.#stop(action.run, action.because);
437
- this.#start(address, action.from, action.to, action.because);
438
- continue;
439
- }
440
- if (action.type === "start") {
441
- this.#start(address, action.from, action.to, action.because);
442
- continue;
443
- }
444
- // A run that stays keeps its claim current: the free stretch ahead of it
445
- // may have shrunk since it was given one.
446
- //
447
- // THE CLAIM IS THE STRETCH IT WAS GIVEN, and there is one rule for that
448
- // everywhere. It used to be narrowed here to what the run had already
449
- // MADE whenever the run had no end, which meant a run claimed the single
450
- // number it was writing and nothing beyond. The plan then read the road
451
- // in front of a working encoder as free and started more encoders on it:
452
- // three processes writing one directory with the same names, field
453
- // 2026-09-06, and a piece of the film lost for good when the first of
454
- // them was cleaned up after.
455
- //
456
- // The worry that narrowing was written for is real and is answered where
457
- // it belongs — a viewer opening the same film further in must not find
458
- // every number taken. That is the plan's business, and the plan can take
459
- // road away from a run that has no end, because such a run carries no
460
- // `-to` and simply stops when its head meets somebody else's claim.
461
- coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
462
- }
463
-
464
- // NOBODY IS MAKING WHAT SOMEBODY IS WAITING FOR. Said here, with the numbers
465
- // the decision was taken from, because it is the one state in which a viewer
466
- // waits for ever and every line above it reads as a healthy proxy.
467
- //
468
- // Field 2026-09-07, twice in one evening: the last word about an output was
469
- // "the film is no worse off without it", and after it nothing — no run, no
470
- // refusal, no answer to the browser's request for the header. The wait ended
471
- // at the browser's own timeout with a message naming no cause, and the
472
- // proxy's log named none either.
473
- //
474
- // SAID ONCE PER STATE, not once per pass. A stuck output is reconciled on
475
- // every event that touches it, and a line repeated for as long as the state
476
- // lasts is what buried the last one: 49 295 copies of `send queue stuck` in
477
- // a 159 090-line file, 31 % of the log, all of one wedge.
478
- const wanting = firstUnmetWant(coverage, windows);
479
- const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
480
- if (wanting === null || stillRunning.length > 0) {
481
- this.#lastUnmet.delete(address);
482
- } else if (this.#lastUnmet.get(address) !== wanting) {
483
- this.#lastUnmet.set(address, wanting);
484
- const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
485
- this.logger.warn(
486
- `encode: #${wanting} of ${address} is wanted and NO ENCODER IS MAKING IT — ` +
487
- `ready=${coverage.stats().ready} of ${coverage.segmentCount} ` +
488
- `files=${held < 0 ? "?" : held} maxRuns=${maxRuns} ` +
489
- `windows=${JSON.stringify(windows)}`
490
- );
491
- }
492
- }
493
-
494
- /**
495
- * @param {string} address
496
- * @param {number} from
497
- * @param {number} to
498
- * @param {string} because
499
- */
500
- #start(address, from, to, because) {
501
- // The encoder is built here and now: whoever builds one waits for nothing,
502
- // so it exists by the time this line returns. That is what makes the
503
- // stretch held from this instant — this class knows what it is making
504
- // because it has just made it, and no second encoder can be started for the
505
- // same stretch on the next pass.
506
- //
507
- // It was not always so. The builder used to answer with nothing and start
508
- // the encoder behind the answer, so the stretch stayed FREE for as long as
509
- // that took, and every pass in between started another one: 684 starts in
510
- // 482 seconds of field 2026-09-05, of which 973 answers said the encoder
511
- // was not there yet — every start without exception.
512
- //
513
- // The run names itself: identity is a property of the thing, and two
514
- // places minting names is how one stops being unique.
515
- const run = this.makeRun({ address, from, to });
516
- if (!run) {
517
- // A refusal, not a wait: no session serves this output, or this position
518
- // has failed to start too many times running.
519
- this.logger.warn(`encode: no encoder could be made for #${from}..#${to} of ${address}`);
520
- return;
521
- }
522
- // What this machine has been measured to do on this output, carried over.
523
- // A restart does not make the machine slower, and without this every moved
524
- // encoder began as one whose speed nothing had measured — which the plan
525
- // reads as "no arrival can be computed" and answers by comparing nothing.
526
- const known = this.#lastSpeed.get(address) ?? 0;
527
- if (known > 0) {
528
- run.noteSpeed(known);
529
- }
530
- const onThisOutput = this.#runs.get(address) ?? [];
531
- onThisOutput.push(run);
532
- this.#runs.set(address, onThisOutput);
533
- // This run rewrites the stretch it was given, so what was closed inside that
534
- // stretch is no longer closed. Without this a number closed by an earlier run
535
- // stays servable while a later one is halfway through writing it again.
536
- //
537
- // Bounded by the run's own end, which is the same number the claim below
538
- // carries. Unbounded it unproved the whole film beyond the start of any run,
539
- // and readiness is now a projection of what is proven — so a one-segment run
540
- // at the beginning would have declared the rest of the output unmade.
541
- const runsTo = endOfRun({ from, to });
542
- this.segmentStore?.forgetClosed(address, from, runsTo);
543
- this.coverageOf(address).claim(run, from, runsTo);
544
- run.start(because);
545
- }
546
-
547
- /**
548
- * How many encoders may run on this output, from every limit at once.
549
- *
550
- * The processor is one of them and is answered from outside, where the
551
- * machine is measured. The other two are known here: what the swarm delivers,
552
- * through the seconds of swarm time a second of film costs, and — once it is
553
- * supplied — the memory the piece store may hold against what one encoder's
554
- * reader keeps.
555
- *
556
- * Said out loud when it is not the processor that decided, because "why is
557
- * there only one encoder" is otherwise a question no log can answer.
558
- *
559
- * @param {string} address
560
- * @param {{ speedX: number }[]} live
561
- * @returns {number}
562
- */
563
- #affordableOn(address, live) {
564
- const byProcessor = Math.max(0, this.maxRunsFor(address));
565
- // The best figure this host has: what a run here is doing now, what one was
566
- // last measured doing, or what the startup benchmark predicted. The first
567
- // two are this output's own; the third exists before either, so the budget
568
- // is never asked to price encoders at a speed of zero.
569
- const fastest = Math.max(
570
- live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
571
- this.#lastSpeed.get(address) ?? 0,
572
- this.startingSpeedFor(address) || 0
573
- );
574
- const budget = affordableRuns({
575
- byProcessor,
576
- speedX: fastest,
577
- refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
578
- // How much slower one encoder runs beside others, read off this host's own
579
- // startup measurement. A pure function over a measured table: beyond what
580
- // was measured it holds the largest reading rather than continuing a curve
581
- // nothing observed.
582
- contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty
583
- });
584
- if (budget.runs !== byProcessor && budget.because !== this.#lastBudgetReason.get(address)) {
585
- this.#lastBudgetReason.set(address, budget.because);
586
- this.logger.info(
587
- `encode: ${budget.runs} encoder(s) on ${address.slice(0, 60)} — ${budget.because} ` +
588
- `(the processor alone would allow ${byProcessor})`
589
- );
590
- }
591
- return budget.runs;
592
- }
593
-
594
- /**
595
- * Take charge of a run this class did not start.
596
- *
597
- * The browser asks for a stream and a run begins for it, long before this
598
- * class has an opinion. Left unknown, that run would be invisible to the plan
599
- * — which would then start a second encoder over the same numbers, believing
600
- * nothing was being made there. So whoever starts one hands it over, and from
601
- * then on it is planned like any other.
602
- *
603
- * @param {string} address
604
- * @param {{ id: string, from: number, to: number, head: number, speedX: number, isAlive: boolean, stop: (because: string) => void }} run
605
- */
606
- adopt(address, run) {
607
- if (!run) {
608
- return;
609
- }
610
- const onThisOutput = this.#runs.get(address) ?? [];
611
- if (onThisOutput.includes(run)) {
612
- return;
613
- }
614
- onThisOutput.push(run);
615
- this.#runs.set(address, onThisOutput);
616
- this.coverageOf(address).claim(run, run.from, endOfRun(run));
617
- }
618
-
619
- /**
620
- * @param {object} run
621
- * @param {string} because
622
- */
623
- #stop(run, because) {
624
- run.stop(because);
625
- }
626
-
627
- /**
628
- * A run has ended, however it ended. Its stretch goes back to the map — what
629
- * it finished stays made — and the ending is counted.
630
- *
631
- * Wired by whoever builds the run, so that a run built outside this class is
632
- * still accounted for.
633
- *
634
- * @param {import("../encode/EncodeRun.js").RunEnded} ended
635
- */
636
- noteEnded(ended) {
637
- this.#costs.note(ended);
638
- // Exactly one ending is normal — the run reached the end of the stretch it
639
- // was given and closed its last file. Every other leaves a piece open, and
640
- // that file looks finished however the run ended: stopped, ffmpeg writes it
641
- // out and names it like any other; killed harder, it leaves the bytes it
642
- // had. Either way it decodes and holds less film than its number promises.
643
- // So what is kept is what the run PROVED it finished, and nothing beyond.
644
- if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
645
- void this.segmentStore
646
- .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
647
- .catch(() => {});
648
- }
649
- this.coverageOf(ended.address).release(ended.run);
650
- const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
651
- if (remaining.length === 0) {
652
- this.#runs.delete(ended.address);
653
- } else {
654
- this.#runs.set(ended.address, remaining);
655
- }
656
- this.#endings.set(ended.ending, (this.#endings.get(ended.ending) ?? 0) + 1);
657
- }
658
-
659
- /**
660
- * How runs have ended over the life of this process, by cause.
661
- *
662
- * The abnormal classes are meant to stand at zero. Without the count,
663
- * "we understand why it ended" is indistinguishable from "we noticed it once".
664
- *
665
- * @returns {Record<string, number>}
666
- */
667
- endings() {
668
- /** @type {Record<string, number>} */
669
- const tally = {};
670
- for (const ending of Object.values(ENCODE_EXIT)) {
671
- tally[ending] = this.#endings.get(ending) ?? 0;
672
- }
673
- return tally;
674
- }
675
-
676
- /**
677
- * One line saying what this proxy is encoding and whether anybody is waiting.
678
- *
679
- * `waiting` is the point of it: a proxy with encoders running and a viewer
680
- * still stopped at a segment nobody is making is the failure this layer was
681
- * built to remove, and it is visible here rather than inferred from a log.
682
- *
683
- * @returns {string}
684
- */
685
- describe() {
686
- const parts = [];
687
- for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
688
- const coverage = this.coverageOf(address);
689
- const stated = this.demand.mapOn(address);
690
- const windows = stated.map((zone) => ({ from: zone.from, to: zone.to }));
691
- const waiting = firstUnmetWant(coverage, windows);
692
- const runs = this.runsOn(address)
693
- .map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
694
- .join(" ");
695
- // The zones as they were stated, with their order, so a plan that is
696
- // working at the wrong end of the film is visible rather than inferred.
697
- const zones = [...stated]
698
- .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from)
699
- .map((w) => `p${w.priority ?? 0}:#${w.from}..#${w.to}`)
700
- .join(" ");
701
- // THE WHOLE ADDRESS. Cut to sixty characters, every output of one film
702
- // printed the same string — the picture, its quality steps and each
703
- // soundtrack are told apart only by the tail — so three lines of this
704
- // could not be matched to the three things they describe. Read on
705
- // 2026-09-07 while accounting for a session that produced nothing, and
706
- // the accounting had to be done by which line carried a run.
707
- //
708
- // And WHAT THE DISK HOLDS beside what is proven closed. They are two
709
- // different statements: files with nothing proving them closed reads as a
710
- // reporting fault, no files at all reads as an output yet to be made, and
711
- // the difference decides where to look.
712
- const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
713
- parts.push(
714
- `${address} ready=${coverage.stats().ready}` +
715
- `${held < 0 ? "" : ` of ${held} file(s) on disk`} ` +
716
- `zones=[${zones}] runs=[${runs}] ` +
717
- `waiting=${waiting === null ? "nobody" : `#${waiting}`}`
718
- );
719
- }
720
- const tally = this.endings();
721
- const endings = Object.entries(tally)
722
- .map(([cause, count]) => `${cause}=${count}`)
723
- .join(" ");
724
- return `encode: ${parts.length === 0 ? "nothing wanted" : parts.join(" | ")} :: endings ${endings}`;
725
- }
726
- }
1
+ /**
2
+ * @file How many encoders run on this proxy, on which outputs, and over which
3
+ * stretches.
4
+ *
5
+ * The application layer over `encode/`. It holds four things and owns none of
6
+ * their rules:
7
+ *
8
+ * 1. what viewers want (`SegmentDemand`), stated once each and read as a union;
9
+ * 2. what has been made and what is being made, one `CoverageMap` per output;
10
+ * 3. the encoders that are running (`EncodeRun`), each over a stretch;
11
+ * 4. a tally of how every run ended, which is what makes "abnormal endings do
12
+ * not happen" a number rather than an impression.
13
+ *
14
+ * The decision itself is `EncodePlan.planEncoders`, from numbers alone. This
15
+ * carries it out, and everything it cannot know is injected: how many encoders
16
+ * this machine can afford, how a run is built for a given stretch, and which
17
+ * segments already exist.
18
+ *
19
+ * **No viewer reaches the decision.** A viewer states a window and is forgotten
20
+ * as a name; what the plan sees is a union of windows. That is the rule the
21
+ * layer exists for, stated by the user 2026-09-04: requests come from any
22
+ * viewers in any number, encoders are managed to suit them, and viewers get the
23
+ * result when it is ready.
24
+ */
25
+
26
+ import { CoverageMap } from "../encode/CoverageMap.js";
27
+ import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
28
+ import { endOfRun } from "../encode/EncodeRun.js";
29
+ import { ENCODE_EXIT } from "../encode/encode-exit.js";
30
+ import { affordableRuns } from "../encode/run-budget.js";
31
+ import { RunCosts } from "../encode/run-costs.js";
32
+ import { contentionPenalty } from "../encode/contention.js";
33
+ import { SegmentDemand } from "../encode/SegmentDemand.js";
34
+
35
+ export class EncodeOrchestrator {
36
+ /** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
37
+ #coverage = new Map();
38
+
39
+ /** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
40
+ #runs = new Map();
41
+
42
+
43
+ /** The fastest speed measured on one output, kept across restarts. @type {Map<string, number>} */
44
+ #lastSpeed = new Map();
45
+
46
+ /** How runs have ended, by cause. @type {Map<string, number>} */
47
+ #endings = new Map();
48
+
49
+ /** The last state said out loud, so an unchanged state is not repeated. */
50
+ #lastDescribed = "";
51
+
52
+ /** What a stop and a start have cost on this host. */
53
+ #costs = new RunCosts();
54
+
55
+ /** The last reason a budget was cut, so the same one is not said twice. */
56
+ #lastBudgetReason = new Map();
57
+
58
+ /** The last unmet want said out loud, so a stuck one is said once. */
59
+ #lastUnmet = new Map();
60
+
61
+ /**
62
+ * @param {object} params
63
+ * @param {(address: string) => number} params.maxRunsFor - How many encoders
64
+ * this machine can afford on one output. The same arithmetic that decides
65
+ * the quality offer; measured per host, never chosen here.
66
+ * @param {(params: { address: string, from: number, to: number }) =>
67
+ * import("../encode/EncodeRun.js").EncodeRun} params.makeRun - Build a run
68
+ * for a stretch. What to read, what to map and how to cut belong to whoever
69
+ * knows the source.
70
+ * @param {number} params.segmentSeconds
71
+ * @param {import("../encode/contention.js").ContentionPenalties | null}
72
+ * [params.contentionPenalties] - How much slower one encoder runs beside
73
+ * others, MEASURED on this host at startup and keyed by how many others
74
+ * there are. Null until something has measured it, and then the penalty is
75
+ * 1 — a number invented here would be the same mistake as an invented
76
+ * encoding speed.
77
+ * @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
78
+ * @param {() => number} [params.now]
79
+ */
80
+ constructor({
81
+ maxRunsFor,
82
+ makeRun,
83
+ segmentSeconds,
84
+ contentionPenalties = null,
85
+ refetchSecPerFilmSecond = () => 0,
86
+ startingSpeedFor = () => 0,
87
+ segmentStore = null,
88
+ logger,
89
+ now
90
+ }) {
91
+ // The store of produced segments — the layer below this one. It is asked to
92
+ // clean up after a run that ended other than by reaching the end of its
93
+ // stretch, which is the one thing an ending must not leave behind: a file
94
+ // under a name that promises a whole segment.
95
+ this.segmentStore = segmentStore;
96
+ this.demand = new SegmentDemand();
97
+ this.maxRunsFor = maxRunsFor;
98
+ // Seconds of swarm time per second of film: what re-encoding material that
99
+ // already exists costs the download, over and above the encoder's own time.
100
+ // Injected, because the film's byte rate and the swarm's are measured
101
+ // elsewhere and this class must not reach for them.
102
+ this.refetchSecPerFilmSecond = refetchSecPerFilmSecond;
103
+ // Measured per host: what a second encoder costs the first. Unmeasured is 1,
104
+ // and then only the budget bounds how many there are.
105
+ this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
106
+ // WHAT THIS HOST ENCODES AT BEFORE ANY RUN HAS REPORTED. The startup
107
+ // benchmark measures it — a real pipeline over real clips, before a viewer
108
+ // exists — so the plan is never asked to compare arrivals with no speed to
109
+ // compute them from. Every run that then works refines it.
110
+ this.startingSpeedFor = startingSpeedFor;
111
+ this.makeRun = makeRun;
112
+ this.segmentSeconds = segmentSeconds;
113
+ this.logger = logger;
114
+ this.now = typeof now === "function" ? now : Date.now;
115
+ }
116
+
117
+ /**
118
+ * The map of one output, made on first mention.
119
+ *
120
+ * @param {string} address
121
+ * @returns {CoverageMap}
122
+ */
123
+ coverageOf(address) {
124
+ let map = this.#coverage.get(address);
125
+ if (!map) {
126
+ map = new CoverageMap();
127
+ this.#coverage.set(address, map);
128
+ }
129
+ return map;
130
+ }
131
+
132
+ /**
133
+ * Put the map's picture of what is ready back in step with the disk.
134
+ *
135
+ * ONE AUTHORITY ON WHAT EXISTS, AND IT IS THE STORE. The map holds no memory
136
+ * of readiness between calls: it is handed the whole answer, replacing
137
+ * whatever it had, immediately before anything is decided from it. So a
138
+ * segment whose file was discarded with the run that had it open, dropped to
139
+ * make room, or reopened by a run restarting on it stops being ready in the
140
+ * same breath — without anything having to notice and say so.
141
+ *
142
+ * The map used to be filled from outside, by the session manager, with a
143
+ * method that only ever added. Nothing anywhere took a number back. Field
144
+ * 2026-09-07: the map claimed all 482 segments of a film while the directory
145
+ * held nothing, so every arrangement scored perfect, the only encoder was
146
+ * stopped as unnecessary and none was placed again — two sessions in a row
147
+ * with no picture at all.
148
+ *
149
+ * A store is optional here only in the sense that an authority which was not
150
+ * supplied cannot be consulted: without one the map keeps what it was told
151
+ * directly, which is how this class is exercised with plain numbers.
152
+ *
153
+ * @param {string} address
154
+ * @returns {CoverageMap}
155
+ */
156
+ #upToDateCoverage(address) {
157
+ const coverage = this.coverageOf(address);
158
+ if (this.segmentStore) {
159
+ coverage.setReady(this.segmentStore.provenNumbers(address));
160
+ }
161
+ return coverage;
162
+ }
163
+
164
+ /**
165
+ * Where a run started here must stop: the free stretch in front of it.
166
+ *
167
+ * Asked of the one map, brought up to date first. It used to be worked out by
168
+ * the session manager, which reached into this layer for the map and into the
169
+ * store for what was on the disk and put the two together itself — one fact
170
+ * with two owners and a third party carrying it between them, which is how the
171
+ * two came to disagree.
172
+ *
173
+ * @param {object} params
174
+ * @param {string} params.address
175
+ * @param {number} params.from - Where the run will start.
176
+ * @param {object | null} [params.exceptRun] - The run being replaced, whose
177
+ * own claim is not somebody else's material.
178
+ * @param {number} [params.segmentCount] - The output's length, when known.
179
+ * @returns {number} The last number to work through, or `-1` for the end of
180
+ * the film.
181
+ */
182
+ freeStretchEnd({ address, from, exceptRun = null, segmentCount = 0 }) {
183
+ if (!address) {
184
+ return -1;
185
+ }
186
+ if (segmentCount > 0) {
187
+ this.coverageOf(address).setSegmentCount(segmentCount);
188
+ }
189
+ const coverage = this.#upToDateCoverage(address);
190
+ const start = Math.max(0, from);
191
+ const free = coverage.freeRunFrom(start, exceptRun);
192
+ if (!Number.isFinite(free)) {
193
+ return -1;
194
+ }
195
+ const end = start + Math.max(1, free) - 1;
196
+ return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
197
+ }
198
+
199
+ /**
200
+ * @param {string} address
201
+ * @returns {import("../encode/EncodeRun.js").EncodeRun[]}
202
+ */
203
+ runsOn(address) {
204
+ return this.#runs.get(address) ?? [];
205
+ }
206
+
207
+ /**
208
+ * How long an output is, once its playlist is known.
209
+ *
210
+ * @param {string} address
211
+ * @param {number} segmentCount
212
+ */
213
+ setSegmentCount(address, segmentCount) {
214
+ this.coverageOf(address).setSegmentCount(segmentCount);
215
+ }
216
+
217
+ /**
218
+ * Segments that already exist — from a previous life of this process, or
219
+ * because somebody else made them. Told to the map, which is what stops an
220
+ * encoder being started to make them again.
221
+ *
222
+ * @param {string} address
223
+ * @param {Iterable<number>} indexes
224
+ */
225
+ noteAlreadyMade(address, indexes) {
226
+ for (const index of indexes) {
227
+ this.noteProduced(address, index);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * A viewer states what it is waiting for. Replaces whatever it said before.
233
+ *
234
+ * @param {object} params
235
+ * @param {string} params.claimant
236
+ * @param {string} params.address
237
+ * @param {number} params.from
238
+ * @param {number} params.to
239
+ * @param {number} [params.priority] - Higher is sooner. One viewer states
240
+ * several stretches at once — what must be ready before they set off, what
241
+ * is reachable while they watch it, the rest of the track — and the filling
242
+ * takes them in this order. Absent means one undifferentiated want, which
243
+ * is what a caller that knows only a position states.
244
+ */
245
+ /**
246
+ * What is wanted of one output, in its own segment numbers.
247
+ *
248
+ * ONE MAP, ALREADY MERGED, AND WITH NOBODY'S NAME ON IT. It is built once per
249
+ * film by the layer that knows where the viewers are; this layer receives it
250
+ * converted into an output's own numbering and never asks who is in it.
251
+ *
252
+ * That replaced a window per viewer per band stated here and merged here,
253
+ * which was the same work done twice in two layers, with the viewer's name as
254
+ * the key of a claim — against the rule that the encoding and the viewer are
255
+ * not connected at all.
256
+ *
257
+ * An empty map says nobody is coming anywhere in this output, and the plan
258
+ * stops its encoders for it. Nothing has to be released when somebody leaves:
259
+ * the map that arrives next simply does not have them in it.
260
+ *
261
+ * @param {string} address
262
+ * @param {{ from: number, to: number, priority: number, withinSeconds: number }[]} zones
263
+ */
264
+ notePriorityMap(address, zones) {
265
+ this.demand.state(address, zones);
266
+ }
267
+
268
+ /**
269
+ * A segment has been finished, by whichever run made it.
270
+ *
271
+ * @param {string} address
272
+ * @param {number} index
273
+ */
274
+ noteProduced(address, index) {
275
+ // TOLD TO THE AUTHORITY, not only to the map. A piece being closed is a fact
276
+ // about the disk, and the store is what holds those; told to the map alone
277
+ // it would survive exactly until the next time the map is brought back into
278
+ // step, and then be gone with no file to show for it.
279
+ this.segmentStore?.markClosed(address, index);
280
+ this.coverageOf(address).markReady(index);
281
+ for (const run of this.runsOn(address)) {
282
+ run.noteProduced(index);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * @param {string} address
288
+ * @param {object} run
289
+ * @param {number} speedX
290
+ */
291
+ noteSpeed(address, wanted, speedX) {
292
+ for (const run of this.runsOn(address)) {
293
+ if (run === wanted) {
294
+ run.noteSpeed(speedX);
295
+ }
296
+ }
297
+ // HOW FAST THIS MACHINE ENCODES THIS OUTPUT is a property of the machine and
298
+ // the material, not of one process. Read off `run.speedX` alone it was lost
299
+ // at every restart: a moved encoder is a new object that has measured
300
+ // nothing, so the plan fell back to "nothing is known" and stopped comparing
301
+ // arrivals at all — which is every decision in this layer.
302
+ if (speedX > 0 && speedX > (this.#lastSpeed.get(address) ?? 0)) {
303
+ this.#lastSpeed.set(address, speedX);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Decide and act, for every output anybody wants anything of and every output
309
+ * that still has an encoder on it.
310
+ *
311
+ * Safe to call as often as anything changes: the plan is a function of the
312
+ * state, so a pass that finds nothing to change does nothing.
313
+ */
314
+ reconcile() {
315
+ const addresses = new Set([...this.demand.addresses(), ...this.#runs.keys()]);
316
+ for (const address of addresses) {
317
+ this.#reconcileOne(address);
318
+ }
319
+ // WHAT THIS CLASS BELIEVES, said by this class. `describe()` was written
320
+ // and called from nowhere, so on 2026-09-05 the question "why did the plan
321
+ // not see the gap the viewer was stopped at" had to be answered by
322
+ // inference from start and stop lines, and was not answered at all.
323
+ //
324
+ // Printed on CHANGE rather than on a timer: a quiet session says nothing, a
325
+ // session that is deciding something says what it decided, and there is no
326
+ // interval to choose.
327
+ const state = this.describe();
328
+ if (state !== this.#lastDescribed) {
329
+ this.#lastDescribed = state;
330
+ this.logger.info(state);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * @param {string} address
336
+ */
337
+ #reconcileOne(address) {
338
+ // WHAT EXISTS IS ASKED OF THE DISK, HERE, EVERY TIME. The plan is arithmetic
339
+ // over what is made, what is being made and what is wanted, and the first of
340
+ // those is not this layer's to remember.
341
+ const coverage = this.#upToDateCoverage(address);
342
+ // A run that has ended and said nothing. One built here reports its own
343
+ // ending and is released by `noteEnded`; one ADOPTED from elsewhere — a
344
+ // session whose encoder stopped — has no such promise, and its claim would
345
+ // otherwise sit in the map for the life of the process, telling the plan
346
+ // that a stretch nobody is making is being made. Nothing would ever be
347
+ // started there again.
348
+ for (const run of this.runsOn(address)) {
349
+ if (!run.isAlive && !run.isStopping) {
350
+ this.noteEnded({
351
+ address,
352
+ run,
353
+ ending: ENCODE_EXIT.GONE,
354
+ because: "it is no longer running, and it did not say so"
355
+ });
356
+ }
357
+ }
358
+ // ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
359
+ //
360
+ // Two viewers a few seconds apart state stretches that overlap, and the plan
361
+ // puts one encoder on each stretch it is given — so unmerged windows buy an
362
+ // encoder per viewer for film they both want, which is the opposite of what
363
+ // sharing the output is for. Merged, the highest rank and the soonest time
364
+ // per number win and the stretches do not overlap, so one encoder serves
365
+ // everyone standing in front of it.
366
+ //
367
+ // Asked of the register, which is the thing that holds the windows. This
368
+ // used to reach into the layer that STATES them for the same arithmetic,
369
+ // which is the coupling the layer rule forbids; the arithmetic itself now
370
+ // lives where it belongs to nobody.
371
+ const windows = this.demand.mapOn(address);
372
+ const live = this.runsOn(address).filter((run) => run.isAlive);
373
+ // Asked ONCE. It is arithmetic over measurements, but it also says out loud
374
+ // when the reason it cuts the budget changes, so asking it three times in
375
+ // one pass is three chances to say a thing that happened once.
376
+ const maxRuns = this.#affordableOn(address, live);
377
+ const actions = planEncoders({
378
+ coverage,
379
+ windows,
380
+ // The runs themselves. The plan is arithmetic and reads four numbers off
381
+ // each; what it hands back names the run by BEING it, so nothing has to
382
+ // invent a token to refer to one by.
383
+ runs: live,
384
+ maxRuns,
385
+ segmentSeconds: this.segmentSeconds,
386
+ // What a start and a kill cost, measured from this host's own runs rather
387
+ // than written into the code from one machine's reading. Zero until
388
+ // something has been measured, which is the same convention as the
389
+ // refetch price below and is stated so the bias is known.
390
+ ...this.#costs.seconds(),
391
+ // What a second of film costs to fetch again, in seconds of swarm time.
392
+ // Answered by whoever measures the film's own byte rate and the swarm's;
393
+ // zero until they have, which makes driving through look cheaper than it
394
+ // is and is stated here so the bias is known.
395
+ refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
396
+ // How much slower one encoder runs beside others, read off this host's own
397
+ // startup measurement. A pure function over a measured table: beyond what
398
+ // was measured it holds the largest reading rather than continuing a curve
399
+ // nothing observed.
400
+ contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
401
+ // The best figure this host has: what a run here is doing now, what one
402
+ // was last measured doing, or what the startup benchmark predicted. The
403
+ // first two are this output's own; the third exists before either.
404
+ speedX: Math.max(
405
+ live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
406
+ this.#lastSpeed.get(address) ?? 0,
407
+ this.startingSpeedFor(address) || 0
408
+ )
409
+ });
410
+
411
+ // A move is the plan taking a running encoder away from where it already
412
+ // stands, which is exactly the decision that was found wandering back and
413
+ // forth in the field on 2026-09-07 with no way to see why: the "because"
414
+ // line names the comparison in words, never the numbers it was decided
415
+ // from. Said here, once per reconcile, and only when a move actually
416
+ // happens — everything a rerun of the same decision needs: the windows
417
+ // this call saw (priority, the real time, which side of the viewers),
418
+ // the budget, and where every live run stood.
419
+ if (actions.some((action) => action.type === "move")) {
420
+ this.logger.info(
421
+ `encode-plan move on ${address}: windows=${JSON.stringify(windows)} ` +
422
+ `maxRuns=${maxRuns} ` +
423
+ `live=${JSON.stringify(live.map((run) => ({ from: run.from, to: run.to, head: run.head, speedX: run.speedX })))}`
424
+ );
425
+ }
426
+ for (const action of actions) {
427
+ if (action.type === "stop") {
428
+ this.#stop(action.run, action.because);
429
+ continue;
430
+ }
431
+ if (action.type === "move") {
432
+ // A running encoder's position cannot be changed — it is fixed when the
433
+ // process starts — so a move is this one ending and another beginning
434
+ // where the material is missing. Both halves are recorded as what they
435
+ // are, which is why the ending of a moved run is not called normal.
436
+ this.#stop(action.run, action.because);
437
+ this.#start(address, action.from, action.to, action.because);
438
+ continue;
439
+ }
440
+ if (action.type === "start") {
441
+ this.#start(address, action.from, action.to, action.because);
442
+ continue;
443
+ }
444
+ // A run that stays keeps its claim current: the free stretch ahead of it
445
+ // may have shrunk since it was given one.
446
+ //
447
+ // THE CLAIM IS THE STRETCH IT WAS GIVEN, and there is one rule for that
448
+ // everywhere. It used to be narrowed here to what the run had already
449
+ // MADE whenever the run had no end, which meant a run claimed the single
450
+ // number it was writing and nothing beyond. The plan then read the road
451
+ // in front of a working encoder as free and started more encoders on it:
452
+ // three processes writing one directory with the same names, field
453
+ // 2026-09-06, and a piece of the film lost for good when the first of
454
+ // them was cleaned up after.
455
+ //
456
+ // The worry that narrowing was written for is real and is answered where
457
+ // it belongs — a viewer opening the same film further in must not find
458
+ // every number taken. That is the plan's business, and the plan can take
459
+ // road away from a run that has no end, because such a run carries no
460
+ // `-to` and simply stops when its head meets somebody else's claim.
461
+ coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
462
+ }
463
+
464
+ // NOBODY IS MAKING WHAT SOMEBODY IS WAITING FOR. Said here, with the numbers
465
+ // the decision was taken from, because it is the one state in which a viewer
466
+ // waits for ever and every line above it reads as a healthy proxy.
467
+ //
468
+ // Field 2026-09-07, twice in one evening: the last word about an output was
469
+ // "the film is no worse off without it", and after it nothing — no run, no
470
+ // refusal, no answer to the browser's request for the header. The wait ended
471
+ // at the browser's own timeout with a message naming no cause, and the
472
+ // proxy's log named none either.
473
+ //
474
+ // SAID ONCE PER STATE, not once per pass. A stuck output is reconciled on
475
+ // every event that touches it, and a line repeated for as long as the state
476
+ // lasts is what buried the last one: 49 295 copies of `send queue stuck` in
477
+ // a 159 090-line file, 31 % of the log, all of one wedge.
478
+ const wanting = firstUnmetWant(coverage, windows);
479
+ const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
480
+ if (wanting === null || stillRunning.length > 0) {
481
+ this.#lastUnmet.delete(address);
482
+ } else if (this.#lastUnmet.get(address) !== wanting) {
483
+ this.#lastUnmet.set(address, wanting);
484
+ const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
485
+ this.logger.warn(
486
+ `encode: #${wanting} of ${address} is wanted and NO ENCODER IS MAKING IT — ` +
487
+ `ready=${coverage.stats().ready} of ${coverage.segmentCount} ` +
488
+ `files=${held < 0 ? "?" : held} maxRuns=${maxRuns} ` +
489
+ `windows=${JSON.stringify(windows)}`
490
+ );
491
+ }
492
+ }
493
+
494
+ /**
495
+ * @param {string} address
496
+ * @param {number} from
497
+ * @param {number} to
498
+ * @param {string} because
499
+ */
500
+ #start(address, from, to, because) {
501
+ // The encoder is built here and now: whoever builds one waits for nothing,
502
+ // so it exists by the time this line returns. That is what makes the
503
+ // stretch held from this instant — this class knows what it is making
504
+ // because it has just made it, and no second encoder can be started for the
505
+ // same stretch on the next pass.
506
+ //
507
+ // It was not always so. The builder used to answer with nothing and start
508
+ // the encoder behind the answer, so the stretch stayed FREE for as long as
509
+ // that took, and every pass in between started another one: 684 starts in
510
+ // 482 seconds of field 2026-09-05, of which 973 answers said the encoder
511
+ // was not there yet — every start without exception.
512
+ //
513
+ // The run names itself: identity is a property of the thing, and two
514
+ // places minting names is how one stops being unique.
515
+ const run = this.makeRun({ address, from, to });
516
+ if (!run) {
517
+ // A refusal, not a wait: no session serves this output, or this position
518
+ // has failed to start too many times running.
519
+ this.logger.warn(`encode: no encoder could be made for #${from}..#${to} of ${address}`);
520
+ return;
521
+ }
522
+ // What this machine has been measured to do on this output, carried over.
523
+ // A restart does not make the machine slower, and without this every moved
524
+ // encoder began as one whose speed nothing had measured — which the plan
525
+ // reads as "no arrival can be computed" and answers by comparing nothing.
526
+ const known = this.#lastSpeed.get(address) ?? 0;
527
+ if (known > 0) {
528
+ run.noteSpeed(known);
529
+ }
530
+ const onThisOutput = this.#runs.get(address) ?? [];
531
+ onThisOutput.push(run);
532
+ this.#runs.set(address, onThisOutput);
533
+ // This run rewrites the stretch it was given, so what was closed inside that
534
+ // stretch is no longer closed. Without this a number closed by an earlier run
535
+ // stays servable while a later one is halfway through writing it again.
536
+ //
537
+ // Bounded by the run's own end, which is the same number the claim below
538
+ // carries. Unbounded it unproved the whole film beyond the start of any run,
539
+ // and readiness is now a projection of what is proven — so a one-segment run
540
+ // at the beginning would have declared the rest of the output unmade.
541
+ const runsTo = endOfRun({ from, to });
542
+ this.segmentStore?.forgetClosed(address, from, runsTo);
543
+ this.coverageOf(address).claim(run, from, runsTo);
544
+ run.start(because);
545
+ }
546
+
547
+ /**
548
+ * How many encoders may run on this output, from every limit at once.
549
+ *
550
+ * The processor is one of them and is answered from outside, where the
551
+ * machine is measured. The other two are known here: what the swarm delivers,
552
+ * through the seconds of swarm time a second of film costs, and — once it is
553
+ * supplied — the memory the piece store may hold against what one encoder's
554
+ * reader keeps.
555
+ *
556
+ * Said out loud when it is not the processor that decided, because "why is
557
+ * there only one encoder" is otherwise a question no log can answer.
558
+ *
559
+ * @param {string} address
560
+ * @param {{ speedX: number }[]} live
561
+ * @returns {number}
562
+ */
563
+ #affordableOn(address, live) {
564
+ const byProcessor = Math.max(0, this.maxRunsFor(address));
565
+ // The best figure this host has: what a run here is doing now, what one was
566
+ // last measured doing, or what the startup benchmark predicted. The first
567
+ // two are this output's own; the third exists before either, so the budget
568
+ // is never asked to price encoders at a speed of zero.
569
+ const fastest = Math.max(
570
+ live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
571
+ this.#lastSpeed.get(address) ?? 0,
572
+ this.startingSpeedFor(address) || 0
573
+ );
574
+ const budget = affordableRuns({
575
+ byProcessor,
576
+ speedX: fastest,
577
+ refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
578
+ // How much slower one encoder runs beside others, read off this host's own
579
+ // startup measurement. A pure function over a measured table: beyond what
580
+ // was measured it holds the largest reading rather than continuing a curve
581
+ // nothing observed.
582
+ contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty
583
+ });
584
+ if (budget.runs !== byProcessor && budget.because !== this.#lastBudgetReason.get(address)) {
585
+ this.#lastBudgetReason.set(address, budget.because);
586
+ this.logger.info(
587
+ `encode: ${budget.runs} encoder(s) on ${address.slice(0, 60)} — ${budget.because} ` +
588
+ `(the processor alone would allow ${byProcessor})`
589
+ );
590
+ }
591
+ return budget.runs;
592
+ }
593
+
594
+ /**
595
+ * Take charge of a run this class did not start.
596
+ *
597
+ * The browser asks for a stream and a run begins for it, long before this
598
+ * class has an opinion. Left unknown, that run would be invisible to the plan
599
+ * — which would then start a second encoder over the same numbers, believing
600
+ * nothing was being made there. So whoever starts one hands it over, and from
601
+ * then on it is planned like any other.
602
+ *
603
+ * @param {string} address
604
+ * @param {{ id: string, from: number, to: number, head: number, speedX: number, isAlive: boolean, stop: (because: string) => void }} run
605
+ */
606
+ adopt(address, run) {
607
+ if (!run) {
608
+ return;
609
+ }
610
+ const onThisOutput = this.#runs.get(address) ?? [];
611
+ if (onThisOutput.includes(run)) {
612
+ return;
613
+ }
614
+ onThisOutput.push(run);
615
+ this.#runs.set(address, onThisOutput);
616
+ this.coverageOf(address).claim(run, run.from, endOfRun(run));
617
+ }
618
+
619
+ /**
620
+ * @param {object} run
621
+ * @param {string} because
622
+ */
623
+ #stop(run, because) {
624
+ run.stop(because);
625
+ }
626
+
627
+ /**
628
+ * A run has ended, however it ended. Its stretch goes back to the map — what
629
+ * it finished stays made — and the ending is counted.
630
+ *
631
+ * Wired by whoever builds the run, so that a run built outside this class is
632
+ * still accounted for.
633
+ *
634
+ * @param {import("../encode/EncodeRun.js").RunEnded} ended
635
+ */
636
+ noteEnded(ended) {
637
+ this.#costs.note(ended);
638
+ // Exactly one ending is normal — the run reached the end of the stretch it
639
+ // was given and closed its last file. Every other leaves a piece open, and
640
+ // that file looks finished however the run ended: stopped, ffmpeg writes it
641
+ // out and names it like any other; killed harder, it leaves the bytes it
642
+ // had. Either way it decodes and holds less film than its number promises.
643
+ // So what is kept is what the run PROVED it finished, and nothing beyond.
644
+ if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
645
+ void this.segmentStore
646
+ .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
647
+ .catch(() => {});
648
+ }
649
+ this.coverageOf(ended.address).release(ended.run);
650
+ const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
651
+ if (remaining.length === 0) {
652
+ this.#runs.delete(ended.address);
653
+ } else {
654
+ this.#runs.set(ended.address, remaining);
655
+ }
656
+ this.#endings.set(ended.ending, (this.#endings.get(ended.ending) ?? 0) + 1);
657
+ }
658
+
659
+ /**
660
+ * How runs have ended over the life of this process, by cause.
661
+ *
662
+ * The abnormal classes are meant to stand at zero. Without the count,
663
+ * "we understand why it ended" is indistinguishable from "we noticed it once".
664
+ *
665
+ * @returns {Record<string, number>}
666
+ */
667
+ endings() {
668
+ /** @type {Record<string, number>} */
669
+ const tally = {};
670
+ for (const ending of Object.values(ENCODE_EXIT)) {
671
+ tally[ending] = this.#endings.get(ending) ?? 0;
672
+ }
673
+ return tally;
674
+ }
675
+
676
+ /**
677
+ * One line saying what this proxy is encoding and whether anybody is waiting.
678
+ *
679
+ * `waiting` is the point of it: a proxy with encoders running and a viewer
680
+ * still stopped at a segment nobody is making is the failure this layer was
681
+ * built to remove, and it is visible here rather than inferred from a log.
682
+ *
683
+ * @returns {string}
684
+ */
685
+ describe() {
686
+ const parts = [];
687
+ for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
688
+ const coverage = this.coverageOf(address);
689
+ const stated = this.demand.mapOn(address);
690
+ const windows = stated.map((zone) => ({ from: zone.from, to: zone.to }));
691
+ const waiting = firstUnmetWant(coverage, windows);
692
+ const runs = this.runsOn(address)
693
+ .map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
694
+ .join(" ");
695
+ // The zones as they were stated, with their order, so a plan that is
696
+ // working at the wrong end of the film is visible rather than inferred.
697
+ const zones = [...stated]
698
+ .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from)
699
+ .map((w) => `p${w.priority ?? 0}:#${w.from}..#${w.to}`)
700
+ .join(" ");
701
+ // THE WHOLE ADDRESS. Cut to sixty characters, every output of one film
702
+ // printed the same string — the picture, its quality steps and each
703
+ // soundtrack are told apart only by the tail — so three lines of this
704
+ // could not be matched to the three things they describe. Read on
705
+ // 2026-09-07 while accounting for a session that produced nothing, and
706
+ // the accounting had to be done by which line carried a run.
707
+ //
708
+ // And WHAT THE DISK HOLDS beside what is proven closed. They are two
709
+ // different statements: files with nothing proving them closed reads as a
710
+ // reporting fault, no files at all reads as an output yet to be made, and
711
+ // the difference decides where to look.
712
+ const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
713
+ parts.push(
714
+ `${address} ready=${coverage.stats().ready}` +
715
+ `${held < 0 ? "" : ` of ${held} file(s) on disk`} ` +
716
+ `zones=[${zones}] runs=[${runs}] ` +
717
+ `waiting=${waiting === null ? "nobody" : `#${waiting}`}`
718
+ );
719
+ }
720
+ const tally = this.endings();
721
+ const endings = Object.entries(tally)
722
+ .map(([cause, count]) => `${cause}=${count}`)
723
+ .join(" ");
724
+ return `encode: ${parts.length === 0 ? "nothing wanted" : parts.join(" | ")} :: endings ${endings}`;
725
+ }
726
+ }