@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.0",
3
+ "version": "2.80.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": {
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
- store.protectRange(window.claimant, range.from, range.to);
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 gapsWanted = [];
236
- for (const span of [...wanted].sort(
237
- (left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from
238
- )) {
239
- const gap = coverage.firstGapFrom(span.from, span.to);
240
- if (gap !== null) {
241
- gapsWanted.push(gap);
242
- }
243
- }
244
- const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
245
- const budget = Math.max(0, maxRuns - surviving.size);
246
- for (const gap of [...new Set(gapsWanted)]) {
247
- if (starts.length >= budget) {
248
- break;
249
- }
250
- if (alreadyPlanned.has(gap)) {
251
- continue;
252
- }
253
- const free = coverage.freeRunFrom(gap);
254
- if (free === 0) {
255
- continue;
256
- }
257
- alreadyPlanned.add(gap);
258
- starts.push({
259
- type: "start",
260
- from: gap,
261
- to: endOfStretch(gap, free),
262
- because: `#${gap} is wanted and nobody is making it`
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
- // The slope between two progress reports, which is what this class
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
- const windows = this.demand.windowsOn(address).map((window) => ({
248
- from: window.from,
249
- to: window.to,
250
- priority: Number(window.priority) || 0
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
- * - **recency**, so the piece evicted is the one least likely to be wanted;
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
- * Reader id → the piece range it expects to read next.
38
+ * Claimant → the piece range it states, and how much it wants it.
32
39
  *
33
- * Recency alone does not describe this. A reader walking a film touches its
34
- * pieces once, so the piece the decoder will want in two seconds looks
35
- * exactly as stale as one fetched forty minutes ago and never read again
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 window would deadlock it; when
42
- * nothing unprotected is left, protection is ignored rather than obeyed.
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 recently used piece that is free to go, or `null` when every
168
- * resident piece is pinned.
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
- // First choice: the least recently used piece nobody is reading and nobody
198
- // is about to read.
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 (!this.#pins.has(index) && !this.#isProtected(index)) {
201
- return { index, protectionYielded: false, distance: this.#distanceToWindow(index) };
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
- // Nothing spare left. Protection yields — it is a preference, and refusing
205
- // here would leave the store unable to admit anything at all. Pins do not
206
- // yield: a piece being read now cannot have its memory taken away.
207
- for (const index of this.#order) {
208
- if (!this.#pins.has(index)) {
209
- return { index, protectionYielded: true, distance: this.#distanceToWindow(index) };
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 { index: null, protectionYielded: false, distance: -1 };
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, and how long it will be waited for.
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
- * Declare the pieces a reader expects to need next, replacing whatever it
346
- * declared before. Ranges from different readers add up.
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 reader, so its own range
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, { from, to });
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
  /**