@torrent-tv/proxy 2.80.0 → 2.80.2
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 +1576 -1558
- package/package.json +1 -1
- package/server.js +16 -0
- package/services/demand/DemandRegister.js +20 -0
- package/services/download/SwarmSelection.js +4 -1
- package/services/encode/EncodePlan.js +159 -31
- package/services/encode/SegmentStore.js +29 -0
- package/services/encode/run-budget.js +8 -0
- package/services/hls-session-manager.js +25 -8
- package/services/orchestrators/EncodeOrchestrator.js +27 -5
- package/services/piece-store/piece-lru.js +91 -31
- package/services/piece-store/shared-piece-store.js +1531 -1512
- package/services/priority/PriorityMap.js +57 -101
- package/services/priority/PriorityOrchestrator.js +149 -0
- package/services/torrent-pool.js +104 -0
- package/services/torrent-worker/client.js +20 -0
- package/services/torrent-worker/piece-reader.js +57 -217
- package/services/torrent-worker/pool-adapter.js +17 -0
- package/services/torrent-worker/protocol.js +10 -0
- package/services/torrent-worker/worker.js +11 -0
- package/test/demand-register.test.js +11 -0
- package/test/encode-plan.test.js +65 -0
- package/test/piece-lru.test.js +61 -0
- package/test/piece-store-eviction.test.js +30 -0
- package/test/priority-map-download.test.js +156 -0
- package/test/priority-map.test.js +50 -77
- package/test/read-window.test.js +46 -29
- package/test/read-bands.test.js +0 -140
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -172,6 +172,22 @@ export async function startProxyServer({
|
|
|
172
172
|
}
|
|
173
173
|
return torrentPool.getTorrentTotals();
|
|
174
174
|
},
|
|
175
|
+
// The priority map, on its way to the downloading. It lives in another
|
|
176
|
+
// thread, so the map crosses the worker channel; what it does with it —
|
|
177
|
+
// seconds into bytes, what to ask the swarm for, what to keep in memory —
|
|
178
|
+
// is its own business.
|
|
179
|
+
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
180
|
+
const record = sourceRegistry.get(sourceKey);
|
|
181
|
+
if (!record) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
|
|
186
|
+
} catch {
|
|
187
|
+
// Best effort: the map is republished on the next change, and the
|
|
188
|
+
// downloading goes on serving reads meanwhile.
|
|
189
|
+
}
|
|
190
|
+
},
|
|
175
191
|
getSourceStats: async (sourceKey, fileIndex) => {
|
|
176
192
|
const record = sourceRegistry.get(sourceKey);
|
|
177
193
|
if (!record) {
|
|
@@ -84,6 +84,26 @@ export class DemandRegister {
|
|
|
84
84
|
return this.windows().filter((window) => window.urgency === urgency);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* How urgently one byte of one file is wanted, by whoever wants it most.
|
|
89
|
+
*
|
|
90
|
+
* The priority map states its zones here like anybody else, so this answers
|
|
91
|
+
* for a byte nobody is reading yet: it is how a wait is attributed to a level
|
|
92
|
+
* without the reader keeping a forecast of its own.
|
|
93
|
+
*
|
|
94
|
+
* @param {number} fileIndex
|
|
95
|
+
* @param {number} byteOffset
|
|
96
|
+
* @returns {number | null} Null when nothing covers that byte.
|
|
97
|
+
*/
|
|
98
|
+
urgencyAt(fileIndex, byteOffset) {
|
|
99
|
+
// `windows()` is sorted most urgent first, so the first cover is the answer.
|
|
100
|
+
const found = this.windows().find((window) =>
|
|
101
|
+
window.fileIndex === fileIndex &&
|
|
102
|
+
byteOffset >= window.byteStart &&
|
|
103
|
+
byteOffset <= window.byteEnd);
|
|
104
|
+
return found ? found.urgency : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
/**
|
|
88
108
|
* The union of what is wanted of one file at one level, in byte ranges.
|
|
89
109
|
*
|
|
@@ -224,7 +224,10 @@ export class SwarmSelection {
|
|
|
224
224
|
if (!range) {
|
|
225
225
|
continue;
|
|
226
226
|
}
|
|
227
|
-
|
|
227
|
+
// The level goes with the range: it is what eviction compares when
|
|
228
|
+
// everything resident is wanted by somebody, and dropping it here is
|
|
229
|
+
// what left the store choosing by recency alone.
|
|
230
|
+
store.protectRange(window.claimant, range.from, range.to, window.urgency);
|
|
228
231
|
holding.add(window.claimant);
|
|
229
232
|
}
|
|
230
233
|
}
|
|
@@ -232,37 +232,51 @@ export function planEncoders({
|
|
|
232
232
|
// lowest number, because that is where a viewer is stopped. The budget
|
|
233
233
|
// rarely stretches to every gap, so which one is taken first is the whole
|
|
234
234
|
// of what a viewer's presence decides.
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
235
|
+
const budget = Math.max(0, maxRuns - surviving.size);
|
|
236
|
+
const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
|
|
237
|
+
for (const from of placeEncoders({
|
|
238
|
+
coverage,
|
|
239
|
+
windows: wanted,
|
|
240
|
+
howMany: budget,
|
|
241
|
+
speedX: live.reduce((best, run) => Math.max(best, run.speedX || 0), 0)
|
|
242
|
+
})) {
|
|
243
|
+
if (alreadyPlanned.has(from)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
alreadyPlanned.add(from);
|
|
247
|
+
starts.push({
|
|
248
|
+
type: "start",
|
|
249
|
+
from,
|
|
250
|
+
to: endOfStretch(from, coverage.freeRunFrom(from)),
|
|
251
|
+
because: `#${from} is wanted and nobody is making it`
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ONE ENCODER'S WORK ENDS WHERE THE NEXT ONE'S BEGINS.
|
|
256
|
+
//
|
|
257
|
+
// A free stretch may run to the end of the track, and an encoder given all of
|
|
258
|
+
// it stands in the road of every encoder placed behind it: they write the
|
|
259
|
+
// same names, and each one's output is the other's "material somebody else
|
|
260
|
+
// made", so they stop one another. Field 2026-09-05: three encoders started
|
|
261
|
+
// on one track within 200 ms, each into the road another was already writing.
|
|
262
|
+
// Fifteen readers on a piece store that holds sixteen pieces followed, half
|
|
263
|
+
// of all evictions took a piece a reader had declared, and `/stream` began
|
|
264
|
+
// handing out bytes that were not the file's.
|
|
265
|
+
//
|
|
266
|
+
// The bound is taken from the NEXT ENCODER'S START, not from a band edge: a
|
|
267
|
+
// band edge travels with the viewer, so every step forward would leave a
|
|
268
|
+
// sliver just past the previous encoder and buy an encoder for it.
|
|
269
|
+
const placed = [...moves, ...starts].sort(
|
|
270
|
+
(left, right) => /** @type {any} */ (left).from - /** @type {any} */ (right).from
|
|
271
|
+
);
|
|
272
|
+
for (let index = 0; index < placed.length - 1; index += 1) {
|
|
273
|
+
const here = /** @type {{ from: number, to: number }} */ (placed[index]);
|
|
274
|
+
const next = /** @type {{ from: number }} */ (placed[index + 1]);
|
|
275
|
+
if (here.to < 0 || here.to >= next.from) {
|
|
276
|
+
here.to = next.from - 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
266
280
|
return [...stops, ...moves, ...starts, ...keeps];
|
|
267
281
|
}
|
|
268
282
|
|
|
@@ -307,3 +321,117 @@ export function firstUnmetWant(coverage, windows) {
|
|
|
307
321
|
}
|
|
308
322
|
return lowest;
|
|
309
323
|
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Where to put the encoders this machine can afford.
|
|
327
|
+
*
|
|
328
|
+
* Two things are wanted of a division of the film, and they are wanted in this
|
|
329
|
+
* order:
|
|
330
|
+
*
|
|
331
|
+
* 1. **the viewer must not stop.** An encoder starting at `q` stays ahead of a
|
|
332
|
+
* viewer at `p` while `y / s <= q + y - p`, so it holds `(q - p) * s / (1-s)`
|
|
333
|
+
* of film and no more. Beyond that the viewer catches it, and the next
|
|
334
|
+
* encoder has to be standing there. That is where the first ones go, and it
|
|
335
|
+
* is why the stretches grow: the further off one starts, the later the
|
|
336
|
+
* viewer arrives and the longer it may work;
|
|
337
|
+
* 2. **the film should be finished as soon as possible.** Once the viewer is
|
|
338
|
+
* safe, whatever is left is divided EQUALLY between the encoders that
|
|
339
|
+
* remain: equal shares finish together, and any other division finishes when
|
|
340
|
+
* its longest share does. That is what makes seeking cheap — the film exists.
|
|
341
|
+
*
|
|
342
|
+
* At or above realtime the first requirement is met by one encoder for the
|
|
343
|
+
* whole film, and every other encoder goes to the second — which is the common
|
|
344
|
+
* case on a copied picture, and is why "one viewer, one encoder" was never the
|
|
345
|
+
* rule.
|
|
346
|
+
*
|
|
347
|
+
* @param {object} params
|
|
348
|
+
* @param {import("./CoverageMap.js").CoverageMap} params.coverage
|
|
349
|
+
* @param {WantedSpan[]} params.windows - The merged map, in this output's own
|
|
350
|
+
* numbering. Its highest-numbered band starts where the viewer is.
|
|
351
|
+
* @param {number} params.howMany - What the machine affords.
|
|
352
|
+
* @param {number} params.speedX - Measured. Zero when nothing has measured it,
|
|
353
|
+
* and then only the first requirement can be served.
|
|
354
|
+
* @returns {number[]} Where to start each encoder, ascending.
|
|
355
|
+
*/
|
|
356
|
+
export function placeEncoders({ coverage, windows, howMany, speedX }) {
|
|
357
|
+
if (!(howMany > 0) || windows.length === 0) {
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
const byUrgency = [...windows].sort(
|
|
361
|
+
(left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from
|
|
362
|
+
);
|
|
363
|
+
const viewer = byUrgency[0].from;
|
|
364
|
+
const lastWanted = Math.max(...windows.map((span) => span.to));
|
|
365
|
+
|
|
366
|
+
/** @type {number[]} */
|
|
367
|
+
const places = [];
|
|
368
|
+
const take = (at) => {
|
|
369
|
+
const gap = coverage.firstGapFrom(at, lastWanted);
|
|
370
|
+
if (gap !== null && !places.includes(gap)) {
|
|
371
|
+
places.push(gap);
|
|
372
|
+
}
|
|
373
|
+
return gap;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// The first one goes where somebody is stopped.
|
|
377
|
+
let previous = take(viewer);
|
|
378
|
+
if (previous === null) {
|
|
379
|
+
// Nothing ahead is missing. Whatever is left anywhere is divided equally.
|
|
380
|
+
return divideEqually(coverage, byUrgency, howMany, places);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const growth = speedX > 0 && speedX < 1 ? speedX / (1 - speedX) : Number.POSITIVE_INFINITY;
|
|
384
|
+
while (places.length < howMany) {
|
|
385
|
+
const holds = (previous - viewer) * growth;
|
|
386
|
+
if (!Number.isFinite(holds) || previous + holds > lastWanted) {
|
|
387
|
+
// Either it keeps ahead for the rest of the film, or its guarantee runs
|
|
388
|
+
// past the end of what anybody wants. Nobody is in danger further on, so
|
|
389
|
+
// the encoders left over go to finishing the film sooner.
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
const next = take(Math.ceil(previous + Math.max(1, holds)));
|
|
393
|
+
if (next === null || next <= previous) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
previous = next;
|
|
397
|
+
}
|
|
398
|
+
return divideEqually(coverage, byUrgency, howMany, places);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Spread whatever encoders are left over the film that is still missing.
|
|
403
|
+
*
|
|
404
|
+
* Equal shares, because equal shares finish together: any other division is
|
|
405
|
+
* finished when its longest share is, which is later.
|
|
406
|
+
*
|
|
407
|
+
* @param {import("./CoverageMap.js").CoverageMap} coverage
|
|
408
|
+
* @param {WantedSpan[]} byUrgency
|
|
409
|
+
* @param {number} howMany
|
|
410
|
+
* @param {number[]} places
|
|
411
|
+
* @returns {number[]}
|
|
412
|
+
*/
|
|
413
|
+
function divideEqually(coverage, byUrgency, howMany, places) {
|
|
414
|
+
for (const span of byUrgency) {
|
|
415
|
+
if (places.length >= howMany) {
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
const gap = coverage.firstGapFrom(span.from, span.to);
|
|
419
|
+
if (gap === null) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
// Where this band's missing film would be cut if the encoders left over
|
|
423
|
+
// shared it. One share per encoder, and the first share is the gap itself.
|
|
424
|
+
const left = howMany - places.length;
|
|
425
|
+
const width = Math.max(1, Math.floor((span.to - gap + 1) / left));
|
|
426
|
+
for (let at = gap; at <= span.to && places.length < howMany; at += width) {
|
|
427
|
+
const found = coverage.firstGapFrom(at, span.to);
|
|
428
|
+
if (found === null) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
if (!places.includes(found)) {
|
|
432
|
+
places.push(found);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return [...places].sort((left, right) => left - right);
|
|
437
|
+
}
|
|
@@ -258,6 +258,35 @@ export class SegmentStore {
|
|
|
258
258
|
return proven.sort((left, right) => left - right);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* A run is about to write these numbers again: forget that they were closed.
|
|
263
|
+
*
|
|
264
|
+
* A number closed once is not closed for ever. An encoder started at #N
|
|
265
|
+
* rewrites #N and everything after it, and while it is doing so the file
|
|
266
|
+
* under that name is half a segment — but the store remembered the earlier
|
|
267
|
+
* closing and would call it whole. Field 2026-09-05: seventeen runs were
|
|
268
|
+
* stopped and none ended normally, so numbers were being rewritten
|
|
269
|
+
* constantly, and the player met a fatal append error it never recovered
|
|
270
|
+
* from — an empty picture for the six minutes that followed.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} key
|
|
273
|
+
* @param {number} from
|
|
274
|
+
*/
|
|
275
|
+
forgetClosedFrom(key, from) {
|
|
276
|
+
const known = this.#closed.get(key);
|
|
277
|
+
if (!known || !Number.isInteger(from)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
for (const index of known) {
|
|
281
|
+
if (index >= from) {
|
|
282
|
+
known.delete(index);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// What the directory says has to be read again too: the successor rule
|
|
286
|
+
// would otherwise prove the rewritten piece from a file made before it.
|
|
287
|
+
this.#held.delete(key);
|
|
288
|
+
}
|
|
289
|
+
|
|
261
290
|
/**
|
|
262
291
|
* Whether this piece is finished, and may therefore be served.
|
|
263
292
|
*
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Which of them bound the answer is returned beside it, because "why is there
|
|
26
26
|
* only one encoder" is otherwise a question no log can answer.
|
|
27
|
+
*
|
|
28
|
+
* **Only the first is supplied today.** The two readings the others need are
|
|
29
|
+
* both in the torrent thread — what the swarm delivers against the film's own
|
|
30
|
+
* byte rate, and the store's allowance against what one reader keeps — and
|
|
31
|
+
* carrying them across is its own piece of work, deliberately not done here.
|
|
32
|
+
* Until it is, the swarm and the memory terms are inert: this returns what the
|
|
33
|
+
* processor allows, and says so. Stated rather than left to be discovered from
|
|
34
|
+
* a parameter nobody passes.
|
|
27
35
|
*/
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -24,6 +24,7 @@ import { availableShareFrom } from "./available-share.js";
|
|
|
24
24
|
import { contentionPenalty } from "./contention.js";
|
|
25
25
|
import { minimumBufferFrom } from "./supply-margin.js";
|
|
26
26
|
import { mapForViewer } from "./priority/PriorityMap.js";
|
|
27
|
+
import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
|
|
27
28
|
import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
|
|
28
29
|
import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
|
|
29
30
|
import {
|
|
@@ -1522,6 +1523,7 @@ export class HlsSessionManager {
|
|
|
1522
1523
|
softwarePresetBenchmark = null,
|
|
1523
1524
|
decodeCostModel = null,
|
|
1524
1525
|
getSourceStats = null,
|
|
1526
|
+
setPriorityMap = null,
|
|
1525
1527
|
// What a second job costs on this host, measured at startup. Null when it
|
|
1526
1528
|
// could not be measured, and then nothing is corrected — the alternative
|
|
1527
1529
|
// is inventing a penalty, which is the same fault as inventing a fill rate.
|
|
@@ -1578,6 +1580,7 @@ export class HlsSessionManager {
|
|
|
1578
1580
|
// realtime budget to tell a CPU limit from a download-starved input:
|
|
1579
1581
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
1580
1582
|
this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
|
|
1583
|
+
this.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
|
|
1581
1584
|
this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
1582
1585
|
// Totals across every torrent this proxy holds, used to price what the
|
|
1583
1586
|
// torrent itself costs the machine (item 7). Optional: a proxy wired
|
|
@@ -1669,6 +1672,21 @@ export class HlsSessionManager {
|
|
|
1669
1672
|
encodersRunningNow: () => this.#encodersRunningNow(),
|
|
1670
1673
|
torrentCostSecFor: (session) => this.#torrentCostSecFor(session)
|
|
1671
1674
|
});
|
|
1675
|
+
// The priority map, built from where the viewers are and handed to both
|
|
1676
|
+
// sides that act on it. The downloading lives in another thread, so its
|
|
1677
|
+
// copy travels over the worker channel.
|
|
1678
|
+
this.priority = new PriorityOrchestrator({
|
|
1679
|
+
publish: ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
1680
|
+
void Promise.resolve(
|
|
1681
|
+
this.setPriorityMap?.({ sourceKey, fileIndex, durationSeconds, zones })
|
|
1682
|
+
).catch(() => {});
|
|
1683
|
+
},
|
|
1684
|
+
viewersOf: (session) => viewersOf(session),
|
|
1685
|
+
allowanceFor: (session) => minimumBufferFrom({
|
|
1686
|
+
segmentSeconds: this.segmentDurationSec,
|
|
1687
|
+
worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
|
|
1688
|
+
})?.seconds ?? this.segmentDurationSec
|
|
1689
|
+
});
|
|
1672
1690
|
this.encodeOrchestrator = new EncodeOrchestrator({
|
|
1673
1691
|
maxRunsFor: (address) => this.maxRunsForOutput(address),
|
|
1674
1692
|
makeRun: ({ address, from, to }) => this.#makeRunAt(address, from, to),
|
|
@@ -3710,7 +3728,7 @@ export class HlsSessionManager {
|
|
|
3710
3728
|
* @returns {{from: number, to: number, priority: number}[]} In segment
|
|
3711
3729
|
* numbers, both ends inclusive.
|
|
3712
3730
|
*/
|
|
3713
|
-
#demandZonesFor(session, atSeconds) {
|
|
3731
|
+
#demandZonesFor(session, atSeconds, playing = true) {
|
|
3714
3732
|
const boundaries = session.timeline?.boundaries ?? [];
|
|
3715
3733
|
const segmentCount = Number(session.timeline?.segmentCount) || 0;
|
|
3716
3734
|
// Where they are, in this output's own numbering. The viewer holds seconds;
|
|
@@ -3731,12 +3749,7 @@ export class HlsSessionManager {
|
|
|
3731
3749
|
segmentSeconds: this.segmentDurationSec,
|
|
3732
3750
|
worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
|
|
3733
3751
|
})?.seconds ?? this.segmentDurationSec,
|
|
3734
|
-
|
|
3735
|
-
// measures everywhere else it prices an encode. `progress.speed` is
|
|
3736
|
-
// ffmpeg's own cumulative figure and includes the run's start, so it
|
|
3737
|
-
// reads low for the first seconds of every run; `recentSpeed` is the
|
|
3738
|
-
// reading taken over a window and is the one the budget already trusts.
|
|
3739
|
-
encodeSpeedX: Number(session.recentSpeed?.speed) || Number(session.progress?.speed) || 0
|
|
3752
|
+
playing
|
|
3740
3753
|
});
|
|
3741
3754
|
/** @type {{from: number, to: number, priority: number}[]} */
|
|
3742
3755
|
const inSegments = [];
|
|
@@ -3943,7 +3956,7 @@ export class HlsSessionManager {
|
|
|
3943
3956
|
// consulted: the 120 seconds that used to size this window were the
|
|
3944
3957
|
// suspended encoder's threshold, one chosen number answering seven
|
|
3945
3958
|
// different questions.
|
|
3946
|
-
for (const zone of this.#demandZonesFor(session, at)) {
|
|
3959
|
+
for (const zone of this.#demandZonesFor(session, at, viewer.playing !== false)) {
|
|
3947
3960
|
this.encodeOrchestrator.want({
|
|
3948
3961
|
// The claimant is the PERSON, without the priority in it: their
|
|
3949
3962
|
// zones are separate windows, but they leave together, and
|
|
@@ -3958,6 +3971,10 @@ export class HlsSessionManager {
|
|
|
3958
3971
|
}
|
|
3959
3972
|
}
|
|
3960
3973
|
}
|
|
3974
|
+
this.priority.publishFor({
|
|
3975
|
+
sessionGroups: byOutput.values(),
|
|
3976
|
+
staleAfterMs: this.presenceStaleAfterMs()
|
|
3977
|
+
});
|
|
3961
3978
|
this.encodeOrchestrator.reconcile();
|
|
3962
3979
|
}
|
|
3963
3980
|
|
|
@@ -27,6 +27,7 @@ import { CoverageMap } from "../encode/CoverageMap.js";
|
|
|
27
27
|
import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
|
|
28
28
|
import { endOfRun } from "../encode/EncodeRun.js";
|
|
29
29
|
import { ENCODE_EXIT } from "../encode/encode-exit.js";
|
|
30
|
+
import { mergeMaps } from "../priority/PriorityMap.js";
|
|
30
31
|
import { affordableRuns } from "../encode/run-budget.js";
|
|
31
32
|
import { RunCosts } from "../encode/run-costs.js";
|
|
32
33
|
import { SegmentDemand } from "../encode/SegmentDemand.js";
|
|
@@ -244,11 +245,28 @@ export class EncodeOrchestrator {
|
|
|
244
245
|
// order: what a viewer must have before they set off comes before what is
|
|
245
246
|
// merely in front of them, which comes before the rest of the track. Passed
|
|
246
247
|
// as a plain number so the plan stays arithmetic.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
248
|
+
// ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
|
|
249
|
+
//
|
|
250
|
+
// Two viewers a few seconds apart state stretches that overlap, and the
|
|
251
|
+
// plan puts one encoder on each stretch it is given — so unmerged windows
|
|
252
|
+
// buy an encoder per viewer for film they both want, which is the opposite
|
|
253
|
+
// of what sharing the output is for. Merged, the highest number per segment
|
|
254
|
+
// wins and the stretches do not overlap, so one encoder serves everyone
|
|
255
|
+
// standing in front of it.
|
|
256
|
+
const windows = mergeMaps([
|
|
257
|
+
this.demand.windowsOn(address).map((window) => ({
|
|
258
|
+
from: window.from,
|
|
259
|
+
// Half-open on the way in and back again: these are whole segment
|
|
260
|
+
// numbers, and #10..#20 next to #21..#30 must not be read as touching
|
|
261
|
+
// at 20 and 21 at once.
|
|
262
|
+
to: window.to + 1,
|
|
263
|
+
// A window stated without a number is still a want — one
|
|
264
|
+
// undifferentiated want, which is what a caller that knows only a
|
|
265
|
+
// position states. Zero would read as "nothing wanted here" and the
|
|
266
|
+
// merge would drop it.
|
|
267
|
+
priority: Number(window.priority) || 1
|
|
268
|
+
}))
|
|
269
|
+
]).map((zone) => ({ from: zone.from, to: zone.to - 1, priority: zone.priority }));
|
|
252
270
|
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
253
271
|
const actions = planEncoders({
|
|
254
272
|
coverage,
|
|
@@ -325,6 +343,10 @@ export class EncodeOrchestrator {
|
|
|
325
343
|
const onThisOutput = this.#runs.get(address) ?? [];
|
|
326
344
|
onThisOutput.push(run);
|
|
327
345
|
this.#runs.set(address, onThisOutput);
|
|
346
|
+
// This run rewrites everything from here on, so what was closed from here
|
|
347
|
+
// on is no longer closed. Without this a number closed by an earlier run
|
|
348
|
+
// stays servable while a later one is halfway through writing it again.
|
|
349
|
+
this.segmentStore?.forgetClosedFrom(address, from);
|
|
328
350
|
this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
|
|
329
351
|
run.start(because);
|
|
330
352
|
}
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two responsibilities, deliberately kept apart from any storage:
|
|
5
5
|
*
|
|
6
|
-
* - **
|
|
6
|
+
* - **how much a piece is wanted**, so the piece evicted is the one wanted
|
|
7
|
+
* least. That number comes from the priority map, which is the one place
|
|
8
|
+
* that knows where the viewers are; recency only separates pieces the map
|
|
9
|
+
* wants equally. Recency alone cannot answer it: a reader walking a film
|
|
10
|
+
* touches each piece once, so the piece the decoder will want in two seconds
|
|
11
|
+
* looks exactly as stale as one fetched forty minutes ago and never read
|
|
12
|
+
* again — and with the encoder running ahead of the viewer, the second kind
|
|
13
|
+
* is what fills the store;
|
|
7
14
|
* - **pinning**, so a piece being read cannot be evicted at all.
|
|
8
15
|
*
|
|
9
16
|
* The second is not a refinement of the first. webtor's seeder relies on recency
|
|
@@ -28,18 +35,16 @@ export class PieceLru {
|
|
|
28
35
|
/** Piece index → number of readers currently holding it. */
|
|
29
36
|
#pins = new Map();
|
|
30
37
|
/**
|
|
31
|
-
*
|
|
38
|
+
* Claimant → the piece range it states, and how much it wants it.
|
|
32
39
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* and with the encoder running ahead of the viewer, the second kind is what
|
|
37
|
-
* fills the store. Measured 2026-08-04: the hit rate fell from 100% to 45.7%
|
|
38
|
-
* with 221 pieces read back from disk in one session.
|
|
40
|
+
* The ranges are the priority map's zones, stated by whoever holds the map,
|
|
41
|
+
* plus the piece a read is stopped on. The number is the map's own: lower is
|
|
42
|
+
* more urgent, and a piece no range covers is wanted by nobody at all.
|
|
39
43
|
*
|
|
40
44
|
* A preference, not a pin. At the smallest budget the store guarantees only
|
|
41
|
-
* two resident pieces, so a hard hold on a
|
|
42
|
-
*
|
|
45
|
+
* two resident pieces, so a hard hold on a zone would deadlock it; when
|
|
46
|
+
* everything resident is wanted, the least wanted of them goes rather than
|
|
47
|
+
* nothing going at all.
|
|
43
48
|
*/
|
|
44
49
|
#protected = new Map();
|
|
45
50
|
#capacity;
|
|
@@ -164,8 +169,8 @@ export class PieceLru {
|
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
/**
|
|
167
|
-
* The least
|
|
168
|
-
*
|
|
172
|
+
* The least wanted piece that is free to go, or `null` when every resident
|
|
173
|
+
* piece is pinned.
|
|
169
174
|
*
|
|
170
175
|
* Returning `null` rather than evicting a pinned piece is the whole point:
|
|
171
176
|
* the caller must then wait or fail, never take memory out from under a
|
|
@@ -194,22 +199,63 @@ export class PieceLru {
|
|
|
194
199
|
* victim is inside one, and -1 when no reader has declared anything.
|
|
195
200
|
*/
|
|
196
201
|
evictionChoice() {
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
/** @type {{ index: number, want: number } | null} */
|
|
203
|
+
let victim = null;
|
|
204
|
+
// `#order` runs least-recently-used first, so among pieces the map wants
|
|
205
|
+
// equally the first one seen is the stalest — recency decides the tie and
|
|
206
|
+
// nothing else. A piece no zone covers is wanted by nobody, which is the
|
|
207
|
+
// most anything can be un-wanted, so the walk stops at the first of those:
|
|
208
|
+
// that is the ordinary case and it costs one step.
|
|
199
209
|
for (const index of this.#order) {
|
|
200
|
-
if (
|
|
201
|
-
|
|
210
|
+
if (this.#pins.has(index)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const want = this.wantAt(index);
|
|
214
|
+
if (victim === null || want > victim.want) {
|
|
215
|
+
victim = { index, want };
|
|
216
|
+
if (want === Number.POSITIVE_INFINITY) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
202
219
|
}
|
|
203
220
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
221
|
+
if (victim === null) {
|
|
222
|
+
// Every resident piece is being read. The caller must wait or fail; it
|
|
223
|
+
// may never take memory out from under a reader.
|
|
224
|
+
return { index: null, protectionYielded: false, distance: -1 };
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
index: victim.index,
|
|
228
|
+
// The map wanted this piece and it is going anyway — the store is being
|
|
229
|
+
// asked to hold more than it has room for, and this piece comes back
|
|
230
|
+
// from disk. Reported so that thrashing is visible as thrashing rather
|
|
231
|
+
// than as ordinary work: 6565 spills and 7575 revivals in 44 minutes on
|
|
232
|
+
// 2026-09-02, with only 53.6 % of reads served from memory.
|
|
233
|
+
protectionYielded: victim.want !== Number.POSITIVE_INFINITY,
|
|
234
|
+
distance: this.#distanceToWindow(victim.index)
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* How much the priority map wants this piece, by the most urgent zone that
|
|
240
|
+
* covers it.
|
|
241
|
+
*
|
|
242
|
+
* Public because admission asks it too: whether an arriving piece displaces
|
|
243
|
+
* a resident one is the same comparison as which resident one goes, and
|
|
244
|
+
* answering them from two different quantities is how a store evicts what it
|
|
245
|
+
* has just decided to keep.
|
|
246
|
+
*
|
|
247
|
+
* @param {number} index
|
|
248
|
+
* @returns {number} Lower is more urgent. Infinity when no zone covers it,
|
|
249
|
+
* so a piece nobody asked for compares as less wanted than any zone.
|
|
250
|
+
*/
|
|
251
|
+
wantAt(index) {
|
|
252
|
+
let want = Number.POSITIVE_INFINITY;
|
|
253
|
+
for (const range of this.#protected.values()) {
|
|
254
|
+
if (index >= range.from && index <= range.to && range.urgency < want) {
|
|
255
|
+
want = range.urgency;
|
|
210
256
|
}
|
|
211
257
|
}
|
|
212
|
-
return
|
|
258
|
+
return want;
|
|
213
259
|
}
|
|
214
260
|
|
|
215
261
|
/**
|
|
@@ -279,14 +325,20 @@ export class PieceLru {
|
|
|
279
325
|
}
|
|
280
326
|
|
|
281
327
|
/**
|
|
282
|
-
* The piece that would be evicted next,
|
|
328
|
+
* The piece that would be evicted next, how much the map wants it, and how
|
|
329
|
+
* long it will be waited for.
|
|
330
|
+
*
|
|
331
|
+
* Both numbers, because that is the order admission compares them in: the
|
|
332
|
+
* map's level first, and the distance only between pieces the map wants
|
|
333
|
+
* equally.
|
|
283
334
|
*
|
|
284
|
-
* @returns {{ index: number | null, wait: number }}
|
|
335
|
+
* @returns {{ index: number | null, want: number, wait: number }}
|
|
285
336
|
*/
|
|
286
337
|
nextVictim() {
|
|
287
338
|
const choice = this.evictionChoice();
|
|
288
339
|
return {
|
|
289
340
|
index: choice.index,
|
|
341
|
+
want: choice.index === null ? Number.POSITIVE_INFINITY : this.wantAt(choice.index),
|
|
290
342
|
wait: choice.index === null ? -1 : this.waitFor(choice.index)
|
|
291
343
|
};
|
|
292
344
|
}
|
|
@@ -342,20 +394,28 @@ export class PieceLru {
|
|
|
342
394
|
}
|
|
343
395
|
|
|
344
396
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
397
|
+
* State a range of pieces and how much they are wanted, replacing whatever
|
|
398
|
+
* that claimant stated before. Ranges from different claimants add up.
|
|
347
399
|
*
|
|
348
|
-
* @param {string|number} readerId - Identity of the
|
|
349
|
-
* is replaced rather than accumulated.
|
|
400
|
+
* @param {string|number} readerId - Identity of the claimant, so its own
|
|
401
|
+
* range is replaced rather than accumulated.
|
|
350
402
|
* @param {number} from - First piece, inclusive.
|
|
351
403
|
* @param {number} to - Last piece, inclusive.
|
|
404
|
+
* @param {number} [urgency] - The priority map's own number, lower being more
|
|
405
|
+
* urgent. A caller that states none is treated as wanting these pieces
|
|
406
|
+
* least of everyone who did state one, so an unstated range can never
|
|
407
|
+
* displace a stated one.
|
|
352
408
|
* @returns {void}
|
|
353
409
|
*/
|
|
354
|
-
protect(readerId, from, to) {
|
|
410
|
+
protect(readerId, from, to, urgency) {
|
|
355
411
|
if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) {
|
|
356
412
|
return;
|
|
357
413
|
}
|
|
358
|
-
this.#protected.set(readerId, {
|
|
414
|
+
this.#protected.set(readerId, {
|
|
415
|
+
from,
|
|
416
|
+
to,
|
|
417
|
+
urgency: Number.isFinite(urgency) ? Number(urgency) : Number.MAX_SAFE_INTEGER
|
|
418
|
+
});
|
|
359
419
|
}
|
|
360
420
|
|
|
361
421
|
/**
|