@torrent-tv/proxy 2.69.1 → 2.69.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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## 2.69.2
2
+
3
+ - **New**: The piece store says WHY it spills, which no reading has ever answered. A session on 2026-09-02 did 6565 spills and 7575 revivals in 44 minutes with only 53.6 % of reads served from memory, and nothing recorded whether that was an eviction order fighting the read order or a working set that simply does not fit. Three figures now settle it, on one line per store: what the live readers between them are asking to keep against what the store may hold; how many evictions had to take a piece a reader had declared it wants; and how long a revived piece had been on disk before it was wanted back.
4
+ - **Chore**: The demand is the UNION of the readers' windows, not their sum. Two readers of one file — picture and sound — overlap by construction, and summing them would report the store as short when it is not. A union wider than the capacity cannot be held however the eviction is ordered, which is the difference between a policy to fix and arithmetic to accept.
5
+ - **Chore**: `PieceLru.evictionChoice()` returns the victim with the two facts about it — whether protection had to yield, and how many pieces the victim lies from the nearest declared window, zero inside one and -1 when nothing is declared. `evictionCandidate()` is kept and delegates, so nothing else moved.
6
+ - **Chore**: A revived piece's age is kept as a bounded window of the last 200, because the figure wanted is a median rather than a history. The line also says how many of those came back within five seconds — a piece wanted again that soon should not have left. Seven checks in `test/piece-lru.test.js` and `test/piece-store-eviction.test.js`.
7
+
1
8
  ## 2.69.1
2
9
 
3
10
  - **Fix**: The change trigger on the torrent worker's memory line watched the one figure that does not move. A thread's watched quantity was `heapTotal`, and through the session of 2026-09-02 that stood at 31-173 MB while the same isolate's `arrayBuffers` swung between 130 and 950 MB — so nothing ever earned a line and every reading of the quantity that grew came out on the quiet minute. Each of `heapTotal`, `external` and `arrayBuffers` is now compared against its own last written value and any one of them moving writes the line. Nothing is summed: `arrayBuffers` is documented as part of `external` and reads larger than it here, and this code has no business resolving that.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.69.1",
3
+ "version": "2.69.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -174,11 +174,31 @@ export class PieceLru {
174
174
  * @returns {number | null}
175
175
  */
