@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
|
@@ -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
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
package/test/piece-lru.test.js
CHANGED
|
@@ -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
|
+
});
|