@torrent-tv/proxy 2.101.9 → 2.101.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.9",
3
+ "version": "2.101.10",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -1068,6 +1068,7 @@ export async function startProxyServer({
1068
1068
  // seconds into bytes, what to ask the swarm for, what to keep in memory —
1069
1069
  // is its own business.
1070
1070
  setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
1071
+ setSpeculativeHold: (held, because) => torrentPool.setSpeculativeHold?.(held, because),
1071
1072
  setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
1072
1073
  void sourcePreparation?.refresh();
1073
1074
  const record = sourceRegistry.get(sourceKey);
@@ -152,6 +152,14 @@ export class EncodeOrchestrator {
152
152
  */
153
153
  #pauseRule = new PauseRule();
154
154
 
155
+ /**
156
+ * Whether the run a viewer needs first is short of the processor, from the
157
+ * last time who has the processor was decided (`processorShortfall`).
158
+ *
159
+ * @type {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number } | null}
160
+ */
161
+ #shortfall = null;
162
+
155
163
  /**
156
164
  * What starting and stopping an encoder was measured to cost on this host,
157
165
  * before any viewer existed. Told once; real runs replace it as they end.
@@ -238,6 +246,8 @@ export class EncodeOrchestrator {
238
246
  * @param {{ freeCores: () => number | null, otherCores: () => number | null } | null} [params.processor] -
239
247
  * The processor left free and the processor other work takes, in cores
240
248
  * (`ProcessorBudget`); absent, both are unknown.
249
+ * @param {() => void} [params.shortfallNoted] - Told each time who has the
250
+ * processor was decided, so the download can read `processorShortfall`.
241
251
  */
242
252
  constructor({
243
253
  maxRunsFor,
@@ -253,6 +263,7 @@ export class EncodeOrchestrator {
253
263
  describeWaits = null,
254
264
  admission = null,
255
265
  processor = null,
266
+ shortfallNoted = null,
256
267
  logger,
257
268
  now
258
269
  }) {
@@ -262,6 +273,7 @@ export class EncodeOrchestrator {
262
273
  // is about one output, and then only the per-output limit binds.
263
274
  this.admission = admission && typeof admission.placesFor === "function" ? admission : null;
264
275
  this.processor = processor && typeof processor.freeCores === "function" ? processor : null;
276
+ this.shortfallNoted = typeof shortfallNoted === "function" ? shortfallNoted : () => undefined;
265
277
  this.#describeWaits = typeof describeWaits === "function" ? describeWaits : () => "";
266
278
  // Only the segment facts and operations this planner needs cross the
267
279
  // storage boundary; the concrete storage remains in the server assembly.
@@ -720,12 +732,16 @@ export class EncodeOrchestrator {
720
732
  const leading = this.#leadingBand();
721
733
  this.#leading = leading;
722
734
  if (!leading) {
735
+ this.#noteShortfall(null);
723
736
  this.#placeHeldBack(null);
724
737
  return;
725
738
  }
726
739
  const now = this.now();
727
740
  const running = this.#runningNow();
728
741
  const need = this.#needOf(leading);
742
+ // Only a run in a band a viewer is in can be short for a viewer.
743
+ const short = leading.urgent === true ? this.#pauseRule.shortfall({ leader: leading.run, need }) : null;
744
+ this.#noteShortfall(short ? { address: leading.address, ...short } : null);
729
745
  const free = this.#processorReading("freeCores");
730
746
  const others = this.#processorReading("otherCores");
731
747
  for (const [address, runs] of this.#runs) {
@@ -752,6 +768,25 @@ export class EncodeOrchestrator {
752
768
  this.#placeHeldBack(leading);
753
769
  }
754
770
 
771
+ /**
772
+ * Whether the run a viewer needs first is short of the processor, and by
773
+ * what: its output, the speed it made over its seconds kept (median and
774
+ * scatter) and the speed its deadlines need, infinite where a viewer waits
775
+ * for it (`PauseRule.shortfall`, torrent-tv/meta#180). Null where it is not
776
+ * short, or nothing measured it.
777
+ *
778
+ * @returns {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number } | null}
779
+ */
780
+ processorShortfall() {
781
+ return this.#shortfall;
782
+ }
783
+
784
+ /** @param {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number } | null} short */
785
+ #noteShortfall(short) {
786
+ this.#shortfall = short;
787
+ this.shortfallNoted();
788
+ }
789
+
755
790
  /**
756
791
  * One second of each encoder's processor and speed, by process
757
792
  * (`storage/processor-report.js`): what `PauseRule` judges from. Who has the
@@ -198,6 +198,29 @@ export class PauseRule {
198
198
  return { runs: true, because: measured };
199
199
  }
200
200
 
201
+ /**
202
+ * Whether the leader is short of the processor: its last second made film
203
+ * without waiting for its input, and over the seconds kept it made less than
204
+ * its need — anything at all where a viewer waits for it. Null where it is
205
+ * not short, waits for its input, or has fewer seconds than a median is
206
+ * taken from (torrent-tv/meta#180, phase 5: what the download's speculative
207
+ * levels give way to).
208
+ *
209
+ * @param {object} params
210
+ * @param {object} params.leader
211
+ * @param {number} params.need - `s*`, as for `judge`.
212
+ * @returns {{ speed: { middle: number, scatter: number }, need: number, seconds: number } | null}
213
+ */
214
+ shortfall({ leader, need }) {
215
+ const kept = this.#seconds.get(leader) ?? [];
216
+ if (!(need > 0) || kept.length === 0 || kept.at(-1).waiting) return null;
217
+ const lead = kept.filter((second) => !second.waiting && second.speed !== null);
218
+ if (lead.length < LEAST_SECONDS) return null;
219
+ const speed = middleAndScatter(lead.map((second) => second.speed));
220
+ if (Number.isFinite(need) && speed.middle >= need) return null;
221
+ return { speed, need, seconds: lead.length };
222
+ }
223
+
201
224
  /**
202
225
  * Whether a run not started yet, on `address` and less urgent than
203
226
  * `leader`, would be let go at once: a start the rule would pause is not
@@ -123,6 +123,15 @@ export class HostLoad {
123
123
  /** @type {number[]} Recent readings behind that median. */
124
124
  #torrentCostReadings = [];
125
125
 
126
+ /**
127
+ * What every torrent carried per interval, in bytes per second: in all, and
128
+ * over the time our selection held no connection back (`SelectionLimit`).
129
+ * Kept as readings for `swarmRate`.
130
+ *
131
+ * @type {{ carried: number[], unheld: number[] }}
132
+ */
133
+ #swarmReadings = { carried: [], unheld: [] };
134
+
126
135
  /**
127
136
  * The share of the machine this process draws with nothing encoding and the
128
137
  * torrents moving nothing — the spending that would have happened anyway.
@@ -264,6 +273,7 @@ export class HostLoad {
264
273
  // worker starts its clock again: such an interval says nothing.
265
274
  if (!(elapsedSec > 0) || megabytes < 0 || now.threadCpuSeconds < previous.threadCpuSeconds) return;
266
275
  const cpuSeconds = (now.threadCpuSeconds - previous.threadCpuSeconds) / Math.max(1, os.cpus().length);
276
+ this.#noteSwarm(now, previous, megabytes * 1e6 / elapsedSec);
267
277
  const limit = this.downloadLimit;
268
278
  // What else the thread moved over the same interval, across every torrent:
269
279
  // sent back to the swarm, and handed to readers (encoder inputs, container
@@ -311,6 +321,39 @@ export class HostLoad {
311
321
  );
312
322
  }
313
323
 
324
+ /**
325
+ * What the swarm can give every torrent, in bytes per second: the median of
326
+ * what it carried while our selection held no connection back, but never
327
+ * less than the median of what it carried in all — the same choice the
328
+ * start forecast makes for one source (`ViewerRequests` `sourceService`).
329
+ * Time our selection held a connection back measures our asking, not the
330
+ * swarm, so holding the speculative levels back does not lower it
331
+ * (torrent-tv/meta#180). Null before any interval was read.
332
+ *
333
+ * @returns {number | null}
334
+ */
335
+ swarmRate() {
336
+ const carried = medianOf(this.#swarmReadings.carried);
337
+ const unheld = medianOf(this.#swarmReadings.unheld);
338
+ if (carried === null && unheld === null) return null;
339
+ return Math.max(carried ?? 0, unheld ?? 0);
340
+ }
341
+
342
+ /**
343
+ * @param {{ selection: { freeBytes?: number, freeMs?: number } | null }} now
344
+ * @param {{ selection: { freeBytes?: number, freeMs?: number } | null }} previous
345
+ * @param {number} carried - Bytes per second over the interval, every torrent.
346
+ */
347
+ #noteSwarm(now, previous, carried) {
348
+ this.#swarmReadings.carried = [...this.#swarmReadings.carried, carried].slice(-READINGS_KEPT);
349
+ const freeMs = (now.selection?.freeMs ?? 0) - (previous.selection?.freeMs ?? 0);
350
+ const freeBytes = (now.selection?.freeBytes ?? 0) - (previous.selection?.freeBytes ?? 0);
351
+ // Totals that went back belong to a torrent that left: nothing to read.
352
+ if (freeMs > 0 && freeBytes >= 0) {
353
+ this.#swarmReadings.unheld = [...this.#swarmReadings.unheld, freeBytes * 1000 / freeMs].slice(-READINGS_KEPT);
354
+ }
355
+ }
356
+
314
357
  /**
315
358
  * What the torrent thread draws in an interval that downloaded nothing.
316
359
  *
@@ -205,6 +205,7 @@ export function wireOutputs({
205
205
  getSourceStats = null,
206
206
  setPriorityMap = null,
207
207
  setDownloadRate = null,
208
+ setSpeculativeHold = null,
208
209
  // What a second job costs on this host, measured at startup. Null when it
209
210
  // could not be measured, and then nothing is corrected — the alternative
210
211
  // is inventing a penalty, which is the same fault as inventing a fill rate.
@@ -753,6 +754,48 @@ export function wireOutputs({
753
754
  void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
754
755
  `over ${watched.length} file(s), ${Math.round(share.capacity * 100)}% of the machine for the proxy`)).catch(() => {});
755
756
  };
757
+ parts.setSpeculativeHold = typeof setSpeculativeHold === "function" ? setSpeculativeHold : null;
758
+ // THE DOWNLOAD GIVES WAY TO THE ENCODER A VIEWER NEEDS FIRST
759
+ // (torrent-tv/meta#180, phase 5). While that encoder makes film on the
760
+ // processor and less than its deadlines need, what nobody needs yet — the
761
+ // film's tail, the gap behind a seek — is not downloaded, so the torrent
762
+ // thread's processor goes to it. Only where the swarm gives more than the
763
+ // film needs at the speed asked of it (its need, or with a viewer waiting
764
+ // what it makes up to its scatter): then every byte an encoder will reach
765
+ // still comes first and in time, and nothing is lost by not fetching ahead.
766
+ // Where the swarm is slower than that, the bytes fetched ahead are what the
767
+ // film will play from, and they are fetched. Decided each time who has the
768
+ // processor is decided and each time the machine is read.
769
+ parts.speculativeHeld = false;
770
+ parts.applySpeculativeHold = () => {
771
+ if (!parts.setSpeculativeHold || !parts.hostLoad || !parts.encodeOrchestrator) return;
772
+ const short = parts.encodeOrchestrator.processorShortfall();
773
+ let held = false;
774
+ let because = "the encoder a viewer needs first is not short of the processor";
775
+ if (short) {
776
+ const output = parts.outputs.outputsOn(short.address)[0] ?? null;
777
+ const length = Number(output?.file?.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(output?.file?.key));
778
+ const bytesPerFilmSecond = length / Number(output?.file?.durationSeconds);
779
+ const asked = Number.isFinite(short.need) ? short.need : short.speed.middle + short.speed.scatter;
780
+ const needs = bytesPerFilmSecond * asked;
781
+ const swarm = parts.hostLoad.swarmRate();
782
+ const made = `the encoder a viewer needs first made ${short.speed.middle.toFixed(2)}x ` +
783
+ `(scatter ${short.speed.scatter.toFixed(2)}) over ${short.seconds}s` +
784
+ (Number.isFinite(short.need) ? `, needing ${short.need.toFixed(2)}x` : " with a viewer waiting");
785
+ if (!Number.isFinite(needs) || needs <= 0) {
786
+ because = `${made}; its film's bytes per second are unknown`;
787
+ } else if (swarm === null) {
788
+ because = `${made}; the swarm's rate is not measured yet`;
789
+ } else {
790
+ held = swarm > needs;
791
+ because = `${made}; the swarm gives ${(swarm / 1e6).toFixed(2)} MB/s, ` +
792
+ `${held ? "more than" : "not more than"} the ${(needs / 1e6).toFixed(2)} MB/s its film needs at ${asked.toFixed(2)}x`;
793
+ }
794
+ }
795
+ if (held === parts.speculativeHeld) return;
796
+ parts.speculativeHeld = held;
797
+ void Promise.resolve(parts.setSpeculativeHold(held, because)).catch(() => {});
798
+ };
756
799
  // The one owner of what the processor gives the encoders: the share of the
757
800
  // machine other work leaves and the measured contention penalties, read by
758
801
  // every place that decides on the processor (torrent-tv/meta#180).
@@ -1090,6 +1133,7 @@ export function wireOutputs({
1090
1133
  // The processor left free and what other work takes, for the pause rule
1091
1134
  // (torrent-tv/meta#180, phase 4).
1092
1135
  processor: parts.processor,
1136
+ shortfallNoted: () => parts.applySpeculativeHold?.(),
1093
1137
  startingSpeedFor: (address) => parts.encodeCost.speedForOutput(address),
1094
1138
  segmentCoverage,
1095
1139
  // HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
@@ -1257,7 +1301,11 @@ export function wireOutputs({
1257
1301
  liveConsumers: (...args) => parts.renditions.liveConsumers(...args),
1258
1302
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
1259
1303
  producedNumbers: (...args) => parts.serving.producedNumbers(...args),
1260
- reportHostLoad: async (...args) => { await parts.hostLoad.reportHostLoad(...args); parts.applyDownloadShare?.(); },
1304
+ reportHostLoad: async (...args) => {
1305
+ await parts.hostLoad.reportHostLoad(...args);
1306
+ parts.applyDownloadShare?.();
1307
+ parts.applySpeculativeHold?.();
1308
+ },
1261
1309
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
1262
1310
  sampleDownloadRates: (...args) => parts.hostLoad.sampleDownloadRates(...args),
1263
1311
  get encodeCost() { return parts.encodeCost; },
@@ -30,6 +30,12 @@ let futurePending = null;
30
30
  let publishingSelections = false;
31
31
  /** What the last reconciliation told the swarm to fetch first, for the pool to say. */
32
32
  let firstClassStated = "none";
33
+ /**
34
+ * Whether the speculative levels are held back for the processor: the encoder
35
+ * a viewer needs first is short of it, and the swarm gives more than its film
36
+ * needs (`holdSpeculative`, torrent-tv/meta#180).
37
+ */
38
+ let speculativeHeld = false;
33
39
 
34
40
  /**
35
41
  * The last forecast that finished on the maps it was begun on: its result, the
@@ -222,6 +228,26 @@ export function firstClassStatement() {
222
228
  return firstClassStated;
223
229
  }
224
230
 
231
+ /**
232
+ * Hold the speculative levels back, or let them go again, on every torrent.
233
+ *
234
+ * Downloading what nobody needs yet takes the torrent thread's processor
235
+ * (Home Assistant r84: 9-19 % of the machine while it downloaded 7-14 MB/s
236
+ * and handed readers about 0.5 MB/s in most intervals, with 0-6 % of the
237
+ * machine free). The side that measures the encoders decides when that processor is
238
+ * worth more to them (torrent-tv/meta#180, phase 5); the urgent levels are
239
+ * never held, so every byte an encoder will reach is still asked for first.
240
+ *
241
+ * @param {boolean} held
242
+ * @returns {boolean} Whether this changed anything.
243
+ */
244
+ export function holdSpeculative(held) {
245
+ const next = held === true;
246
+ if (next === speculativeHeld) return false;
247
+ speculativeHeld = next;
248
+ return true;
249
+ }
250
+
225
251
  /**
226
252
  * Bring every torrent's download set into line with what is stated.
227
253
  *
@@ -235,7 +261,7 @@ export function firstClassStatement() {
235
261
  export function reconcileAll() {
236
262
  if (publishingSelections) return { torrents: live.size, speculativeAllowed: false, stated: 0, withdrawn: 0 };
237
263
  const entries = [...live];
238
- const speculativeAllowed = !entries.some((entry) => entry.selection.hasUrgentMissing());
264
+ const speculativeAllowed = !speculativeHeld && !entries.some((entry) => entry.selection.hasUrgentMissing());
239
265
  // The first class across every torrent: the most urgent missing level and
240
266
  // its highest priority. Lower classes stay out of every torrent's selection.
241
267
  const bands = entries.map(entry => entry.selection.missingBand()).filter(Boolean);
@@ -18,7 +18,7 @@ import { logger } from "../../utils/logger.js";
18
18
  import { pieceStoreOf } from "./piece-store-of.js";
19
19
  import { demandProgress } from "./demand-progress.js";
20
20
  import { Urgency, urgencyName } from "./demand/index.js";
21
- import { demandFor, firstClassStatement, forgetTorrent, reconcileAll, hasUnmetDemand, selectionLimitOf } from "./download/registry.js";
21
+ import { demandFor, firstClassStatement, forgetTorrent, holdSpeculative, reconcileAll, hasUnmetDemand, selectionLimitOf } from "./download/registry.js";
22
22
  import { applyDownloadRate } from "./download/throttle.js";
23
23
  import { withdrawClaim } from "./download/withdraw-claim.js";
24
24
  import { deadlineOfMapZone, levelOfMapZone, priorityOfMapZone } from "./map-zones.js";
@@ -1309,6 +1309,20 @@ export class TorrentPool {
1309
1309
  /** The power of two the last limit said was nearest, null for none. */
1310
1310
  #downloadLimitScale = null;
1311
1311
 
1312
+ /**
1313
+ * Hold back, or let go, what every torrent downloads speculatively: the
1314
+ * film's tail and the gap behind a seek (`download/registry.js`
1315
+ * `holdSpeculative`, torrent-tv/meta#180). Said when it changes.
1316
+ *
1317
+ * @param {boolean} held
1318
+ * @param {string} [because]
1319
+ */
1320
+ setSpeculativeHold(held, because = "") {
1321
+ if (!holdSpeculative(held)) return;
1322
+ logger.info(`torrent-pool: speculative download ${held === true ? "held" : "let go"}${because ? ` (${because})` : ""}`);
1323
+ reconcileAll();
1324
+ }
1325
+
1312
1326
  applyPriorityMap(torrent, fileIndex, zones, durationSeconds) {
1313
1327
  // A MAP WITH NOTHING IN IT IS A STATEMENT, and it is answered before
1314
1328
  // anything else is looked at. It says nobody wants any of this file, and
@@ -511,6 +511,17 @@ export class TorrentWorkerClient {
511
511
  return this.#caller.call(Command.DOWNLOAD_RATE, { bytesPerSecond: Number.isFinite(bytesPerSecond) ? bytesPerSecond : null, because });
512
512
  }
513
513
 
514
+ /**
515
+ * Whether the speculative levels are held back (`Command.SPECULATIVE_HOLD`).
516
+ *
517
+ * @param {boolean} held
518
+ * @param {string} [because]
519
+ * @returns {Promise<void>}
520
+ */
521
+ async setSpeculativeHold(held, because = "") {
522
+ return this.#caller.call(Command.SPECULATIVE_HOLD, { held: held === true, because });
523
+ }
524
+
514
525
  async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
515
526
  return this.#caller.call(Command.PRIORITY_MAP, {
516
527
  sourceKey,
@@ -179,6 +179,17 @@ export class WorkerTorrentPool {
179
179
  return this.#client.setDownloadRate(bytesPerSecond, because);
180
180
  }
181
181
 
182
+ /**
183
+ * Whether the speculative levels are held back for the encoders.
184
+ *
185
+ * @param {boolean} held
186
+ * @param {string} [because]
187
+ * @returns {Promise<void>}
188
+ */
189
+ async setSpeculativeHold(held, because = "") {
190
+ return this.#client.setSpeculativeHold(held, because);
191
+ }
192
+
182
193
  async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
183
194
  if (!sourceKey) {
184
195
  return;
@@ -72,6 +72,12 @@ export const Command = {
72
72
  * (`storage/processor-share.js`, torrent-tv/meta#178).
73
73
  */
74
74
  DOWNLOAD_RATE: "download-rate",
75
+ /**
76
+ * Whether the speculative levels are held back for the processor the
77
+ * encoders need (`download/registry.js` `holdSpeculative`,
78
+ * torrent-tv/meta#180).
79
+ */
80
+ SPECULATIVE_HOLD: "speculative-hold",
75
81
  /** Read a byte range; the body arrives as CHUNK messages. */
76
82
  READ_RANGE: "read-range",
77
83
  /** Abandon an in-flight READ_RANGE (viewer gone, seek superseded). */
@@ -525,6 +525,11 @@ async function runCommand(command, params, id) {
525
525
  return true;
526
526
  }
527
527
 
528
+ case Command.SPECULATIVE_HOLD: {
529
+ pool.setSpeculativeHold(params.held === true, params.because);
530
+ return true;
531
+ }
532
+
528
533
  case Command.READ_RANGE: {
529
534
  // Streams its own reply; the caller's promise resolves once the body has
530
535
  // been fully sent, which is what lets the client await completion.