176
176
  evictionCandidate() {
177
+ return this.evictionChoice().index;
178
+ }
179
+
180
+ /**
181
+ * The same choice, with the two facts that say whether the store is working
182
+ * or thrashing: whether protection had to yield, and how far the victim was
183
+ * from the nearest piece a reader declared it wants.
184
+ *
185
+ * Evicting a stale piece nobody asked for is the store doing its job.
186
+ * Evicting a piece inside a reader's own declared window is the store being
187
+ * asked to hold more than it has room for, and it comes back from disk
188
+ * moments later — 6565 spills and 7575 revivals in 44 minutes on
189
+ * 2026-09-02, with only 53.6% of reads served from memory. Nothing recorded
190
+ * which of the two was happening (roadmap item 9).
191
+ *
192
+ * @returns {{ index: number | null, protectionYielded: boolean, distance: number }}
193
+ * `distance` is in pieces from the nearest declared window, zero when the
194
+ * victim is inside one, and -1 when no reader has declared anything.
195
+ */
196
+ evictionChoice() {
177
197
  // First choice: the least recently used piece nobody is reading and nobody
178
198
  // is about to read.
179
199
  for (const index of this.#order) {
180
200
  if (!this.#pins.has(index) && !this.#isProtected(index)) {
181
- return index;
201
+ return { index, protectionYielded: false, distance: this.#distanceToWindow(index) };
182
202
  }
183
203
  }
184
204
  // Nothing spare left. Protection yields — it is a preference, and refusing
@@ -186,10 +206,66 @@ export class PieceLru {
186
206
  // yield: a piece being read now cannot have its memory taken away.
187
207
  for (const index of this.#order) {
188
208
  if (!this.#pins.has(index)) {
189
- return index;
209
+ return { index, protectionYielded: true, distance: this.#distanceToWindow(index) };
210
+ }
211
+ }
212
+ return { index: null, protectionYielded: false, distance: -1 };
213
+ }
214
+
215
+ /**
216
+ * How many pieces the live readers between them are asking to keep, against
217
+ * how many this store may hold.
218
+ *
219
+ * The union, not the sum: two readers of one file overlap, and counting the
220
+ * overlap twice would say the store is short when it is not. This is the
221
+ * comparison that decides whether thrashing is a policy fault or arithmetic —
222
+ * a union wider than the capacity cannot be held however the eviction is
223
+ * ordered.
224
+ *
225
+ * @returns {{ readers: number, unionPieces: number, widestPieces: number, capacity: number }}
226
+ */
227
+ demand() {
228
+ const ranges = [...this.#protected.values()]
229
+ .map((range) => ({ from: range.from, to: range.to }))
230
+ .sort((left, right) => left.from - right.from);
231
+ let unionPieces = 0;
232
+ let widestPieces = 0;
233
+ let coveredTo = -Infinity;
234
+ for (const range of ranges) {
235
+ const width = range.to - range.from + 1;
236
+ widestPieces = Math.max(widestPieces, width);
237
+ const from = Math.max(range.from, coveredTo + 1);
238
+ if (range.to >= from) {
239
+ unionPieces += range.to - from + 1;
240
+ coveredTo = range.to;
241
+ }
242
+ }
243
+ return {
244
+ readers: ranges.length,
245
+ unionPieces,
246
+ widestPieces,
247
+ capacity: this.#capacity
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Pieces from `index` to the nearest declared window, zero inside one and -1
253
+ * when nothing is declared.
254
+ *
255
+ * @param {number} index
256
+ * @returns {number}
257
+ */
258
+ #distanceToWindow(index) {
259
+ let nearest = -1;
260
+ for (const range of this.#protected.values()) {
261
+ const gap = index < range.from
262
+ ? range.from - index
263
+ : index > range.to ? index - range.to : 0;
264
+ if (nearest === -1 || gap < nearest) {
265
+ nearest = gap;
190
266
  }
191
267
  }
192
- return null;
268
+ return nearest;
193
269
  }
194
270
 
195
271
  /**
@@ -151,6 +151,32 @@ const MIN_RESIDENT_PIECES = 2;
151
151
  * never failing (field 2026-08-31).
152
152
  */
153
153
  const PINNED_WAIT_MS = 5_000;
154
+
155
+ /**
156
+ * How many revival ages are kept for the median. A window, not a history: two
157
+ * hundred covers several minutes of the busiest session measured (7575
158
+ * revivals in 44 minutes) and costs two hundred numbers.
159
+ */
160
+ const REVIVAL_AGE_SAMPLES = 200;
161
+
162
+ /**
163
+ * The middle value of a sample, or null when there is nothing to take a middle
164
+ * of. Null rather than zero: no revivals and instant revivals are different
165
+ * facts and must not print the same.
166
+ *
167
+ * @param {number[]} values
168
+ * @returns {number | null}
169
+ */
170
+ function median(values) {
171
+ if (values.length === 0) {
172
+ return null;
173
+ }
174
+ const sorted = [...values].sort((left, right) => left - right);
175
+ const middle = Math.floor(sorted.length / 2);
176
+ return sorted.length % 2 === 0
177
+ ? Math.round((sorted[middle - 1] + sorted[middle]) / 2)
178
+ : sorted[middle];
179
+ }
154
180
  const CLAIM_RETRY_MS = 50;
155
181
 
156
182
  export class SharedPieceStore {
@@ -191,8 +217,25 @@ export class SharedPieceStore {
191
217
  blockedByPins: 0,
192
218
  waitedForPins: 0,
193
219
  evictedOnRevise: 0,
194
- spillFailures: 0
220
+ spillFailures: 0,
221
+ // Whether the store is doing its job or being asked to hold more than it
222
+ // has room for. An eviction that had to take a piece a reader declared it
223
+ // wants is the second, and it comes back from disk moments later
224
+ // (roadmap item 9).
225
+ evictedProtected: 0,
226
+ evictedDistanceSum: 0,
227
+ evictedWithDistance: 0
195
228
  };
229
+ /** Piece index → when it was written out, for the age it comes back at. */
230
+ #spilledAt = new Map();
231
+ /**
232
+ * Ages, in milliseconds, of the last revivals — bounded, because the figure
233
+ * wanted is a median and not a history. A piece that comes back seconds
234
+ * after it left should not have left.
235
+ *
236
+ * @type {number[]}
237
+ */
238
+ #revivalAges = [];
196
239
 
197
240
  constructor(chunkLength, options = {}) {
198
241
  if (!Number.isInteger(chunkLength) || chunkLength < 1) {
@@ -244,6 +287,14 @@ export class SharedPieceStore {
244
287
  outstanding: this.#outstandingPieces,
245
288
  spilled: this.#disk.size,
246
289
  spilledBytes: this.#disk.size * this.#chunkLength,
290
+ // What the readers between them are asking this store to keep, against
291
+ // what it may hold. A union wider than the capacity cannot be held
292
+ // however the eviction is ordered, and that is the difference between a
293
+ // policy to fix and arithmetic to accept (roadmap item 9).
294
+ demand: this.#lru.demand(),
295
+ revivalAgeMedianMs: median(this.#revivalAges),
296
+ revivalAgeSamples: this.#revivalAges.length,
297
+ revivedWithinFiveSeconds: this.#revivalAges.filter((age) => age <= 5_000).length,
247
298
  ...this.#counters
248
299
  };
249
300
  }
@@ -263,7 +314,7 @@ export class SharedPieceStore {
263
314
  // old growable pool. Eagerly evict excess to honour the new ceiling.
264
315
  let evicted = 0;
265
316
  while (this.#buffers.size > this.#growthCeiling) {
266
- const victim = this.#lru.evictionCandidate();
317
+ const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
267
318
  if (victim === null) {
268
319
  break;
269
320
  }
@@ -276,6 +327,7 @@ export class SharedPieceStore {
276
327
  this.#lru.remove(victim);
277
328
  evicted += 1;
278
329
  this.#counters.evictedOnRevise += 1;
330
+ this.#noteEviction(protectionYielded, distance);
279
331
  // Nobody awaits this spill, so its failure has to end here. Rethrowing
280
332
  // made it an unhandled rejection, and an unhandled rejection in the
281
333
  // torrent worker ends the thread — a second way to lose the torrent
@@ -421,6 +473,7 @@ export class SharedPieceStore {
421
473
  const spill = this.#disk.write(index, bytes).then(
422
474
  () => {
423
475
  this.#counters.spills += 1;
476
+ this.#spilledAt.set(index, Date.now());
424
477
  this.#evicting.delete(index);
425
478
  this.#noteProgress();
426
479
  },
@@ -440,6 +493,48 @@ export class SharedPieceStore {
440
493
  this.#wake();
441
494
  }
442
495
 
496
+ /**
497
+ * Record how long a piece stayed on disk before it was wanted again.
498
+ *
499
+ * A piece that comes back seconds after it left was evicted from a working
500
+ * set that does not fit, and the write and the read were both waste. Kept as
501
+ * a bounded window of ages because the figure wanted is a median, not a
502
+ * history.
503
+ *
504
+ * @param {number} index
505
+ * @returns {void}
506
+ */
507
+ #noteRevival(index) {
508
+ const spilledAt = this.#spilledAt.get(index);
509
+ if (spilledAt === undefined) {
510
+ return;
511
+ }
512
+ this.#spilledAt.delete(index);
513
+ this.#revivalAges.push(Date.now() - spilledAt);
514
+ if (this.#revivalAges.length > REVIVAL_AGE_SAMPLES) {
515
+ this.#revivalAges.shift();
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Record what an eviction had to take.
521
+ *
522
+ * @param {boolean} protectionYielded - The victim was inside a window a
523
+ * reader had declared, and was taken anyway because nothing else was free.
524
+ * @param {number} distance - Pieces from the nearest declared window, -1 when
525
+ * no reader declared one.
526
+ * @returns {void}
527
+ */
528
+ #noteEviction(protectionYielded, distance) {
529
+ if (protectionYielded) {
530
+ this.#counters.evictedProtected += 1;
531
+ }
532
+ if (distance >= 0) {
533
+ this.#counters.evictedDistanceSum += distance;
534
+ this.#counters.evictedWithDistance += 1;
535
+ }
536
+ }
537
+
443
538
  #wake() {
444
539
  const waiting = this.#waiters;
445
540
  this.#waiters = [];
@@ -456,7 +551,7 @@ export class SharedPieceStore {
456
551
  return true;
457
552
  }
458
553
 
459
- const victim = this.#lru.evictionCandidate();
554
+ const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
460
555
  if (victim === null) {
461
556
  // Nothing may leave. Wait while the store is still MOVING — a spill
462
557
  // completing, a piece admitted, a pin released — and give up when it has
@@ -491,6 +586,7 @@ export class SharedPieceStore {
491
586
  this.#buffers.delete(victim);
492
587
  this.#lru.remove(victim);
493
588
  this.#outstandingPieces += 1;
589
+ this.#noteEviction(protectionYielded, distance);
494
590
 
495
591
  try {
496
592
  await this.#spill(victim, victimBuffer);
@@ -538,6 +634,7 @@ export class SharedPieceStore {
538
634
  this.#registerPiece(index, target);
539
635
  this.#counters.fromDisk += 1;
540
636
  this.#counters.revivals += 1;
637
+ this.#noteRevival(index);
541
638
  return target;
542
639
  } finally {
543
640
  release();
@@ -593,6 +690,7 @@ export class SharedPieceStore {
593
690
  await spill.catch(() => undefined);
594
691
  }
595
692
  this.#disk.forget(index);
693
+ this.#spilledAt.delete(index);
596
694
  }
597
695
 
598
696
  put(index, bytes, callback = () => undefined) {
@@ -707,6 +805,7 @@ export class SharedPieceStore {
707
805
  this.#closed = true;
708
806
  liveStores.delete(this);
709
807
  this.#buffers.clear();
808
+ this.#spilledAt.clear();
710
809
  // Whoever is waiting for a slot is woken and finds the store closed, which
711
810
  // is an error they can report. Left asleep they simply never returned.
712
811
  this.#wake();
@@ -717,6 +816,7 @@ export class SharedPieceStore {
717
816
  this.#closed = true;
718
817
  liveStores.delete(this);
719
818
  this.#buffers.clear();
819
+ this.#spilledAt.clear();
720
820
  this.#wake();
721
821
  this.#disk.destroy().then(() => callback(null), (error) => callback(error));
722
822
  }
@@ -565,7 +565,10 @@ setInterval(() => {
565
565
  }
566
566
  }
567
567
  for (const stats of collectStoreStats()) {
568
- const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}/${stats.evictedOnRevise}/${stats.spillFailures}`;
568
+ const signature =
569
+ `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/` +
570
+ `${stats.blockedByPins}/${stats.evictedOnRevise}/${stats.spillFailures}/` +
571
+ `${stats.evictedProtected}/${stats.demand?.unionPieces ?? 0}`;
569
572
  if (lastReported.get(stats.name) === signature) {
570
573
  continue;
571
574
  }
@@ -586,6 +589,35 @@ setInterval(() => {
586
589
  (stats.spillFailures > 0 ? ` spill-failures=${stats.spillFailures}` : "") +
587
590
  (stats.outstanding > 0 ? ` outstanding=${stats.outstanding}` : "")
588
591
  );
592
+ // Why it spills, on its own line because it is a different question from
593
+ // how much it holds. Three facts, and between them they say whether the
594
+ // thrashing is a policy to fix or arithmetic to accept: what the readers
595
+ // together are asking to keep against what the store may hold; how many
596
+ // evictions had to take a piece a reader had declared it wants; and how
597
+ // long a piece stayed on disk before it was wanted back. On 2026-09-02 a
598
+ // session did 6565 spills and 7575 revivals with 53.6% of reads served
599
+ // from memory, and nothing recorded which of the three was the cause
600
+ // (roadmap item 9).
601
+ const demand = stats.demand;
602
+ if (demand && demand.readers > 0) {
603
+ const age = stats.revivalAgeMedianMs;
604
+ log(
605
+ `piece-store "${stats.name.slice(0, 40)}" demand: ${demand.readers} reader(s) want ` +
606
+ `${demand.unionPieces} piece(s) of ${demand.capacity} the store may hold ` +
607
+ `(widest window ${demand.widestPieces})` +
608
+ (stats.evictedProtected > 0
609
+ ? `; ${stats.evictedProtected} of ${stats.spills} eviction(s) took a piece a reader had declared`
610
+ : "; no eviction has taken a declared piece") +
611
+ (stats.evictedWithDistance > 0
612
+ ? `, a victim lay ${(stats.evictedDistanceSum / stats.evictedWithDistance).toFixed(1)} ` +
613
+ "piece(s) from the nearest window on average"
614
+ : "") +
615
+ (age === null
616
+ ? "; nothing has come back from disk yet"
617
+ : `; a revived piece had been on disk ${(age / 1000).toFixed(1)}s (median of ` +
618
+ `${stats.revivalAgeSamples}, ${stats.revivedWithinFiveSeconds} of them within 5s)`)
619
+ );
620
+ }
589
621
  }
590
622
  }, STORE_REPORT_INTERVAL_MS).unref();
591
623
 
@@ -189,3 +189,66 @@ test("the capacity follows the store's live allowance", () => {
189
189
  lru.setCapacity(0);
190
190
  assert.equal(lru.capacity, 2, "a capacity below one is refused, not obeyed");
191
191
  });
192
+
193
+ test("the demand is the union of the readers' windows, not their sum", () => {
194
+ const lru = new PieceLru(88);
195
+ // Two readers of one file — picture and sound — overlap by construction.
196
+ // Summing them would say the store is short when it is not.
197
+ lru.protect("video", 100, 149);
198
+ lru.protect("audio", 130, 179);
199
+
200
+ const demand = lru.demand();
201
+ assert.equal(demand.readers, 2);
202
+ assert.equal(demand.unionPieces, 80, "100..179 is eighty pieces, not a hundred");
203
+ assert.equal(demand.widestPieces, 50);
204
+ assert.equal(demand.capacity, 88);
205
+
206
+ lru.protect("second-viewer", 900, 979);
207
+ assert.equal(lru.demand().unionPieces, 160, "windows that do not touch add up");
208
+
209
+ lru.unprotect("second-viewer");
210
+ lru.unprotect("audio");
211
+ lru.unprotect("video");
212
+ assert.deepEqual(
213
+ lru.demand(),
214
+ { readers: 0, unionPieces: 0, widestPieces: 0, capacity: 88 },
215
+ "no reader asking for anything is not the same as asking for one piece"
216
+ );
217
+ });
218
+
219
+ test("an eviction says whether it had to take a piece a reader declared", () => {
220
+ const lru = new PieceLru(3);
221
+ lru.touch(10);
222
+ lru.touch(11);
223
+ lru.touch(12);
224
+ lru.protect("video", 11, 12);
225
+
226
+ const spare = lru.evictionChoice();
227
+ assert.equal(spare.index, 10, "the piece outside every window goes first");
228
+ assert.equal(spare.protectionYielded, false);
229
+ assert.equal(spare.distance, 1, "one piece away from the window at 11");
230
+
231
+ // Nothing spare left: both survivors are inside the declared window.
232
+ lru.remove(10);
233
+ const forced = lru.evictionChoice();
234
+ assert.equal(forced.index, 11);
235
+ assert.equal(forced.protectionYielded, true, "the store is holding less than it is asked to");
236
+ assert.equal(forced.distance, 0, "inside a window");
237
+
238
+ assert.equal(lru.evictionCandidate(), 11, "the older answer is the same choice");
239
+ });
240
+
241
+ test("with nothing declared there is no distance to report", () => {
242
+ const lru = new PieceLru(2);
243
+ lru.touch(7);
244
+ const choice = lru.evictionChoice();
245
+ assert.equal(choice.index, 7);
246
+ assert.equal(choice.distance, -1, "-1 is absence, and 0 would read as inside a window");
247
+
248
+ lru.pin(7);
249
+ assert.deepEqual(
250
+ lru.evictionChoice(),
251
+ { index: null, protectionYielded: false, distance: -1 },
252
+ "a pinned piece is never a candidate, and says so without a distance"
253
+ );
254
+ });
@@ -132,3 +132,52 @@ test("pinned pieces are never evicted, and the pin count is reported", async ()
132
132
  await fs.rm(directory, { recursive: true, force: true });
133
133
  }
134
134
  });
135
+
136
+ test("the store says why it spills: what is asked of it, what it had to take, how soon it came back", async () => {
137
+ const capacity = 4;
138
+ const { store, directory } = await makeStore(capacity);
139
+ try {
140
+ // A reader declaring more than the store may hold. This is the shape the
141
+ // field session of 2026-09-02 is suspected of — 88 slots against an
142
+ // encoder running 120-380 s ahead of a viewer, half the reads missing —
143
+ // and nothing recorded it (roadmap item 9).
144
+ store.protectRange("video", 0, 9);
145
+ for (let index = 0; index < 10; index += 1) {
146
+ await put(store, index, pieceOf(index));
147
+ }
148
+
149
+ const asked = store.stats();
150
+ assert.equal(asked.demand.readers, 1);
151
+ assert.equal(asked.demand.unionPieces, 10);
152
+ assert.equal(asked.demand.capacity, capacity);
153
+ assert.ok(
154
+ asked.demand.unionPieces > asked.demand.capacity,
155
+ "a reader asking for more than the store holds is arithmetic, not a policy fault"
156
+ );
157
+ assert.ok(
158
+ asked.evictedProtected > 0,
159
+ "every eviction here had to take a piece the reader had declared"
160
+ );
161
+
162
+ // Read back the pieces that were spilled: each one comes home, and the
163
+ // store says how long it had been away.
164
+ for (let index = 0; index < 10; index += 1) {
165
+ const bytes = await get(store, index);
166
+ assert.ok(bytes.equals(pieceOf(index)), `piece ${index} came back changed`);
167
+ }
168
+
169
+ const after = store.stats();
170
+ assert.ok(after.revivalAgeSamples > 0, "pieces came back and their age was recorded");
171
+ assert.equal(typeof after.revivalAgeMedianMs, "number");
172
+ assert.ok(
173
+ after.revivedWithinFiveSeconds > 0,
174
+ "a piece wanted again seconds after it left should not have left"
175
+ );
176
+
177
+ store.releaseProtection("video");
178
+ assert.equal(store.stats().demand.readers, 0, "a reader that ends stops being counted");
179
+ } finally {
180
+ store.destroy(() => undefined);
181
+ await fs.rm(directory, { recursive: true, force: true });
182
+ }
183
+ });