@torrent-tv/proxy 2.102.0 → 2.102.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.102.0",
3
+ "version": "2.102.1",
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
@@ -12,6 +12,7 @@ import fastifyHelmet from "@fastify/helmet";
12
12
  import getPort from "get-port";
13
13
  import { createRequire } from "node:module";
14
14
  import { performance } from "node:perf_hooks";
15
+ import { SourceWaitTrace } from "./utils/wait-trace.js";
15
16
  import { handleHealthGet } from "./routes/health/get.js";
16
17
  import { handleMediaGet } from "./routes/media/get.js";
17
18
  import { ProbeRequests } from "./services/media/ProbeRequests.js";
@@ -157,6 +158,8 @@ export async function startProxyServer({
157
158
  bodyLimit: 256 * 1024 * 1024 // 256 MB
158
159
  });
159
160
  const forwardedSessionResponseStarts = new WeakMap();
161
+ const waitTrace = new SourceWaitTrace({ emit: value => logger.info(`[wait-trace] ${JSON.stringify(value)}`) });
162
+ waitTrace.install(app);
160
163
  const logForwardedSessionResponseStage = (stage, req) => {
161
164
  const startedAt = forwardedSessionResponseStarts.get(req);
162
165
  const requestId = req.headers["x-ttv-forwarded-request-id"];
@@ -175,7 +178,7 @@ export async function startProxyServer({
175
178
  await app.register(fastifyCors, {
176
179
  origin: true,
177
180
  methods: ["GET", "POST", "OPTIONS"],
178
- allowedHeaders: ["Content-Type", "Range"]
181
+ allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation"]
179
182
  });
180
183
 
181
184
  // Allow browser requests from an HTTPS page to this private-network proxy
@@ -220,6 +223,7 @@ export async function startProxyServer({
220
223
  // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
221
224
  // adapter keeps TorrentPool's interface, so nothing downstream changed.
222
225
  const torrentPool = new WorkerTorrentPool({
226
+ onObserved: fields => waitTrace.source(fields.sourceKey, "torrent-ready", fields),
223
227
  memoryBytes,
224
228
  stateDir,
225
229
  heapSnapshots,
@@ -250,6 +254,8 @@ export async function startProxyServer({
250
254
  // only once the worker thread is up and a piece has verified, which is long
251
255
  // after the declarations below have run.
252
256
  onPiecesArrived: ({ sourceKey, fileIndexes, arrived }) => {
257
+ waitTrace.source(sourceKey, "held-bytes-changed", { fileIndexes, arrived: arrived?.slice?.(0, 32),
258
+ arrivedCount: arrived?.length });
253
259
  for (const fileIndex of fileIndexes) {
254
260
  mediaReads.bytesChanged(sourceKey, fileIndex);
255
261
  probeReads.bytesChanged(sourceKey, fileIndex);
@@ -871,6 +877,7 @@ export async function startProxyServer({
871
877
  } }),
872
878
  label: String(file.name ?? ""),
873
879
  fileOffset: file.offset,
880
+ onObserved: fields => waitTrace.source(sourceKey, "container-read", { fileIndex, requestId, ...fields }),
874
881
  onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
875
882
  onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
876
883
  onReadResult: async (statement, result, revision) => {
@@ -918,6 +925,7 @@ export async function startProxyServer({
918
925
  });
919
926
  let sourcePreparation;
920
927
  const outputParts = wireOutputs({
928
+ observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
921
929
  pinnedPreset,
922
930
  indexMemory,
923
931
  serviceShare,
@@ -1106,6 +1106,7 @@ export class EncodeRuns {
1106
1106
  // proven, and it is cleared up when the run ends.
1107
1107
  // Told when this run closes its first piece; see the next stretch below.
1108
1108
  let atFirstPiece = null;
1109
+ const observedStart = performance.now();
1109
1110
  const run = new EncodeRun({
1110
1111
  address: session.outputKey ?? session.id,
1111
1112
  encoder: this.#host.videoEncoder,
@@ -1196,6 +1197,10 @@ export class EncodeRuns {
1196
1197
  { code: "ERR_INCOMPLETE_MEDIA" });
1197
1198
  const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
1198
1199
  if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
1200
+ if (index === safeIndex) this.#host.observeWait?.(session.file.sourceKey, "first-piece-published", {
1201
+ output: address, segment: index, startedAt: observedStart, endedAt: performance.now(),
1202
+ encoderWrittenAfterMs: Number.isFinite(writtenAt) ? writtenAt - run.startedAt : null,
1203
+ namedAfterMs: Number.isFinite(namedAt) ? namedAt - run.startedAt : null });
1199
1204
  this.#leadingByOutput.get(address)?.delete(index);
1200
1205
  // WHERE A LATE PIECE'S TIME WENT: the encoder writing it, this process
1201
1206
  // getting to its name, or its publication waiting for the file after
@@ -156,13 +156,17 @@ export class ContainerOrchestrator {
156
156
  // table of the whole film it does not use (r38, torrent-tv/meta#178).
157
157
  const aside = this.#answersWithoutReading(key, statement);
158
158
  const previous = aside ? Promise.resolve() : this.#reads.get(key) ?? Promise.resolve();
159
- const askedAt = Date.now();
159
+ const askedAt = performance.now();
160
160
  const reading = previous.catch(() => undefined).then(async () => {
161
161
  if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
162
- const startedAt = Date.now();
162
+ const startedAt = performance.now();
163
163
  const revision = params.onReadStart?.(statement);
164
164
  const result = await this.#inspectRead({ ...params, isCurrent }, statement);
165
- params.onTimed?.(statement, startedAt - askedAt, Date.now() - startedAt);
165
+ const endedAt = performance.now();
166
+ params.onTimed?.(statement, startedAt - askedAt, endedAt - startedAt);
167
+ if (statement !== "source-ranges" || endedAt - askedAt >= 10) params.onObserved?.({ statement, askedAt,
168
+ startedAt, endedAt, queuedMs: startedAt - askedAt, readMs: endedAt - startedAt, cached: aside,
169
+ result: result.kind, reason: result.reason, ranges: result.ranges });
166
170
  return { result, revision };
167
171
  });
168
172
  if (!aside) this.#reads.set(key, reading);
@@ -177,6 +177,7 @@ function buildHttpBaseUrl(host, port) {
177
177
  * @returns {object} The components, and the settings they read.
178
178
  */
179
179
  export function wireOutputs({
180
+ observeWait = null,
180
181
  enabled,
181
182
  ffmpegBin,
182
183
  localBindHost,
@@ -363,6 +364,7 @@ export function wireOutputs({
363
364
  get segmentDurationSec() { return parts.segmentDurationSec; },
364
365
  readsThroughCache: () => parts.inputCacheAvailable === true,
365
366
  segmentFiles,
367
+ observeWait,
366
368
  get videoEncoder() { return parts.encoders.current; },
367
369
  get encoders() { return parts.encoders; },
368
370
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
@@ -31,12 +31,14 @@ export class WorkerTorrentPool {
31
31
  /** Stand-ins by source key, so repeat calls return the same object. */
32
32
  #torrents = new Map();
33
33
  #pending = new Map();
34
+ #observed;
34
35
 
35
36
  /**
36
37
  * @param {{ memoryBytes?: number, stateDir?: string }} [options]
37
38
  * @param {object | null} [client] - The worker interface; tests supply a fake.
38
39
  */
39
40
  constructor(options = {}, client = null) {
41
+ this.#observed = options.onObserved;
40
42
  this.#client = client ?? new TorrentWorkerClient({ ...options, onSourceForgotten: event => {
41
43
  this.forget(event.sourceKey);
42
44
  options.onSourceForgotten?.(event);
@@ -51,13 +53,16 @@ export class WorkerTorrentPool {
51
53
  * @returns {Promise<object>}
52
54
  */
53
55
  async getTorrent(sourceType, source) {
56
+ const startedAt = performance.now();
54
57
  const sourceKey = await deriveSourceKey(sourceType, source);
58
+ const observed = outcome => this.#observed?.({ sourceKey, startedAt, endedAt: performance.now(), outcome });
55
59
  const existing = this.#torrents.get(sourceKey);
56
60
  if (existing) {
61
+ observed("cached");
57
62
  return existing;
58
63
  }
59
64
  const joined = this.#pending.get(sourceKey);
60
- if (joined) return joined;
65
+ if (joined) return joined.finally(() => observed("joined"));
61
66
  const pending = this.#client.getTorrent({ sourceKey, sourceType, source }).then(torrent => {
62
67
  if (this.#pending.get(sourceKey) !== pending) {
63
68
  const error = new Error("Source was forgotten while its metadata was being read.");
@@ -66,7 +71,10 @@ export class WorkerTorrentPool {
66
71
  }
67
72
  this.#torrents.set(sourceKey, torrent);
68
73
  return torrent;
69
- }).finally(() => { if (this.#pending.get(sourceKey) === pending) this.#pending.delete(sourceKey); });
74
+ }).finally(() => {
75
+ observed(this.#torrents.has(sourceKey) ? "ready" : "error");
76
+ if (this.#pending.get(sourceKey) === pending) this.#pending.delete(sourceKey);
77
+ });
70
78
  this.#pending.set(sourceKey, pending);
71
79
  return pending;
72
80
  }
@@ -0,0 +1,68 @@
1
+ /** Bounded, opt-in startup observations. No torrent or application imports. */
2
+ export class SourceWaitTrace {
3
+ constructor({ emit, now = () => performance.now() }) {
4
+ Object.assign(this, { emit, now, sources: new Map(), requests: new WeakMap() });
5
+ }
6
+
7
+ write(context, event, fields = {}) {
8
+ this.emit({ version: 1, clock: "proxy-main", wait: context.wait, attempt: context.attempt,
9
+ operation: context.operation, event, at: this.now(), ...fields });
10
+ }
11
+
12
+ remember(sourceKey, context) {
13
+ if (typeof sourceKey !== "string" || !sourceKey) return;
14
+ for (const [key, value] of this.sources) if (this.now() - value.seen > 60_000) this.sources.delete(key);
15
+ if (this.sources.size >= 64 && !this.sources.has(sourceKey)) this.sources.delete(this.sources.keys().next().value);
16
+ this.sources.set(sourceKey, { ...context, seen: this.now() });
17
+ }
18
+
19
+ source(sourceKey, event, fields = {}) {
20
+ const context = this.sources.get(sourceKey);
21
+ if (!context || this.now() - context.seen > 60_000) return;
22
+ this.write({ ...context, operation: null }, event, { sourceKey, ...fields });
23
+ }
24
+
25
+ install(app) {
26
+ app.addHook("onRequest", async (req, reply) => {
27
+ const wait = req.headers["x-ttv-wait-trace"];
28
+ const operation = req.headers["x-ttv-wait-operation"];
29
+ const attempt = Number(req.headers["x-ttv-wait-attempt"]);
30
+ if (typeof wait !== "string" || !/^[a-zA-Z0-9.-]{1,80}$/.test(wait) ||
31
+ typeof operation !== "string" || !/^b[0-9]{1,12}$/.test(operation) ||
32
+ !Number.isSafeInteger(attempt) || attempt < 0) return;
33
+ const context = { wait, attempt, operation, startedAt: this.now() };
34
+ this.requests.set(req, context);
35
+ this.write(context, "http-start", { method: req.method, path: req.url,
36
+ requestId: req.headers["x-ttv-forwarded-request-id"] ?? req.id });
37
+ let finished = false;
38
+ const end = outcome => {
39
+ if (finished) return;
40
+ finished = true;
41
+ this.write(context, "http-end", { outcome, status: reply.statusCode, duration: this.now() - context.startedAt });
42
+ };
43
+ reply.raw.once("finish", () => end("finished"));
44
+ reply.raw.once("close", () => end(reply.raw.writableFinished ? "finished" : "closed"));
45
+ });
46
+ app.addHook("preHandler", async req => {
47
+ const context = this.requests.get(req);
48
+ if (!context) return;
49
+ const match = /^\/api\/sources\/([^/?]+)/.exec(req.url);
50
+ const sourceKey = req.body?.sourceKey ?? req.query?.sourceKey ?? (match ? decodeURIComponent(match[1]) : null);
51
+ this.remember(sourceKey, context);
52
+ this.write(context, "http-handler", { sourceKey });
53
+ });
54
+ app.addHook("preSerialization", async (req, _reply, payload) => {
55
+ const context = this.requests.get(req);
56
+ if (context) {
57
+ this.remember(payload?.sourceKey, context);
58
+ this.write(context, "http-serialize");
59
+ }
60
+ return payload;
61
+ });
62
+ app.addHook("onSend", async (req, _reply, payload) => {
63
+ const context = this.requests.get(req);
64
+ if (context) this.write(context, "http-send");
65
+ return payload;
66
+ });
67
+ }
68
+ }