@torrent-tv/proxy 2.82.0 → 2.83.0
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 +9 -0
- package/CLAUDE.md +11 -0
- package/docs/disk-architecture.md +161 -0
- package/docs/encode-architecture.md +36 -7
- package/package.json +1 -1
- package/services/disk/keep.js +48 -0
- package/services/disk/returns.js +103 -0
- package/services/encode/Encoder.js +15 -0
- package/services/encode/QsvEncoder.js +5 -0
- package/services/encode/SegmentStore.js +14 -0
- package/services/encode/VaapiEncoder.js +5 -0
- package/services/encode/start-stop-cost.js +6 -2
- package/services/hls-session-manager.js +17 -1
- package/services/hwaccel.js +4 -0
- package/services/piece-store/piece-disk-store.js +71 -6
- package/services/piece-store/piece-lru.js +17 -0
- package/services/piece-store/shared-piece-store.js +1556 -1549
- package/services/torrent-pool.js +8 -7
- package/test/keeping-period.test.js +83 -0
- package/test/piece-disk-store.test.js +88 -0
|
@@ -1,1549 +1,1556 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Torrent pieces in shared memory, with disk as the second tier.
|
|
3
|
-
*
|
|
4
|
-
* Replaces the chunk store WebTorrent would otherwise build for itself. Two
|
|
5
|
-
* reasons, one forced and one chosen.
|
|
6
|
-
*
|
|
7
|
-
* **Forced.** WebTorrent's own piece cache hands out the buffer it keeps using
|
|
8
|
-
* and slices again on the next read. The torrent client now runs on its own
|
|
9
|
-
* thread, and moving a piece to the main thread by transferring ownership
|
|
10
|
-
* detached the cache's memory: proxy 2.9.71-2.9.73 answered every read with an
|
|
11
|
-
* empty body (`Stream ends prematurely at 0`) and the failure was invisible,
|
|
12
|
-
* because the error never reached the reader. Owning the memory ourselves
|
|
13
|
-
* removes the question of whose it was.
|
|
14
|
-
*
|
|
15
|
-
* **Chosen.** Memory this side of the thread boundary can be *shared* memory,
|
|
16
|
-
* which the main thread reads without receiving bytes as a copy — see
|
|
17
|
-
* {@link SharedPieceStore#locate}. Measured on the field host: a piece copy
|
|
18
|
-
* costs 3.64 ms, a read from the page cache into a buffer we own 7.63 ms, and
|
|
19
|
-
* re-downloading a piece from the swarm ~1430 ms. So memory first, disk under
|
|
20
|
-
* it, and never the swarm twice.
|
|
21
|
-
*
|
|
22
|
-
* Per piece allocation: each resident piece owns its own `SharedArrayBuffer`.
|
|
23
|
-
* Evicting a piece deletes its entry and the memory is reclaimable by GC.
|
|
24
|
-
* `committed` therefore equals `resident`, not a high-water mark.
|
|
25
|
-
*
|
|
26
|
-
* **Room for a piece is an owned reservation, not a shared number.**
|
|
27
|
-
* `#claimSlot` hands back a release the caller runs in a `finally`; nothing
|
|
28
|
-
* else touches `#outstandingPieces`. It was a counter incremented in one
|
|
29
|
-
* function and decremented in another, and every consequence of that was a
|
|
30
|
-
* defect: a failure between the two lost a slot for the life of the process,
|
|
31
|
-
* `put`'s error path guessed at the correction and could take back a
|
|
32
|
-
* reservation belonging to a different claim, and one lost reservation made
|
|
33
|
-
* the five-second "everything is pinned" error permanently unreachable, so a
|
|
34
|
-
* read retried every 50 ms for ever without completing or failing. Read out of
|
|
35
|
-
* the field failure of 2026-08-31 (`research/worker-heap-oom-2026-08-31.md`).
|
|
36
|
-
*
|
|
37
|
-
* What this deliberately does NOT do is manage the disk as a cache of its own.
|
|
38
|
-
* Pieces evicted from memory are written once and read back on demand; the file
|
|
39
|
-
* is discarded whole when the torrent goes away. libtorrent 2.0 and webtor's
|
|
40
|
-
* seeder both concluded that a hand-rolled disk cache earns less than it costs,
|
|
41
|
-
* and nothing here disagrees.
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
import os from "node:os";
|
|
45
|
-
import { readFileSync } from "node:fs";
|
|
46
|
-
import { divideAllowance, machineAllowanceBytes, OtherDemand } from "./allowance.js";
|
|
47
|
-
import { PieceLru } from "./piece-lru.js";
|
|
48
|
-
import { PieceDiskStore } from "./piece-disk-store.js";
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Live stores, so the worker can report on them.
|
|
52
|
-
*
|
|
53
|
-
* @type {Set<SharedPieceStore>}
|
|
54
|
-
*/
|
|
55
|
-
const liveStores = new Set();
|
|
56
|
-
|
|
57
|
-
export function collectStoreStats() {
|
|
58
|
-
return [...liveStores].map((store) => store.stats());
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function findSharedStore(torrent) {
|
|
62
|
-
let candidate = torrent?.store;
|
|
63
|
-
for (let depth = 0; candidate && depth < 8; depth += 1) {
|
|
64
|
-
if (candidate instanceof SharedPieceStore) {
|
|
65
|
-
return candidate;
|
|
66
|
-
}
|
|
67
|
-
candidate = candidate.store;
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The largest fall in the machine's available memory that this process has
|
|
74
|
-
* seen and did not cause itself.
|
|
75
|
-
*
|
|
76
|
-
* What has to be left for everything else on the machine. It cannot be a share
|
|
77
|
-
* of what is free — a share is a number chosen out of nothing, which is what
|
|
78
|
-
* `AVAILABLE_MEMORY_SHARE = 0.25`, `MIN_BUDGET_BYTES` and
|
|
79
|
-
* `MEMORY_BUDGET_CEILING_BYTES` were until 2026-09-02, all three traceable to
|
|
80
|
-
* one observation of one host on 2026-08-03. It is measured instead: between
|
|
81
|
-
* two readings, how much available memory went away beyond what the stores
|
|
82
|
-
* themselves took. On the field host that quantity is large — while the proxy
|
|
83
|
-
* held 76-133 MB overnight the machine's available memory fell from 2378 MB to
|
|
84
|
-
* 306 MB and came back — and on a quiet host it stays near zero, which is the
|
|
85
|
-
* right answer there.
|
|
86
|
-
*
|
|
87
|
-
* Starts at zero: nothing is reserved until somebody else has been seen to
|
|
88
|
-
* need it.
|
|
89
|
-
*/
|
|
90
|
-
const otherDemand = new OtherDemand();
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Note what the machine had, and how much of the change was not ours.
|
|
94
|
-
*
|
|
95
|
-
* @param {number} availableBytes
|
|
96
|
-
* @param {number} storeBytes - What the live stores hold right now.
|
|
97
|
-
* @returns {number} The reserve, in bytes.
|
|
98
|
-
*/
|
|
99
|
-
export function noteMachineMemory(availableBytes, storeBytes) {
|
|
100
|
-
return otherDemand.note(availableBytes, storeBytes);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** What has recently been observed to be needed by everything that is not us. */
|
|
104
|
-
export function machineReserveBytes() {
|
|
105
|
-
return otherDemand.reserve();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Forget what other processes have needed. For tests, which share a module. */
|
|
109
|
-
export function forgetMachineMemory() {
|
|
110
|
-
otherDemand.forget();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export { divideAllowance, machineAllowanceBytes };
|
|
114
|
-
|
|
115
|
-
export function reviseStoreBudgets() {
|
|
116
|
-
const stores = [...liveStores];
|
|
117
|
-
const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
|
|
118
|
-
const available = availableMemorySync();
|
|
119
|
-
const reserve = noteMachineMemory(available, held);
|
|
120
|
-
const allowance = machineAllowanceBytes(available, held, reserve);
|
|
121
|
-
const shares = divideAllowance(stores.map((store) => store.wantedBytes), allowance);
|
|
122
|
-
const revised = [];
|
|
123
|
-
for (const [position, store] of stores.entries()) {
|
|
124
|
-
revised.push(store.reviseGrowthCeiling(shares[position]));
|
|
125
|
-
}
|
|
126
|
-
return revised;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Say how much disk the spilled pieces may take between them.
|
|
131
|
-
*
|
|
132
|
-
* Told rather than worked out. The disk is one and this store is not its only
|
|
133
|
-
* user — the segments an encoder produces are on it too — so a ceiling one of
|
|
134
|
-
* two users sets for itself is not a ceiling. Until 2026-09-10 the spill had no
|
|
135
|
-
* ceiling of any kind: 14 400 MB written in one fifty-minute viewing.
|
|
136
|
-
*
|
|
137
|
-
* @param {number | null} allowanceBytes - This thread's whole share, from the
|
|
138
|
-
* owner of the disk on the main thread. Null means nobody has said yet, and
|
|
139
|
-
* nothing is thrown away until somebody does.
|
|
140
|
-
* @param {SharedPieceStore[]} [live] - The stores to divide between.
|
|
141
|
-
* @returns {{ name: string, allowanceBytes: number | null, bytes: number }[]}
|
|
142
|
-
*/
|
|
143
|
-
export function reviseSpillBudgets(allowanceBytes, live = [...liveStores]) {
|
|
144
|
-
if (live.length === 0) {
|
|
145
|
-
return [];
|
|
146
|
-
}
|
|
147
|
-
// NOT READ HERE. The disk has one owner and it is on the main thread, where
|
|
148
|
-
// the segments an encoder produces live on the same disk. Reading it here as
|
|
149
|
-
// well is exactly the fault this replaced: two ceilings, each standing for the
|
|
150
|
-
// whole disk. What arrives is this thread's whole share.
|
|
151
|
-
const total = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
|
|
152
|
-
if (total === null) {
|
|
153
|
-
return live.map((store) => store.reviseSpillCeiling(null));
|
|
154
|
-
}
|
|
155
|
-
// EQUALLY, unlike memory. A store's memory ask is its readers' declared
|
|
156
|
-
// windows; the spill has no such statement, because whatever memory evicts
|
|
157
|
-
// arrives here. Every store's ask is "all of it", nothing distinguishes them,
|
|
158
|
-
// and an equal share is what that means.
|
|
159
|
-
const share = Math.floor(total / live.length);
|
|
160
|
-
return live.map((store) => store.reviseSpillCeiling(share));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function defaultMemoryBytes() {
|
|
164
|
-
const stores = [...liveStores];
|
|
165
|
-
const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
|
|
166
|
-
const available = availableMemorySync();
|
|
167
|
-
const allowance = machineAllowanceBytes(available, held, machineReserveBytes());
|
|
168
|
-
// A store being created has no readers, so it has no demand to state and no
|
|
169
|
-
// basis for asking for more or less than the others. It opens on an equal
|
|
170
|
-
// share and the first revision — within a minute, and within seconds of a
|
|
171
|
-
// read starting — replaces that with what its readers actually declare.
|
|
172
|
-
// Deliberately not the whole allowance: on a machine with gigabytes free that
|
|
173
|
-
// would let a torrent nobody is reading yet fill memory before the first
|
|
174
|
-
// revision arrives.
|
|
175
|
-
return Math.floor(allowance / (stores.length + 1));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function availableMemorySync() {
|
|
179
|
-
try {
|
|
180
|
-
const text = readFileSync("/proc/meminfo", "utf8");
|
|
181
|
-
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(text);
|
|
182
|
-
if (match) {
|
|
183
|
-
return Number(match[1]) * 1024;
|
|
184
|
-
}
|
|
185
|
-
} catch {
|
|
186
|
-
}
|
|
187
|
-
return os.freemem();
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* How many blocks of piece memory this thread has let go of, and how many the
|
|
192
|
-
* collector has actually taken back.
|
|
193
|
-
*
|
|
194
|
-
* Since the store keeps a pool, one block serves many pieces, so this counts
|
|
195
|
-
* blocks and not pieces — and a healthy store allocates only as many as its
|
|
196
|
-
* allowance permits, so both numbers are now small and nearly equal.
|
|
197
|
-
*
|
|
198
|
-
* The one reading that separates the two explanations of the 700 MB nobody can
|
|
199
|
-
* account for (roadmap item 2, field 2026-08-31): if the two numbers track each
|
|
200
|
-
* other, this code is holding nothing and whatever grows is below us, in the
|
|
201
|
-
* allocator — on musl there is no `malloc_trim` and no way to ask. If the gap
|
|
202
|
-
* widens, a reference of ours outlives the piece, and then a heap snapshot can
|
|
203
|
-
* name the holder.
|
|
204
|
-
*
|
|
205
|
-
* What it does NOT prove: a `SharedArrayBuffer`'s memory is shared between the
|
|
206
|
-
* isolates, so this thread's handle going means only that THIS thread let go.
|
|
207
|
-
* The main thread counts its own (`torrent-worker/client.js`), and the pair is
|
|
208
|
-
* what answers the question.
|
|
209
|
-
*/
|
|
210
|
-
const released = { count: 0, collected: 0 };
|
|
211
|
-
const collector = typeof FinalizationRegistry === "function"
|
|
212
|
-
? new FinalizationRegistry(() => {
|
|
213
|
-
released.collected += 1;
|
|
214
|
-
})
|
|
215
|
-
: null;
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* What the collector has taken back against what was let go.
|
|
219
|
-
*
|
|
220
|
-
* @returns {{ released: number, collected: number }}
|
|
221
|
-
*/
|
|
222
|
-
export function pieceBufferCollection() {
|
|
223
|
-
return { released: released.count, collected: released.collected };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const MIN_RESIDENT_PIECES = 2;
|
|
227
|
-
/**
|
|
228
|
-
* How long a claim may go without ANYTHING moving before it gives up.
|
|
229
|
-
*
|
|
230
|
-
* Measured against progress, not against activity. The earlier rule skipped
|
|
231
|
-
* this timer entirely while a spill was in flight or a reservation was held —
|
|
232
|
-
* so one reservation that was never returned made the timer unreachable and a
|
|
233
|
-
* read retried every 50 ms for the life of the process, never completing and
|
|
234
|
-
* never failing (field 2026-08-31).
|
|
235
|
-
*/
|
|
236
|
-
const PINNED_WAIT_MS = 5_000;
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* How many revival ages are kept for the median. A window, not a history: two
|
|
240
|
-
* hundred covers several minutes of the busiest session measured (7575
|
|
241
|
-
* revivals in 44 minutes) and costs two hundred numbers.
|
|
242
|
-
*/
|
|
243
|
-
const REVIVAL_AGE_SAMPLES = 200;
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* How many block re-use gaps are kept. The same window as the revival ages, and
|
|
247
|
-
* for the same reason: what is wanted is the rhythm of recent work, not a
|
|
248
|
-
* history of it.
|
|
249
|
-
*/
|
|
250
|
-
const REUSE_GAP_SAMPLES = 200;
|
|
251
|
-
|
|
252
|
-
/** How many write durations are kept for the median. */
|
|
253
|
-
const WRITE_DURATION_SAMPLES = 50;
|
|
254
|
-
/** How many admission times are kept, and how far back they are counted. */
|
|
255
|
-
const ARRIVAL_SAMPLES = 100;
|
|
256
|
-
const ARRIVAL_WINDOW_MS = 10_000;
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* The middle value of a sample, or null when there is nothing to take a middle
|
|
260
|
-
* of. Null rather than zero: no revivals and instant revivals are different
|
|
261
|
-
* facts and must not print the same.
|
|
262
|
-
*
|
|
263
|
-
* @param {number[]} values
|
|
264
|
-
* @returns {number | null}
|
|
265
|
-
*/
|
|
266
|
-
function median(values) {
|
|
267
|
-
if (values.length === 0) {
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
const sorted = [...values].sort((left, right) => left - right);
|
|
271
|
-
const middle = Math.floor(sorted.length / 2);
|
|
272
|
-
return sorted.length % 2 === 0
|
|
273
|
-
? Math.round((sorted[middle - 1] + sorted[middle]) / 2)
|
|
274
|
-
: sorted[middle];
|
|
275
|
-
}
|
|
276
|
-
const CLAIM_RETRY_MS = 50;
|
|
277
|
-
|
|
278
|
-
export class SharedPieceStore {
|
|
279
|
-
#chunkLength;
|
|
280
|
-
#lastChunkLength;
|
|
281
|
-
#lastChunkIndex;
|
|
282
|
-
#growthCeiling;
|
|
283
|
-
/** Piece index → SharedArrayBuffer of that piece */
|
|
284
|
-
#buffers = new Map();
|
|
285
|
-
/** @type {Map<number, Promise<void>>} */
|
|
286
|
-
#evicting = new Map();
|
|
287
|
-
/**
|
|
288
|
-
* Slots claimed but not yet filled.
|
|
289
|
-
*
|
|
290
|
-
* Handed out by {@link SharedPieceStore##claimSlot} as a release function the
|
|
291
|
-
* caller must call in a `finally`, never as a number one function increments
|
|
292
|
-
* and another decrements. The counter used to be paired across
|
|
293
|
-
* `#claimSlot`/`#registerPiece`, so any failure between the two lost a slot
|
|
294
|
-
* for the life of the process, and `put`'s error path tried to correct that
|
|
295
|
-
* by guessing — which could take back a reservation belonging to a different
|
|
296
|
-
* claim and let the store admit past its allowance.
|
|
297
|
-
*/
|
|
298
|
-
#outstandingPieces = 0;
|
|
299
|
-
/** When something last actually moved: a piece admitted, spilled or unpinned. */
|
|
300
|
-
#lastProgressAt = 0;
|
|
301
|
-
#waiters = [];
|
|
302
|
-
#lru;
|
|
303
|
-
#disk;
|
|
304
|
-
#closed = false;
|
|
305
|
-
#name;
|
|
306
|
-
#counters = {
|
|
307
|
-
fromMemory: 0,
|
|
308
|
-
fromDisk: 0,
|
|
309
|
-
spills: 0,
|
|
310
|
-
revivals: 0,
|
|
311
|
-
blockedByPins: 0,
|
|
312
|
-
waitedForPins: 0,
|
|
313
|
-
evictedOnRevise: 0,
|
|
314
|
-
spillFailures: 0,
|
|
315
|
-
// Whether the store is doing its job or being asked to hold more than it
|
|
316
|
-
// has room for. An eviction that had to take a piece a reader declared it
|
|
317
|
-
// wants is the second, and it comes back from disk moments later
|
|
318
|
-
// (roadmap item 9).
|
|
319
|
-
evictedProtected: 0,
|
|
320
|
-
evictedDistanceSum: 0,
|
|
321
|
-
evictedWithDistance: 0,
|
|
322
|
-
// Where an arriving piece went. A piece nobody has declared is being
|
|
323
|
-
// downloaded ahead of every reader; putting it in memory pushes out one
|
|
324
|
-
// that IS declared, which is then read back from disk moments later
|
|
325
|
-
// (roadmap item 9).
|
|
326
|
-
admittedInsideWindow: 0,
|
|
327
|
-
admittedOutsideWindow: 0,
|
|
328
|
-
admittedToDisk: 0,
|
|
329
|
-
/** Blocks given back to the operating system. */
|
|
330
|
-
blocksReleased: 0,
|
|
331
|
-
/**
|
|
332
|
-
* A block put back for re-use while a reader still held the piece. Zero by
|
|
333
|
-
* construction — a pinned piece is never evicted, and a re-put of a pinned
|
|
334
|
-
* one drops its block instead of recycling it. Counted because if it ever
|
|
335
|
-
* stops being zero, a consumer is reading another piece's bytes, and that
|
|
336
|
-
* is invisible from anywhere else.
|
|
337
|
-
*/
|
|
338
|
-
returnedWhilePinned: 0,
|
|
339
|
-
/** Spills that found the disk already holding identical bytes. */
|
|
340
|
-
spillsSkipped: 0,
|
|
341
|
-
/**
|
|
342
|
-
* Blocks a second registration of the same piece displaced. Expected to be
|
|
343
|
-
* small and non-zero: two callers racing for one piece is ordinary. What is
|
|
344
|
-
* NOT ordinary is the block going missing when it happens, which is what
|
|
345
|
-
* killed the process on 2026-09-02.
|
|
346
|
-
*/
|
|
347
|
-
blocksDisplaced: 0,
|
|
348
|
-
/** Admissions that waited for the disk instead of evicting another piece. */
|
|
349
|
-
waitedForDisk: 0,
|
|
350
|
-
/**
|
|
351
|
-
* Admissions that grew memory because the disk had stopped answering.
|
|
352
|
-
* Non-zero means the store exceeded its allowance on purpose, and by how
|
|
353
|
-
* many pieces.
|
|
354
|
-
*/
|
|
355
|
-
grewWaitingForDisk: 0,
|
|
356
|
-
/**
|
|
357
|
-
* Times a block was wanted, none was free, and the pool was already at its
|
|
358
|
-
* ceiling. Zero by construction — a block is only taken after a slot has
|
|
359
|
-
* been claimed, and slots are what the ceiling counts — so a number here
|
|
360
|
-
* means the two have come apart and the pool is growing past its allowance.
|
|
361
|
-
*/
|
|
362
|
-
blocksBeyondCeiling: 0
|
|
363
|
-
};
|
|
364
|
-
/**
|
|
365
|
-
* Blocks that hold no piece, most recently freed last.
|
|
366
|
-
*
|
|
367
|
-
* A block is one piece's worth of memory. Allocating a new one for every
|
|
368
|
-
* piece meant 7575 allocations of 4 MiB in 44 minutes on 2026-09-02, each
|
|
369
|
-
* released only when the collector got to it — which is why the process held
|
|
370
|
-
* 1.86 GB while its own accounting said 352 MB. Blocks are taken from here
|
|
371
|
-
* and put back here instead (roadmap item 2).
|
|
372
|
-
*
|
|
373
|
-
* @type {{ buffer: SharedArrayBuffer, freedAt: number }[]}
|
|
374
|
-
*/
|
|
375
|
-
#freeBlocks = [];
|
|
376
|
-
/** Blocks that exist at all: free plus holding a piece. */
|
|
377
|
-
#blocksAllocated = 0;
|
|
378
|
-
/** Whether a reader has ever declared a window here. See `wantedBytes`. */
|
|
379
|
-
#everHadReader = false;
|
|
380
|
-
/** Whether the last revision had to exceed the machine's share. */
|
|
381
|
-
#beyondTheMachine = false;
|
|
382
|
-
/**
|
|
383
|
-
* How long a block sat free before it was taken again, in milliseconds.
|
|
384
|
-
* Bounded, because what is wanted is the longest gap of RECENT work: an
|
|
385
|
-
* all-time maximum would be raised by one long pause and then never let a
|
|
386
|
-
* block go again.
|
|
387
|
-
*
|
|
388
|
-
* @type {number[]}
|
|
389
|
-
*/
|
|
390
|
-
#reuseGaps = [];
|
|
391
|
-
/**
|
|
392
|
-
* How long recent writes to disk took, in milliseconds, and when pieces were
|
|
393
|
-
* admitted. Together they say how much room the store needs beyond what the
|
|
394
|
-
* readers ask for: while one write is finishing, more pieces arrive, and each
|
|
395
|
-
* needs somewhere to go. Without that room every arrival evicts something,
|
|
396
|
-
* which is what produced 233 evictions against 119 completed writes in a
|
|
397
|
-
* minute on 2026-09-02.
|
|
398
|
-
*
|
|
399
|
-
* @type {number[]}
|
|
400
|
-
*/
|
|
401
|
-
#writeDurations = [];
|
|
402
|
-
/** When the last few pieces were admitted, for the arrival rate. */
|
|
403
|
-
#admittedAt = [];
|
|
404
|
-
/** Piece index → when it was written out, for the age it comes back at. */
|
|
405
|
-
#spilledAt = new Map();
|
|
406
|
-
/**
|
|
407
|
-
* Ages, in milliseconds, of the last revivals — bounded, because the figure
|
|
408
|
-
* wanted is a median and not a history. A piece that comes back seconds
|
|
409
|
-
* after it left should not have left.
|
|
410
|
-
*
|
|
411
|
-
* @type {number[]}
|
|
412
|
-
*/
|
|
413
|
-
#revivalAges = [];
|
|
414
|
-
|
|
415
|
-
constructor(chunkLength, options = {}) {
|
|
416
|
-
if (!Number.isInteger(chunkLength) || chunkLength < 1) {
|
|
417
|
-
throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
|
|
418
|
-
}
|
|
419
|
-
this.#chunkLength = chunkLength;
|
|
420
|
-
|
|
421
|
-
const totalLength = Number.isFinite(options.length) ? options.length : 0;
|
|
422
|
-
this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
|
|
423
|
-
const remainder = totalLength % chunkLength;
|
|
424
|
-
this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
|
|
425
|
-
|
|
426
|
-
const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
|
|
427
|
-
? options.memoryBytes
|
|
428
|
-
: defaultMemoryBytes();
|
|
429
|
-
// Only where this store STARTS. It is not kept, so it cannot come back as a
|
|
430
|
-
// cap on the revision the way `#capacity` did.
|
|
431
|
-
const openingCeiling = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
|
|
432
|
-
this.#growthCeiling = openingCeiling;
|
|
433
|
-
this.#lru = new PieceLru(openingCeiling);
|
|
434
|
-
this.#name = options.name ?? "pieces";
|
|
435
|
-
// `options.disk` exists so a test can hold a write open or make one fail on
|
|
436
|
-
// purpose. Four of the defects fixed here live in what happens when the
|
|
437
|
-
// disk tier does not answer immediately or at all, and none of them is
|
|
438
|
-
// reachable from outside without saying so.
|
|
439
|
-
this.#disk = options.disk ?? new PieceDiskStore({
|
|
440
|
-
directory: options.path ?? ".",
|
|
441
|
-
name: `${this.#name}.pieces`,
|
|
442
|
-
chunkLength,
|
|
443
|
-
// What it may hold is settled by the same revision that settles memory,
|
|
444
|
-
// within a minute of the store existing. Until then it is unbounded, which
|
|
445
|
-
// is what it has always been — the difference is that it now stops.
|
|
446
|
-
allowanceBytes: null
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
// the
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
)
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
for
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
this.#
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
()
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
this.#
|
|
836
|
-
this.#
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
this
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
this.#returnBlock(buffer);
|
|
843
|
-
this.#noteProgress();
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
this.#
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
*
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
*
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
*
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
*
|
|
991
|
-
*
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
//
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
)
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
*
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
this.#
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
this.#
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
const
|
|
1458
|
-
if (
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
const
|
|
1518
|
-
if (
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
this.#
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
this.#
|
|
1535
|
-
this.#
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
this.#
|
|
1542
|
-
this.#
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
this.#
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Torrent pieces in shared memory, with disk as the second tier.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the chunk store WebTorrent would otherwise build for itself. Two
|
|
5
|
+
* reasons, one forced and one chosen.
|
|
6
|
+
*
|
|
7
|
+
* **Forced.** WebTorrent's own piece cache hands out the buffer it keeps using
|
|
8
|
+
* and slices again on the next read. The torrent client now runs on its own
|
|
9
|
+
* thread, and moving a piece to the main thread by transferring ownership
|
|
10
|
+
* detached the cache's memory: proxy 2.9.71-2.9.73 answered every read with an
|
|
11
|
+
* empty body (`Stream ends prematurely at 0`) and the failure was invisible,
|
|
12
|
+
* because the error never reached the reader. Owning the memory ourselves
|
|
13
|
+
* removes the question of whose it was.
|
|
14
|
+
*
|
|
15
|
+
* **Chosen.** Memory this side of the thread boundary can be *shared* memory,
|
|
16
|
+
* which the main thread reads without receiving bytes as a copy — see
|
|
17
|
+
* {@link SharedPieceStore#locate}. Measured on the field host: a piece copy
|
|
18
|
+
* costs 3.64 ms, a read from the page cache into a buffer we own 7.63 ms, and
|
|
19
|
+
* re-downloading a piece from the swarm ~1430 ms. So memory first, disk under
|
|
20
|
+
* it, and never the swarm twice.
|
|
21
|
+
*
|
|
22
|
+
* Per piece allocation: each resident piece owns its own `SharedArrayBuffer`.
|
|
23
|
+
* Evicting a piece deletes its entry and the memory is reclaimable by GC.
|
|
24
|
+
* `committed` therefore equals `resident`, not a high-water mark.
|
|
25
|
+
*
|
|
26
|
+
* **Room for a piece is an owned reservation, not a shared number.**
|
|
27
|
+
* `#claimSlot` hands back a release the caller runs in a `finally`; nothing
|
|
28
|
+
* else touches `#outstandingPieces`. It was a counter incremented in one
|
|
29
|
+
* function and decremented in another, and every consequence of that was a
|
|
30
|
+
* defect: a failure between the two lost a slot for the life of the process,
|
|
31
|
+
* `put`'s error path guessed at the correction and could take back a
|
|
32
|
+
* reservation belonging to a different claim, and one lost reservation made
|
|
33
|
+
* the five-second "everything is pinned" error permanently unreachable, so a
|
|
34
|
+
* read retried every 50 ms for ever without completing or failing. Read out of
|
|
35
|
+
* the field failure of 2026-08-31 (`research/worker-heap-oom-2026-08-31.md`).
|
|
36
|
+
*
|
|
37
|
+
* What this deliberately does NOT do is manage the disk as a cache of its own.
|
|
38
|
+
* Pieces evicted from memory are written once and read back on demand; the file
|
|
39
|
+
* is discarded whole when the torrent goes away. libtorrent 2.0 and webtor's
|
|
40
|
+
* seeder both concluded that a hand-rolled disk cache earns less than it costs,
|
|
41
|
+
* and nothing here disagrees.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import os from "node:os";
|
|
45
|
+
import { readFileSync } from "node:fs";
|
|
46
|
+
import { divideAllowance, machineAllowanceBytes, OtherDemand } from "./allowance.js";
|
|
47
|
+
import { PieceLru } from "./piece-lru.js";
|
|
48
|
+
import { PieceDiskStore } from "./piece-disk-store.js";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Live stores, so the worker can report on them.
|
|
52
|
+
*
|
|
53
|
+
* @type {Set<SharedPieceStore>}
|
|
54
|
+
*/
|
|
55
|
+
const liveStores = new Set();
|
|
56
|
+
|
|
57
|
+
export function collectStoreStats() {
|
|
58
|
+
return [...liveStores].map((store) => store.stats());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function findSharedStore(torrent) {
|
|
62
|
+
let candidate = torrent?.store;
|
|
63
|
+
for (let depth = 0; candidate && depth < 8; depth += 1) {
|
|
64
|
+
if (candidate instanceof SharedPieceStore) {
|
|
65
|
+
return candidate;
|
|
66
|
+
}
|
|
67
|
+
candidate = candidate.store;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The largest fall in the machine's available memory that this process has
|
|
74
|
+
* seen and did not cause itself.
|
|
75
|
+
*
|
|
76
|
+
* What has to be left for everything else on the machine. It cannot be a share
|
|
77
|
+
* of what is free — a share is a number chosen out of nothing, which is what
|
|
78
|
+
* `AVAILABLE_MEMORY_SHARE = 0.25`, `MIN_BUDGET_BYTES` and
|
|
79
|
+
* `MEMORY_BUDGET_CEILING_BYTES` were until 2026-09-02, all three traceable to
|
|
80
|
+
* one observation of one host on 2026-08-03. It is measured instead: between
|
|
81
|
+
* two readings, how much available memory went away beyond what the stores
|
|
82
|
+
* themselves took. On the field host that quantity is large — while the proxy
|
|
83
|
+
* held 76-133 MB overnight the machine's available memory fell from 2378 MB to
|
|
84
|
+
* 306 MB and came back — and on a quiet host it stays near zero, which is the
|
|
85
|
+
* right answer there.
|
|
86
|
+
*
|
|
87
|
+
* Starts at zero: nothing is reserved until somebody else has been seen to
|
|
88
|
+
* need it.
|
|
89
|
+
*/
|
|
90
|
+
const otherDemand = new OtherDemand();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Note what the machine had, and how much of the change was not ours.
|
|
94
|
+
*
|
|
95
|
+
* @param {number} availableBytes
|
|
96
|
+
* @param {number} storeBytes - What the live stores hold right now.
|
|
97
|
+
* @returns {number} The reserve, in bytes.
|
|
98
|
+
*/
|
|
99
|
+
export function noteMachineMemory(availableBytes, storeBytes) {
|
|
100
|
+
return otherDemand.note(availableBytes, storeBytes);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What has recently been observed to be needed by everything that is not us. */
|
|
104
|
+
export function machineReserveBytes() {
|
|
105
|
+
return otherDemand.reserve();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Forget what other processes have needed. For tests, which share a module. */
|
|
109
|
+
export function forgetMachineMemory() {
|
|
110
|
+
otherDemand.forget();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { divideAllowance, machineAllowanceBytes };
|
|
114
|
+
|
|
115
|
+
export function reviseStoreBudgets() {
|
|
116
|
+
const stores = [...liveStores];
|
|
117
|
+
const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
|
|
118
|
+
const available = availableMemorySync();
|
|
119
|
+
const reserve = noteMachineMemory(available, held);
|
|
120
|
+
const allowance = machineAllowanceBytes(available, held, reserve);
|
|
121
|
+
const shares = divideAllowance(stores.map((store) => store.wantedBytes), allowance);
|
|
122
|
+
const revised = [];
|
|
123
|
+
for (const [position, store] of stores.entries()) {
|
|
124
|
+
revised.push(store.reviseGrowthCeiling(shares[position]));
|
|
125
|
+
}
|
|
126
|
+
return revised;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Say how much disk the spilled pieces may take between them.
|
|
131
|
+
*
|
|
132
|
+
* Told rather than worked out. The disk is one and this store is not its only
|
|
133
|
+
* user — the segments an encoder produces are on it too — so a ceiling one of
|
|
134
|
+
* two users sets for itself is not a ceiling. Until 2026-09-10 the spill had no
|
|
135
|
+
* ceiling of any kind: 14 400 MB written in one fifty-minute viewing.
|
|
136
|
+
*
|
|
137
|
+
* @param {number | null} allowanceBytes - This thread's whole share, from the
|
|
138
|
+
* owner of the disk on the main thread. Null means nobody has said yet, and
|
|
139
|
+
* nothing is thrown away until somebody does.
|
|
140
|
+
* @param {SharedPieceStore[]} [live] - The stores to divide between.
|
|
141
|
+
* @returns {{ name: string, allowanceBytes: number | null, bytes: number }[]}
|
|
142
|
+
*/
|
|
143
|
+
export function reviseSpillBudgets(allowanceBytes, live = [...liveStores]) {
|
|
144
|
+
if (live.length === 0) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
// NOT READ HERE. The disk has one owner and it is on the main thread, where
|
|
148
|
+
// the segments an encoder produces live on the same disk. Reading it here as
|
|
149
|
+
// well is exactly the fault this replaced: two ceilings, each standing for the
|
|
150
|
+
// whole disk. What arrives is this thread's whole share.
|
|
151
|
+
const total = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
|
|
152
|
+
if (total === null) {
|
|
153
|
+
return live.map((store) => store.reviseSpillCeiling(null));
|
|
154
|
+
}
|
|
155
|
+
// EQUALLY, unlike memory. A store's memory ask is its readers' declared
|
|
156
|
+
// windows; the spill has no such statement, because whatever memory evicts
|
|
157
|
+
// arrives here. Every store's ask is "all of it", nothing distinguishes them,
|
|
158
|
+
// and an equal share is what that means.
|
|
159
|
+
const share = Math.floor(total / live.length);
|
|
160
|
+
return live.map((store) => store.reviseSpillCeiling(share));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function defaultMemoryBytes() {
|
|
164
|
+
const stores = [...liveStores];
|
|
165
|
+
const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
|
|
166
|
+
const available = availableMemorySync();
|
|
167
|
+
const allowance = machineAllowanceBytes(available, held, machineReserveBytes());
|
|
168
|
+
// A store being created has no readers, so it has no demand to state and no
|
|
169
|
+
// basis for asking for more or less than the others. It opens on an equal
|
|
170
|
+
// share and the first revision — within a minute, and within seconds of a
|
|
171
|
+
// read starting — replaces that with what its readers actually declare.
|
|
172
|
+
// Deliberately not the whole allowance: on a machine with gigabytes free that
|
|
173
|
+
// would let a torrent nobody is reading yet fill memory before the first
|
|
174
|
+
// revision arrives.
|
|
175
|
+
return Math.floor(allowance / (stores.length + 1));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function availableMemorySync() {
|
|
179
|
+
try {
|
|
180
|
+
const text = readFileSync("/proc/meminfo", "utf8");
|
|
181
|
+
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(text);
|
|
182
|
+
if (match) {
|
|
183
|
+
return Number(match[1]) * 1024;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
return os.freemem();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* How many blocks of piece memory this thread has let go of, and how many the
|
|
192
|
+
* collector has actually taken back.
|
|
193
|
+
*
|
|
194
|
+
* Since the store keeps a pool, one block serves many pieces, so this counts
|
|
195
|
+
* blocks and not pieces — and a healthy store allocates only as many as its
|
|
196
|
+
* allowance permits, so both numbers are now small and nearly equal.
|
|
197
|
+
*
|
|
198
|
+
* The one reading that separates the two explanations of the 700 MB nobody can
|
|
199
|
+
* account for (roadmap item 2, field 2026-08-31): if the two numbers track each
|
|
200
|
+
* other, this code is holding nothing and whatever grows is below us, in the
|
|
201
|
+
* allocator — on musl there is no `malloc_trim` and no way to ask. If the gap
|
|
202
|
+
* widens, a reference of ours outlives the piece, and then a heap snapshot can
|
|
203
|
+
* name the holder.
|
|
204
|
+
*
|
|
205
|
+
* What it does NOT prove: a `SharedArrayBuffer`'s memory is shared between the
|
|
206
|
+
* isolates, so this thread's handle going means only that THIS thread let go.
|
|
207
|
+
* The main thread counts its own (`torrent-worker/client.js`), and the pair is
|
|
208
|
+
* what answers the question.
|
|
209
|
+
*/
|
|
210
|
+
const released = { count: 0, collected: 0 };
|
|
211
|
+
const collector = typeof FinalizationRegistry === "function"
|
|
212
|
+
? new FinalizationRegistry(() => {
|
|
213
|
+
released.collected += 1;
|
|
214
|
+
})
|
|
215
|
+
: null;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What the collector has taken back against what was let go.
|
|
219
|
+
*
|
|
220
|
+
* @returns {{ released: number, collected: number }}
|
|
221
|
+
*/
|
|
222
|
+
export function pieceBufferCollection() {
|
|
223
|
+
return { released: released.count, collected: released.collected };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const MIN_RESIDENT_PIECES = 2;
|
|
227
|
+
/**
|
|
228
|
+
* How long a claim may go without ANYTHING moving before it gives up.
|
|
229
|
+
*
|
|
230
|
+
* Measured against progress, not against activity. The earlier rule skipped
|
|
231
|
+
* this timer entirely while a spill was in flight or a reservation was held —
|
|
232
|
+
* so one reservation that was never returned made the timer unreachable and a
|
|
233
|
+
* read retried every 50 ms for the life of the process, never completing and
|
|
234
|
+
* never failing (field 2026-08-31).
|
|
235
|
+
*/
|
|
236
|
+
const PINNED_WAIT_MS = 5_000;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* How many revival ages are kept for the median. A window, not a history: two
|
|
240
|
+
* hundred covers several minutes of the busiest session measured (7575
|
|
241
|
+
* revivals in 44 minutes) and costs two hundred numbers.
|
|
242
|
+
*/
|
|
243
|
+
const REVIVAL_AGE_SAMPLES = 200;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* How many block re-use gaps are kept. The same window as the revival ages, and
|
|
247
|
+
* for the same reason: what is wanted is the rhythm of recent work, not a
|
|
248
|
+
* history of it.
|
|
249
|
+
*/
|
|
250
|
+
const REUSE_GAP_SAMPLES = 200;
|
|
251
|
+
|
|
252
|
+
/** How many write durations are kept for the median. */
|
|
253
|
+
const WRITE_DURATION_SAMPLES = 50;
|
|
254
|
+
/** How many admission times are kept, and how far back they are counted. */
|
|
255
|
+
const ARRIVAL_SAMPLES = 100;
|
|
256
|
+
const ARRIVAL_WINDOW_MS = 10_000;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The middle value of a sample, or null when there is nothing to take a middle
|
|
260
|
+
* of. Null rather than zero: no revivals and instant revivals are different
|
|
261
|
+
* facts and must not print the same.
|
|
262
|
+
*
|
|
263
|
+
* @param {number[]} values
|
|
264
|
+
* @returns {number | null}
|
|
265
|
+
*/
|
|
266
|
+
function median(values) {
|
|
267
|
+
if (values.length === 0) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
271
|
+
const middle = Math.floor(sorted.length / 2);
|
|
272
|
+
return sorted.length % 2 === 0
|
|
273
|
+
? Math.round((sorted[middle - 1] + sorted[middle]) / 2)
|
|
274
|
+
: sorted[middle];
|
|
275
|
+
}
|
|
276
|
+
const CLAIM_RETRY_MS = 50;
|
|
277
|
+
|
|
278
|
+
export class SharedPieceStore {
|
|
279
|
+
#chunkLength;
|
|
280
|
+
#lastChunkLength;
|
|
281
|
+
#lastChunkIndex;
|
|
282
|
+
#growthCeiling;
|
|
283
|
+
/** Piece index → SharedArrayBuffer of that piece */
|
|
284
|
+
#buffers = new Map();
|
|
285
|
+
/** @type {Map<number, Promise<void>>} */
|
|
286
|
+
#evicting = new Map();
|
|
287
|
+
/**
|
|
288
|
+
* Slots claimed but not yet filled.
|
|
289
|
+
*
|
|
290
|
+
* Handed out by {@link SharedPieceStore##claimSlot} as a release function the
|
|
291
|
+
* caller must call in a `finally`, never as a number one function increments
|
|
292
|
+
* and another decrements. The counter used to be paired across
|
|
293
|
+
* `#claimSlot`/`#registerPiece`, so any failure between the two lost a slot
|
|
294
|
+
* for the life of the process, and `put`'s error path tried to correct that
|
|
295
|
+
* by guessing — which could take back a reservation belonging to a different
|
|
296
|
+
* claim and let the store admit past its allowance.
|
|
297
|
+
*/
|
|
298
|
+
#outstandingPieces = 0;
|
|
299
|
+
/** When something last actually moved: a piece admitted, spilled or unpinned. */
|
|
300
|
+
#lastProgressAt = 0;
|
|
301
|
+
#waiters = [];
|
|
302
|
+
#lru;
|
|
303
|
+
#disk;
|
|
304
|
+
#closed = false;
|
|
305
|
+
#name;
|
|
306
|
+
#counters = {
|
|
307
|
+
fromMemory: 0,
|
|
308
|
+
fromDisk: 0,
|
|
309
|
+
spills: 0,
|
|
310
|
+
revivals: 0,
|
|
311
|
+
blockedByPins: 0,
|
|
312
|
+
waitedForPins: 0,
|
|
313
|
+
evictedOnRevise: 0,
|
|
314
|
+
spillFailures: 0,
|
|
315
|
+
// Whether the store is doing its job or being asked to hold more than it
|
|
316
|
+
// has room for. An eviction that had to take a piece a reader declared it
|
|
317
|
+
// wants is the second, and it comes back from disk moments later
|
|
318
|
+
// (roadmap item 9).
|
|
319
|
+
evictedProtected: 0,
|
|
320
|
+
evictedDistanceSum: 0,
|
|
321
|
+
evictedWithDistance: 0,
|
|
322
|
+
// Where an arriving piece went. A piece nobody has declared is being
|
|
323
|
+
// downloaded ahead of every reader; putting it in memory pushes out one
|
|
324
|
+
// that IS declared, which is then read back from disk moments later
|
|
325
|
+
// (roadmap item 9).
|
|
326
|
+
admittedInsideWindow: 0,
|
|
327
|
+
admittedOutsideWindow: 0,
|
|
328
|
+
admittedToDisk: 0,
|
|
329
|
+
/** Blocks given back to the operating system. */
|
|
330
|
+
blocksReleased: 0,
|
|
331
|
+
/**
|
|
332
|
+
* A block put back for re-use while a reader still held the piece. Zero by
|
|
333
|
+
* construction — a pinned piece is never evicted, and a re-put of a pinned
|
|
334
|
+
* one drops its block instead of recycling it. Counted because if it ever
|
|
335
|
+
* stops being zero, a consumer is reading another piece's bytes, and that
|
|
336
|
+
* is invisible from anywhere else.
|
|
337
|
+
*/
|
|
338
|
+
returnedWhilePinned: 0,
|
|
339
|
+
/** Spills that found the disk already holding identical bytes. */
|
|
340
|
+
spillsSkipped: 0,
|
|
341
|
+
/**
|
|
342
|
+
* Blocks a second registration of the same piece displaced. Expected to be
|
|
343
|
+
* small and non-zero: two callers racing for one piece is ordinary. What is
|
|
344
|
+
* NOT ordinary is the block going missing when it happens, which is what
|
|
345
|
+
* killed the process on 2026-09-02.
|
|
346
|
+
*/
|
|
347
|
+
blocksDisplaced: 0,
|
|
348
|
+
/** Admissions that waited for the disk instead of evicting another piece. */
|
|
349
|
+
waitedForDisk: 0,
|
|
350
|
+
/**
|
|
351
|
+
* Admissions that grew memory because the disk had stopped answering.
|
|
352
|
+
* Non-zero means the store exceeded its allowance on purpose, and by how
|
|
353
|
+
* many pieces.
|
|
354
|
+
*/
|
|
355
|
+
grewWaitingForDisk: 0,
|
|
356
|
+
/**
|
|
357
|
+
* Times a block was wanted, none was free, and the pool was already at its
|
|
358
|
+
* ceiling. Zero by construction — a block is only taken after a slot has
|
|
359
|
+
* been claimed, and slots are what the ceiling counts — so a number here
|
|
360
|
+
* means the two have come apart and the pool is growing past its allowance.
|
|
361
|
+
*/
|
|
362
|
+
blocksBeyondCeiling: 0
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* Blocks that hold no piece, most recently freed last.
|
|
366
|
+
*
|
|
367
|
+
* A block is one piece's worth of memory. Allocating a new one for every
|
|
368
|
+
* piece meant 7575 allocations of 4 MiB in 44 minutes on 2026-09-02, each
|
|
369
|
+
* released only when the collector got to it — which is why the process held
|
|
370
|
+
* 1.86 GB while its own accounting said 352 MB. Blocks are taken from here
|
|
371
|
+
* and put back here instead (roadmap item 2).
|
|
372
|
+
*
|
|
373
|
+
* @type {{ buffer: SharedArrayBuffer, freedAt: number }[]}
|
|
374
|
+
*/
|
|
375
|
+
#freeBlocks = [];
|
|
376
|
+
/** Blocks that exist at all: free plus holding a piece. */
|
|
377
|
+
#blocksAllocated = 0;
|
|
378
|
+
/** Whether a reader has ever declared a window here. See `wantedBytes`. */
|
|
379
|
+
#everHadReader = false;
|
|
380
|
+
/** Whether the last revision had to exceed the machine's share. */
|
|
381
|
+
#beyondTheMachine = false;
|
|
382
|
+
/**
|
|
383
|
+
* How long a block sat free before it was taken again, in milliseconds.
|
|
384
|
+
* Bounded, because what is wanted is the longest gap of RECENT work: an
|
|
385
|
+
* all-time maximum would be raised by one long pause and then never let a
|
|
386
|
+
* block go again.
|
|
387
|
+
*
|
|
388
|
+
* @type {number[]}
|
|
389
|
+
*/
|
|
390
|
+
#reuseGaps = [];
|
|
391
|
+
/**
|
|
392
|
+
* How long recent writes to disk took, in milliseconds, and when pieces were
|
|
393
|
+
* admitted. Together they say how much room the store needs beyond what the
|
|
394
|
+
* readers ask for: while one write is finishing, more pieces arrive, and each
|
|
395
|
+
* needs somewhere to go. Without that room every arrival evicts something,
|
|
396
|
+
* which is what produced 233 evictions against 119 completed writes in a
|
|
397
|
+
* minute on 2026-09-02.
|
|
398
|
+
*
|
|
399
|
+
* @type {number[]}
|
|
400
|
+
*/
|
|
401
|
+
#writeDurations = [];
|
|
402
|
+
/** When the last few pieces were admitted, for the arrival rate. */
|
|
403
|
+
#admittedAt = [];
|
|
404
|
+
/** Piece index → when it was written out, for the age it comes back at. */
|
|
405
|
+
#spilledAt = new Map();
|
|
406
|
+
/**
|
|
407
|
+
* Ages, in milliseconds, of the last revivals — bounded, because the figure
|
|
408
|
+
* wanted is a median and not a history. A piece that comes back seconds
|
|
409
|
+
* after it left should not have left.
|
|
410
|
+
*
|
|
411
|
+
* @type {number[]}
|
|
412
|
+
*/
|
|
413
|
+
#revivalAges = [];
|
|
414
|
+
|
|
415
|
+
constructor(chunkLength, options = {}) {
|
|
416
|
+
if (!Number.isInteger(chunkLength) || chunkLength < 1) {
|
|
417
|
+
throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
|
|
418
|
+
}
|
|
419
|
+
this.#chunkLength = chunkLength;
|
|
420
|
+
|
|
421
|
+
const totalLength = Number.isFinite(options.length) ? options.length : 0;
|
|
422
|
+
this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
|
|
423
|
+
const remainder = totalLength % chunkLength;
|
|
424
|
+
this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
|
|
425
|
+
|
|
426
|
+
const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
|
|
427
|
+
? options.memoryBytes
|
|
428
|
+
: defaultMemoryBytes();
|
|
429
|
+
// Only where this store STARTS. It is not kept, so it cannot come back as a
|
|
430
|
+
// cap on the revision the way `#capacity` did.
|
|
431
|
+
const openingCeiling = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
|
|
432
|
+
this.#growthCeiling = openingCeiling;
|
|
433
|
+
this.#lru = new PieceLru(openingCeiling);
|
|
434
|
+
this.#name = options.name ?? "pieces";
|
|
435
|
+
// `options.disk` exists so a test can hold a write open or make one fail on
|
|
436
|
+
// purpose. Four of the defects fixed here live in what happens when the
|
|
437
|
+
// disk tier does not answer immediately or at all, and none of them is
|
|
438
|
+
// reachable from outside without saying so.
|
|
439
|
+
this.#disk = options.disk ?? new PieceDiskStore({
|
|
440
|
+
directory: options.path ?? ".",
|
|
441
|
+
name: `${this.#name}.pieces`,
|
|
442
|
+
chunkLength,
|
|
443
|
+
// What it may hold is settled by the same revision that settles memory,
|
|
444
|
+
// within a minute of the store existing. Until then it is unbounded, which
|
|
445
|
+
// is what it has always been — the difference is that it now stops.
|
|
446
|
+
allowanceBytes: null,
|
|
447
|
+
// Where the live readers stand, so what goes first is decided by them and
|
|
448
|
+
// not by which piece happened to be touched longest ago.
|
|
449
|
+
readHeads: () => this.#lru.readHeads()
|
|
450
|
+
});
|
|
451
|
+
liveStores.add(this);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
stats() {
|
|
455
|
+
const resident = this.#buffers.size;
|
|
456
|
+
const residentBytes = resident * this.#chunkLength;
|
|
457
|
+
// Last piece may be short but stats historically use chunkLength.
|
|
458
|
+
return {
|
|
459
|
+
name: this.#name,
|
|
460
|
+
resident,
|
|
461
|
+
capacity: this.#growthCeiling,
|
|
462
|
+
residentBytes,
|
|
463
|
+
allocatedSlots: this.#blocksAllocated,
|
|
464
|
+
// What the process HOLDS, which with a pool is the blocks that exist —
|
|
465
|
+
// not the pieces in them. Holding and using are different quantities and
|
|
466
|
+
// the difference is the point of the reading.
|
|
467
|
+
committedBytes: this.#blocksAllocated * this.#chunkLength,
|
|
468
|
+
budgetBytes: this.#growthCeiling * this.#chunkLength,
|
|
469
|
+
pinned: this.#lru.pinnedCount,
|
|
470
|
+
// Slots claimed and not yet filled. Reported because a reservation that
|
|
471
|
+
// is never returned is invisible until the store cannot admit anything,
|
|
472
|
+
// and by then the reason is long gone. At rest this is zero.
|
|
473
|
+
outstanding: this.#outstandingPieces,
|
|
474
|
+
spilled: this.#disk.size,
|
|
475
|
+
// What the spill file ACTUALLY weighs, piece by piece, rather than the
|
|
476
|
+
// piece count times a full piece length. The last piece of a torrent is
|
|
477
|
+
// short, and until 2026-09-10 nothing here counted disk at all — the
|
|
478
|
+
// figure was a multiplication, and the thing it stood for had no ceiling.
|
|
479
|
+
spilledBytes: this.#disk.bytes,
|
|
480
|
+
spillAllowanceBytes: this.#disk.allowanceBytes,
|
|
481
|
+
spillEvictions: this.#disk.stats().evictions,
|
|
482
|
+
// What the readers between them are asking this store to keep, against
|
|
483
|
+
// what it may hold. A union wider than the capacity cannot be held
|
|
484
|
+
// however the eviction is ordered, and that is the difference between a
|
|
485
|
+
// policy to fix and arithmetic to accept (roadmap item 9).
|
|
486
|
+
demand: this.#lru.demand(),
|
|
487
|
+
// What the process actually holds for this store, which is not the same
|
|
488
|
+
// as what it is using: blocks are kept for re-use. The difference is the
|
|
489
|
+
// spare, and it is the whole of the question whether consumption only
|
|
490
|
+
// grows (roadmap item 2).
|
|
491
|
+
blocksAllocated: this.#blocksAllocated,
|
|
492
|
+
blocksFree: this.#freeBlocks.length,
|
|
493
|
+
blocksDisplaced: this.#counters.blocksDisplaced,
|
|
494
|
+
blocksInUse: this.#blocksInUse(),
|
|
495
|
+
blocksInFlight: this.#blocksInFlight(),
|
|
496
|
+
blocksBeyondCeiling: this.#counters.blocksBeyondCeiling,
|
|
497
|
+
blockBytes: this.#blocksAllocated * this.#chunkLength,
|
|
498
|
+
reuseGapMs: this.#reuseGapCeilingMs(),
|
|
499
|
+
revivalAgeMedianMs: median(this.#revivalAges),
|
|
500
|
+
revivalAgeSamples: this.#revivalAges.length,
|
|
501
|
+
revivedWithinFiveSeconds: this.#revivalAges.filter((age) => age <= 5_000).length,
|
|
502
|
+
...this.#counters
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Whether the machine's share of memory is smaller than one reader's window.
|
|
508
|
+
*
|
|
509
|
+
* The store holds the window anyway — refusing would leave the read it is
|
|
510
|
+
* serving unable to finish, which is worse — but it is the honest measure of
|
|
511
|
+
* "this machine cannot take any more", and it is measured rather than
|
|
512
|
+
* guessed: it is the last revision's own comparison of what the machine
|
|
513
|
+
* allowed against what the widest reader declared.
|
|
514
|
+
*/
|
|
515
|
+
get isBeyondTheMachine() {
|
|
516
|
+
return this.#beyondTheMachine;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** What this store holds right now, in bytes. */
|
|
520
|
+
get residentBytes() {
|
|
521
|
+
return this.#buffers.size * this.#chunkLength;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** What this store's spilled pieces weigh on disk. */
|
|
525
|
+
get spilledBytes() {
|
|
526
|
+
return this.#disk.bytes;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Say how much disk this store's spilled pieces may take.
|
|
531
|
+
*
|
|
532
|
+
* The counterpart of `reviseGrowthCeiling`, and settled by the same pass: the
|
|
533
|
+
* two tiers of one store are two readings of one machine.
|
|
534
|
+
*
|
|
535
|
+
* @param {number | null} allowedBytes
|
|
536
|
+
* @returns {{ name: string, allowanceBytes: number | null, bytes: number }}
|
|
537
|
+
*/
|
|
538
|
+
reviseSpillCeiling(allowedBytes) {
|
|
539
|
+
// THE OTHER RULE, and it does not wait for the disk to be short. A piece
|
|
540
|
+
// behind every read head has been read and will not be read again unless
|
|
541
|
+
// somebody seeks back, and a seek back re-downloads it — the same bargain
|
|
542
|
+
// this tier makes whenever it drops a piece for room. Without it the spill
|
|
543
|
+
// is bounded only by a share of free space, which on a roomy host is tens of
|
|
544
|
+
// gigabytes against a measured growth of 14 400 MB in one viewing: the
|
|
545
|
+
// ceiling never binds and nothing is ever removed until the torrent goes.
|
|
546
|
+
const behind = this.#disk.forgetBehind(this.#lru.readHeads());
|
|
547
|
+
return {
|
|
548
|
+
name: this.#name,
|
|
549
|
+
allowanceBytes: this.#disk.reviseAllowance(allowedBytes),
|
|
550
|
+
bytes: this.#disk.bytes,
|
|
551
|
+
behind
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* What this store is asking to be allowed to hold, in bytes.
|
|
557
|
+
*
|
|
558
|
+
* The union of its readers' declared windows — what they have said they will
|
|
559
|
+
* need — never below the widest single window, because a store that cannot
|
|
560
|
+
* hold one reader's window cannot complete that reader's read at all: every
|
|
561
|
+
* resident piece ends up pinned, the read returns zero bytes and ffmpeg takes
|
|
562
|
+
* that for the end of the file (field 2026-08-15, roadmap item 9).
|
|
563
|
+
*
|
|
564
|
+
* With no reader declaring anything there is no demand to speak of, so the
|
|
565
|
+
* store asks for what the machine allows and the first revision after a read
|
|
566
|
+
* begins brings it down to what that read needs.
|
|
567
|
+
*/
|
|
568
|
+
get wantedBytes() {
|
|
569
|
+
const demand = this.#lru.demand();
|
|
570
|
+
if (demand.readers > 0) {
|
|
571
|
+
this.#everHadReader = true;
|
|
572
|
+
const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces);
|
|
573
|
+
// Plus room to absorb what arrives while a write is finishing. Asking for
|
|
574
|
+
// exactly what the readers want leaves no free place ever, so every
|
|
575
|
+
// arrival evicts one of them — measured 2026-09-02: `6 reader(s) want 23
|
|
576
|
+
// piece(s) of 23 the store may hold`, and 233 evictions in the minute
|
|
577
|
+
// that followed.
|
|
578
|
+
return (pieces + this.slackPieces()) * this.#chunkLength;
|
|
579
|
+
}
|
|
580
|
+
// Readers that have GONE are not the same as readers that have not arrived.
|
|
581
|
+
// A store whose readers ended has nothing to hold pieces for — its torrent
|
|
582
|
+
// sits until the pool's idle timer removes it, which needs a refcount of
|
|
583
|
+
// zero and can be a quarter of an hour away — so it asks for nothing and
|
|
584
|
+
// its memory goes back to the machine now. A store that has never had a
|
|
585
|
+
// reader is being filled for one that is on its way, and asks for what it
|
|
586
|
+
// was opened with until the first read says what it needs.
|
|
587
|
+
return this.#everHadReader
|
|
588
|
+
? (MIN_RESIDENT_PIECES + this.slackPieces()) * this.#chunkLength
|
|
589
|
+
: this.#growthCeiling * this.#chunkLength;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
reviseGrowthCeiling(allowedBytes) {
|
|
593
|
+
// No cap at what the machine could spare when this store was CREATED.
|
|
594
|
+
// `#capacity` was computed once in the constructor and used as an upper
|
|
595
|
+
// bound here, so the allowance could only ever fall: a torrent opened while
|
|
596
|
+
// the machine was full kept a small allowance for its whole life, however
|
|
597
|
+
// much memory was freed afterwards (roadmap item 2, 2026-09-02).
|
|
598
|
+
const wanted = Math.floor(Number(allowedBytes) / this.#chunkLength);
|
|
599
|
+
// Never below what the live readers together hold, even when the machine's
|
|
600
|
+
// share says less. A store that cannot hold what its readers are pinning
|
|
601
|
+
// cannot complete any of their reads at all: every resident piece ends up
|
|
602
|
+
// pinned, a read returns zero bytes and ffmpeg takes that for the end of
|
|
603
|
+
// the file, which killed every encoder on that file in the field on
|
|
604
|
+
// 2026-08-15 (one reader) and again on 2026-09-07 (three readers of one
|
|
605
|
+
// file at once — picture, sound and the edge-warming read — where this
|
|
606
|
+
// floor was still computed from only the widest one of them, `wantedBytes`
|
|
607
|
+
// above already asks for the union and got it right; this is the same
|
|
608
|
+
// union, used as the floor instead of only as the ask). Exceeding the share
|
|
609
|
+
// is the lesser failure, and the line below says when it happens.
|
|
610
|
+
const demand = this.#lru.demand();
|
|
611
|
+
this.#growthCeiling = Math.max(
|
|
612
|
+
MIN_RESIDENT_PIECES,
|
|
613
|
+
demand.readers > 0 ? demand.unionPieces : MIN_RESIDENT_PIECES,
|
|
614
|
+
Number.isFinite(wanted) ? wanted : MIN_RESIDENT_PIECES
|
|
615
|
+
);
|
|
616
|
+
const belowAWindow = demand.readers > 0
|
|
617
|
+
&& Number.isFinite(wanted)
|
|
618
|
+
&& wanted < demand.unionPieces;
|
|
619
|
+
this.#beyondTheMachine = belowAWindow;
|
|
620
|
+
// The LRU is told too. It was constructed with the store's original
|
|
621
|
+
// capacity and never revised, so `isFull()` answered against a number that
|
|
622
|
+
// had not been the limit for some time — dormant only because nothing calls
|
|
623
|
+
// it, which is a trap for whoever calls it next.
|
|
624
|
+
this.#lru.setCapacity(this.#growthCeiling);
|
|
625
|
+
// With per-piece buffers memory CAN be given back immediately, unlike the
|
|
626
|
+
// old growable pool. Eagerly evict excess to honour the new ceiling.
|
|
627
|
+
let evicted = 0;
|
|
628
|
+
while (this.#buffers.size > this.#growthCeiling) {
|
|
629
|
+
const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
|
|
630
|
+
if (victim === null) {
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
const victimBuffer = this.#buffers.get(victim);
|
|
634
|
+
if (victimBuffer === undefined) {
|
|
635
|
+
this.#lru.remove(victim);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
this.#buffers.delete(victim);
|
|
639
|
+
this.#lru.remove(victim);
|
|
640
|
+
evicted += 1;
|
|
641
|
+
this.#counters.evictedOnRevise += 1;
|
|
642
|
+
this.#noteEviction(protectionYielded, distance);
|
|
643
|
+
// Nobody awaits this spill, so its failure has to end here. Rethrowing
|
|
644
|
+
// made it an unhandled rejection, and an unhandled rejection in the
|
|
645
|
+
// torrent worker ends the thread — a second way to lose the torrent
|
|
646
|
+
// client, on top of the one that already loses it.
|
|
647
|
+
void this.#spill(victim, victimBuffer).catch(() => {
|
|
648
|
+
this.#counters.spillFailures += 1;
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
// Blocks the store is no longer using and has waited long enough to give
|
|
652
|
+
// up. The allowance falling is the moment to ask, because that is when the
|
|
653
|
+
// machine has been shown to need the memory.
|
|
654
|
+
const releasedBlocks = this.sweepFreeBlocks();
|
|
655
|
+
return {
|
|
656
|
+
name: this.#name,
|
|
657
|
+
ceilingBytes: this.#growthCeiling * this.#chunkLength,
|
|
658
|
+
committedBytes: this.#blocksAllocated * this.#chunkLength,
|
|
659
|
+
evicted,
|
|
660
|
+
releasedBlocks,
|
|
661
|
+
belowAWindow
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
get chunkLength() {
|
|
666
|
+
return this.#chunkLength;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
get capacity() {
|
|
670
|
+
return this.#growthCeiling;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
get residentCount() {
|
|
674
|
+
return this.#buffers.size;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
get spilledCount() {
|
|
678
|
+
return this.#disk.size;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
#lengthOf(index) {
|
|
682
|
+
return index === this.#lastChunkIndex ? this.#lastChunkLength : this.#chunkLength;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Where a resident piece sits, or `null` if not resident.
|
|
687
|
+
* Returns the piece's own SharedArrayBuffer and the intra-piece range.
|
|
688
|
+
* @param {number} index
|
|
689
|
+
* @returns {{ buffer: SharedArrayBuffer, offset: number, length: number } | null}
|
|
690
|
+
*/
|
|
691
|
+
locate(index) {
|
|
692
|
+
const buffer = this.#buffers.get(index);
|
|
693
|
+
if (buffer === undefined) {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
return { buffer, offset: 0, length: this.#lengthOf(index) };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Direct access to a piece's buffer for zero-copy consumers.
|
|
701
|
+
* @param {number} index
|
|
702
|
+
* @returns {SharedArrayBuffer | undefined}
|
|
703
|
+
*/
|
|
704
|
+
getPieceBuffer(index) {
|
|
705
|
+
return this.#buffers.get(index);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
pin(index) {
|
|
709
|
+
this.#lru.pin(index);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
unpin(index) {
|
|
713
|
+
this.#lru.unpin(index);
|
|
714
|
+
this.#noteProgress();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Reserve room for one piece.
|
|
719
|
+
*
|
|
720
|
+
* @returns {Promise<() => void>} The release, which the caller MUST call in a
|
|
721
|
+
* `finally`. Calling it twice is harmless.
|
|
722
|
+
*/
|
|
723
|
+
async #claimSlot() {
|
|
724
|
+
// How long THIS claim has been trying, kept here and not on the store.
|
|
725
|
+
// It was a field, `#pinnedWaitStartedAt`, shared by the two waits inside
|
|
726
|
+
// `#claimSlotOnce` — the one for the disk and the one for a piece that may
|
|
727
|
+
// be evicted — and each of them zeroed it on giving up, which restarted the
|
|
728
|
+
// other's clock. Measured 2026-09-03: the claim cycled for ever, five
|
|
729
|
+
// seconds per side, `grewWaitingForDisk` and `waitedForPins` both climbing
|
|
730
|
+
// once every five seconds while `blockedByPins` stayed at 0 — the refusal
|
|
731
|
+
// was unreachable. A claim's patience belongs to the claim; with several
|
|
732
|
+
// claimants a shared field is wrong anyway, since one caller giving up
|
|
733
|
+
// would reset the wait of every other.
|
|
734
|
+
const waitingSince = Date.now();
|
|
735
|
+
for (;;) {
|
|
736
|
+
if (this.#closed) {
|
|
737
|
+
throw new Error("Piece store is closed.");
|
|
738
|
+
}
|
|
739
|
+
const ok = await this.#claimSlotOnce(waitingSince);
|
|
740
|
+
if (ok) {
|
|
741
|
+
let released = false;
|
|
742
|
+
return () => {
|
|
743
|
+
if (released) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
released = true;
|
|
747
|
+
this.#outstandingPieces -= 1;
|
|
748
|
+
this.#noteProgress();
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
await this.#waitForSlot();
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Sleep until something moves, or until the retry interval, whichever first.
|
|
757
|
+
*
|
|
758
|
+
* One handler, idempotent, and its timer is cleared when it is woken. The
|
|
759
|
+
* earlier version attached a fresh pair of handlers to EVERY pending spill on
|
|
760
|
+
* every attempt and left a timer running each time, so a claim that could not
|
|
761
|
+
* be satisfied allocated in proportion to attempts times pending spills. A
|
|
762
|
+
* settling spill now wakes the store itself, which is where that belongs.
|
|
763
|
+
*
|
|
764
|
+
* @returns {Promise<void>}
|
|
765
|
+
*/
|
|
766
|
+
#waitForSlot() {
|
|
767
|
+
return new Promise((resolve) => {
|
|
768
|
+
let settled = false;
|
|
769
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
770
|
+
let retry = null;
|
|
771
|
+
const finish = () => {
|
|
772
|
+
if (settled) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
settled = true;
|
|
776
|
+
if (retry !== null) {
|
|
777
|
+
clearTimeout(retry);
|
|
778
|
+
}
|
|
779
|
+
resolve();
|
|
780
|
+
};
|
|
781
|
+
this.#waiters.push(finish);
|
|
782
|
+
retry = setTimeout(finish, CLAIM_RETRY_MS);
|
|
783
|
+
retry.unref?.();
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
#registerPiece(index, buffer) {
|
|
788
|
+
// A piece may already be here. Both paths that register one look first and
|
|
789
|
+
// then await — `put` waits for a slot, `#revive` waits for the disk — and
|
|
790
|
+
// in that gap another caller can register the same index. Overwriting the
|
|
791
|
+
// entry used to drop the previous block on the floor: its memory was not
|
|
792
|
+
// returned to the pool and the pool's own count was never decremented, so
|
|
793
|
+
// the count climbed past the ceiling for ever and the pool degenerated into
|
|
794
|
+
// allocating a fresh block per piece. Field 2026-09-02: a store holding
|
|
795
|
+
// THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
|
|
796
|
+
// allocated and 676 still alive, and the process was killed at 4.37 GB.
|
|
797
|
+
const displaced = this.#buffers.get(index);
|
|
798
|
+
this.#buffers.set(index, buffer);
|
|
799
|
+
if (displaced !== undefined && displaced !== buffer) {
|
|
800
|
+
this.#counters.blocksDisplaced += 1;
|
|
801
|
+
this.#returnBlock(displaced);
|
|
802
|
+
}
|
|
803
|
+
this.#lru.touch(index);
|
|
804
|
+
this.#noteProgress();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Write a piece out and account for it, from the one place that does it.
|
|
809
|
+
*
|
|
810
|
+
* @param {number} index
|
|
811
|
+
* @param {SharedArrayBuffer} buffer
|
|
812
|
+
* @returns {Promise<void>}
|
|
813
|
+
*/
|
|
814
|
+
#spill(index, buffer) {
|
|
815
|
+
// The disk may already hold these very bytes. `#revive` reads a piece back
|
|
816
|
+
// into memory and leaves the copy on disk, and only `put` removes it — so a
|
|
817
|
+
// piece that was revived and not re-put is identical to what is already
|
|
818
|
+
// written, and writing it again is work for nothing. There were 7575
|
|
819
|
+
// revivals in one session on 2026-09-02, and every later eviction of one of
|
|
820
|
+
// them wrote a second time (roadmap item 66: 14.4 GB written in a single
|
|
821
|
+
// viewing).
|
|
822
|
+
if (this.#disk.has(index)) {
|
|
823
|
+
this.#counters.spills += 1;
|
|
824
|
+
this.#counters.spillsSkipped += 1;
|
|
825
|
+
this.#spilledAt.set(index, Date.now());
|
|
826
|
+
this.#returnBlock(buffer);
|
|
827
|
+
this.#noteProgress();
|
|
828
|
+
return Promise.resolve();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
|
|
832
|
+
const startedAt = Date.now();
|
|
833
|
+
const spill = this.#disk.write(index, bytes).then(
|
|
834
|
+
() => {
|
|
835
|
+
this.#noteWriteDuration(Date.now() - startedAt);
|
|
836
|
+
this.#counters.spills += 1;
|
|
837
|
+
this.#spilledAt.set(index, Date.now());
|
|
838
|
+
this.#evicting.delete(index);
|
|
839
|
+
// Only now. The write reads out of this block, so a block handed to
|
|
840
|
+
// another piece before the write finished would put that piece's bytes
|
|
841
|
+
// into this piece's place in the file.
|
|
842
|
+
this.#returnBlock(buffer);
|
|
843
|
+
this.#noteProgress();
|
|
844
|
+
},
|
|
845
|
+
(error) => {
|
|
846
|
+
this.#evicting.delete(index);
|
|
847
|
+
// The block is no longer holding anything either way; keeping it out of
|
|
848
|
+
// the pool because the write failed would lose it for good.
|
|
849
|
+
this.#returnBlock(buffer);
|
|
850
|
+
this.#noteProgress();
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
853
|
+
);
|
|
854
|
+
this.#evicting.set(index, spill);
|
|
855
|
+
return spill;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Something actually moved: wake whoever is waiting and restart the clock. */
|
|
859
|
+
#noteProgress() {
|
|
860
|
+
this.#lastProgressAt = Date.now();
|
|
861
|
+
this.#wake();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Record how long one write to disk took.
|
|
866
|
+
*
|
|
867
|
+
* @param {number} durationMs
|
|
868
|
+
* @returns {void}
|
|
869
|
+
*/
|
|
870
|
+
#noteWriteDuration(durationMs) {
|
|
871
|
+
this.#writeDurations.push(Math.max(0, durationMs));
|
|
872
|
+
if (this.#writeDurations.length > WRITE_DURATION_SAMPLES) {
|
|
873
|
+
this.#writeDurations.shift();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Record that a piece arrived, for the arrival rate. */
|
|
878
|
+
#noteArrival() {
|
|
879
|
+
const now = Date.now();
|
|
880
|
+
this.#admittedAt.push(now);
|
|
881
|
+
while (this.#admittedAt.length > ARRIVAL_SAMPLES
|
|
882
|
+
|| (this.#admittedAt.length > 0 && now - this.#admittedAt[0] > ARRIVAL_WINDOW_MS)) {
|
|
883
|
+
this.#admittedAt.shift();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* How many pieces the store needs room for beyond what the readers ask for.
|
|
889
|
+
*
|
|
890
|
+
* Measured, not chosen: the pieces that arrive while one write to disk is
|
|
891
|
+
* finishing. Without this room every arrival must evict something, and each
|
|
892
|
+
* eviction holds its block until its write completes — so a disk slower than
|
|
893
|
+
* the swarm turns every admission into one more block held. On 2026-09-02
|
|
894
|
+
* that was 233 evictions against 119 completed writes in a minute, and 203
|
|
895
|
+
* blocks held with three pieces resident.
|
|
896
|
+
*
|
|
897
|
+
* Zero until both quantities have been seen, because a slack invented before
|
|
898
|
+
* anything is measured is a chosen number, and this store has been bitten by
|
|
899
|
+
* those.
|
|
900
|
+
*
|
|
901
|
+
* @returns {number} Pieces.
|
|
902
|
+
*/
|
|
903
|
+
slackPieces() {
|
|
904
|
+
if (this.#writeDurations.length === 0 || this.#admittedAt.length < 2) {
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
907
|
+
const sorted = [...this.#writeDurations].sort((left, right) => left - right);
|
|
908
|
+
const writeMs = sorted[Math.floor(sorted.length / 2)];
|
|
909
|
+
const spanMs = this.#admittedAt[this.#admittedAt.length - 1] - this.#admittedAt[0];
|
|
910
|
+
if (!(spanMs > 0)) {
|
|
911
|
+
return 0;
|
|
912
|
+
}
|
|
913
|
+
const perMs = (this.#admittedAt.length - 1) / spanMs;
|
|
914
|
+
return Math.ceil(perMs * writeMs);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Blocks that are not free: resident pieces, blocks being written out, and
|
|
919
|
+
* blocks taken but not yet registered.
|
|
920
|
+
*
|
|
921
|
+
* This is what the ceiling has to bound, and until 2026-09-02 it bounded
|
|
922
|
+
* resident pieces instead. The difference is exactly the memory that goes
|
|
923
|
+
* missing from the count: a piece being spilled leaves `#buffers` the moment
|
|
924
|
+
* the eviction begins, while its block stays held until the write it feeds
|
|
925
|
+
* has finished.
|
|
926
|
+
*
|
|
927
|
+
* @returns {number}
|
|
928
|
+
*/
|
|
929
|
+
#blocksInUse() {
|
|
930
|
+
return this.#blocksAllocated - this.#freeBlocks.length;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Blocks held by writes that have not finished.
|
|
935
|
+
*
|
|
936
|
+
* @returns {number}
|
|
937
|
+
*/
|
|
938
|
+
#blocksInFlight() {
|
|
939
|
+
return Math.max(0, this.#blocksInUse() - this.#buffers.size);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Whether admitting one more piece would need something evicted first.
|
|
944
|
+
*
|
|
945
|
+
* Reservations count: a slot claimed and not yet filled is as taken as a
|
|
946
|
+
* resident piece.
|
|
947
|
+
*
|
|
948
|
+
* @returns {boolean}
|
|
949
|
+
*/
|
|
950
|
+
#isFullNow() {
|
|
951
|
+
return this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Write an arriving piece straight to disk, without it ever occupying memory.
|
|
956
|
+
*
|
|
957
|
+
* Registered in `#evicting` like a spill, so a `get` for this index waits for
|
|
958
|
+
* the write instead of finding the piece on neither tier. Deliberately NOT
|
|
959
|
+
* recorded in `#spilledAt`: that clock measures how long an EVICTED piece
|
|
960
|
+
* stayed away, and a piece that was never resident has no such age.
|
|
961
|
+
*
|
|
962
|
+
* @param {number} index
|
|
963
|
+
* @param {Uint8Array} bytes
|
|
964
|
+
* @returns {Promise<void>}
|
|
965
|
+
*/
|
|
966
|
+
#writeThrough(index, bytes) {
|
|
967
|
+
// Copied, not viewed. `PieceDiskStore.write` opens the file before it reads the
|
|
968
|
+
// bytes, so a view onto the caller's buffer could be written to in between
|
|
969
|
+
// and the file would get the wrong data. The spill path may pass a view
|
|
970
|
+
// because that memory is ours; this buffer belongs to the torrent client.
|
|
971
|
+
// It costs nothing extra: the piece was being copied into a shared buffer
|
|
972
|
+
// on this path before, and now it is copied here instead.
|
|
973
|
+
const copy = Buffer.from(bytes.subarray(0, Math.min(bytes.length, this.#lengthOf(index))));
|
|
974
|
+
const write = this.#disk.write(index, copy).then(
|
|
975
|
+
() => {
|
|
976
|
+
this.#evicting.delete(index);
|
|
977
|
+
this.#noteProgress();
|
|
978
|
+
},
|
|
979
|
+
(error) => {
|
|
980
|
+
this.#evicting.delete(index);
|
|
981
|
+
this.#noteProgress();
|
|
982
|
+
throw error;
|
|
983
|
+
}
|
|
984
|
+
);
|
|
985
|
+
this.#evicting.set(index, write);
|
|
986
|
+
return write;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Record how long a piece stayed on disk before it was wanted again.
|
|
991
|
+
*
|
|
992
|
+
* A piece that comes back seconds after it left was evicted from a working
|
|
993
|
+
* set that does not fit, and the write and the read were both waste. Kept as
|
|
994
|
+
* a bounded window of ages because the figure wanted is a median, not a
|
|
995
|
+
* history.
|
|
996
|
+
*
|
|
997
|
+
* @param {number} index
|
|
998
|
+
* @returns {void}
|
|
999
|
+
*/
|
|
1000
|
+
#noteRevival(index) {
|
|
1001
|
+
const spilledAt = this.#spilledAt.get(index);
|
|
1002
|
+
if (spilledAt === undefined) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
this.#spilledAt.delete(index);
|
|
1006
|
+
this.#revivalAges.push(Date.now() - spilledAt);
|
|
1007
|
+
if (this.#revivalAges.length > REVIVAL_AGE_SAMPLES) {
|
|
1008
|
+
this.#revivalAges.shift();
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Record what an eviction had to take.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {boolean} protectionYielded - The victim was inside a window a
|
|
1016
|
+
* reader had declared, and was taken anyway because nothing else was free.
|
|
1017
|
+
* @param {number} distance - Pieces from the nearest declared window, -1 when
|
|
1018
|
+
* no reader declared one.
|
|
1019
|
+
* @returns {void}
|
|
1020
|
+
*/
|
|
1021
|
+
#noteEviction(protectionYielded, distance) {
|
|
1022
|
+
if (protectionYielded) {
|
|
1023
|
+
this.#counters.evictedProtected += 1;
|
|
1024
|
+
}
|
|
1025
|
+
if (distance >= 0) {
|
|
1026
|
+
this.#counters.evictedDistanceSum += distance;
|
|
1027
|
+
this.#counters.evictedWithDistance += 1;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
#wake() {
|
|
1032
|
+
const waiting = this.#waiters;
|
|
1033
|
+
this.#waiters = [];
|
|
1034
|
+
for (const resolve of waiting) {
|
|
1035
|
+
resolve();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* One attempt at a reservation.
|
|
1041
|
+
*
|
|
1042
|
+
* @param {number} waitingSince - When the claim this attempt belongs to began
|
|
1043
|
+
* trying. Both waits below are measured from the later of it and the last
|
|
1044
|
+
* time the store moved, so neither can restart the other's clock — see
|
|
1045
|
+
* `#claimSlot`.
|
|
1046
|
+
* @returns {Promise<boolean>} Whether a slot was reserved.
|
|
1047
|
+
*/
|
|
1048
|
+
async #claimSlotOnce(waitingSince) {
|
|
1049
|
+
// Reserve before suspension so concurrent callers see the reservation.
|
|
1050
|
+
if (this.#blocksInUse() + this.#outstandingPieces < this.#growthCeiling) {
|
|
1051
|
+
this.#outstandingPieces += 1;
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
const stillFor = () => Date.now() - Math.max(waitingSince, this.#lastProgressAt);
|
|
1055
|
+
|
|
1056
|
+
// Full, and evicting would make it worse rather than better. A piece being
|
|
1057
|
+
// written out has already left `#buffers` while its block is still held —
|
|
1058
|
+
// the write reads from that block — so evicting another one converts a
|
|
1059
|
+
// resident block into an in-flight block and takes a fresh block for the
|
|
1060
|
+
// arrival: the memory in use goes UP by one per admission for as long as
|
|
1061
|
+
// the disk is behind. Field 2026-09-02: 233 evictions against 119 completed
|
|
1062
|
+
// writes in a minute, 203 blocks held with three pieces resident, and the
|
|
1063
|
+
// process killed at 4.37 GB.
|
|
1064
|
+
//
|
|
1065
|
+
// So when the disk is what the store is waiting for, it waits. A completing
|
|
1066
|
+
// write calls `#noteProgress`, which wakes whoever is here.
|
|
1067
|
+
if (this.#blocksInFlight() > 0 && this.#blocksInUse() >= this.#growthCeiling) {
|
|
1068
|
+
if (stillFor() < PINNED_WAIT_MS) {
|
|
1069
|
+
this.#counters.waitedForDisk += 1;
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
// The disk has stopped answering. Falling through to eviction is the
|
|
1073
|
+
// lesser failure: it grows memory, and the line above says by how much,
|
|
1074
|
+
// where refusing would fail the read outright. The clock is NOT restarted
|
|
1075
|
+
// here: the wait that follows asks the same question of the same claim,
|
|
1076
|
+
// and zeroing it was half of the loop described in `#claimSlot`.
|
|
1077
|
+
this.#counters.grewWaitingForDisk += 1;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
|
|
1081
|
+
if (victim === null) {
|
|
1082
|
+
// Nothing may leave. Wait while the store is still MOVING — a spill
|
|
1083
|
+
// completing, a piece admitted, a pin released — and give up when it has
|
|
1084
|
+
// not moved for PINNED_WAIT_MS, whatever is nominally in flight. The old
|
|
1085
|
+
// rule asked whether anything was in flight rather than whether anything
|
|
1086
|
+
// had happened, which is why one lost reservation could hold a read here
|
|
1087
|
+
// for ever.
|
|
1088
|
+
if (stillFor() < PINNED_WAIT_MS) {
|
|
1089
|
+
this.#counters.waitedForPins += 1;
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
this.#counters.blockedByPins += 1;
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`Every resident piece is pinned and nothing moved for ${PINNED_WAIT_MS}ms; no slot can be freed.`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const victimBuffer = this.#buffers.get(victim);
|
|
1099
|
+
if (victimBuffer === undefined) {
|
|
1100
|
+
// Should not happen: LRU says resident but buffer missing.
|
|
1101
|
+
this.#lru.remove(victim);
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Claim atomically before await.
|
|
1106
|
+
this.#buffers.delete(victim);
|
|
1107
|
+
this.#lru.remove(victim);
|
|
1108
|
+
this.#outstandingPieces += 1;
|
|
1109
|
+
this.#noteEviction(protectionYielded, distance);
|
|
1110
|
+
|
|
1111
|
+
try {
|
|
1112
|
+
await this.#spill(victim, victimBuffer);
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
// The caller never received a release for this reservation, so it is
|
|
1115
|
+
// given back here rather than left outstanding for ever.
|
|
1116
|
+
this.#outstandingPieces -= 1;
|
|
1117
|
+
this.#noteProgress();
|
|
1118
|
+
throw error;
|
|
1119
|
+
}
|
|
1120
|
+
// Outstanding stays +1 for the caller; the slot for the new piece is now free.
|
|
1121
|
+
return true;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Bring a spilled piece back into memory, once, however many callers ask.
|
|
1126
|
+
*
|
|
1127
|
+
* @param {number} index
|
|
1128
|
+
* @returns {Promise<SharedArrayBuffer | null>} `null` when the piece is on
|
|
1129
|
+
* neither tier.
|
|
1130
|
+
*/
|
|
1131
|
+
async #revive(index) {
|
|
1132
|
+
const spill = this.#evicting.get(index);
|
|
1133
|
+
if (spill) {
|
|
1134
|
+
await spill.catch(() => undefined);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (!this.#disk.has(index)) {
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const release = await this.#claimSlot();
|
|
1142
|
+
try {
|
|
1143
|
+
// Another caller may have brought it back while this one waited for a
|
|
1144
|
+
// slot. Registering a second buffer for the same piece would leave
|
|
1145
|
+
// whoever holds the first reading memory nothing evicts.
|
|
1146
|
+
const already = this.#buffers.get(index);
|
|
1147
|
+
if (already !== undefined) {
|
|
1148
|
+
this.#lru.touch(index);
|
|
1149
|
+
this.#counters.fromMemory += 1;
|
|
1150
|
+
return already;
|
|
1151
|
+
}
|
|
1152
|
+
const target = this.#takeBlock();
|
|
1153
|
+
try {
|
|
1154
|
+
// Only this piece's own length: the block is a full piece long and the
|
|
1155
|
+
// last piece of a file is shorter, so reading the whole block would ask
|
|
1156
|
+
// the file for bytes past its end.
|
|
1157
|
+
await this.#disk.read(index, Buffer.from(target, 0, this.#lengthOf(index)));
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
this.#returnBlock(target);
|
|
1160
|
+
throw error;
|
|
1161
|
+
}
|
|
1162
|
+
this.#registerPiece(index, target);
|
|
1163
|
+
this.#counters.fromDisk += 1;
|
|
1164
|
+
this.#counters.revivals += 1;
|
|
1165
|
+
this.#noteRevival(index);
|
|
1166
|
+
return target;
|
|
1167
|
+
} finally {
|
|
1168
|
+
release();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* A block to hold one piece: the most recently freed one, or a new one.
|
|
1174
|
+
*
|
|
1175
|
+
* Every block is a full piece long, whatever piece will live in it. The last
|
|
1176
|
+
* piece of a file is shorter, and it occupies a full block with only its own
|
|
1177
|
+
* bytes meaningful — `#lengthOf` is what decides how much is ever read out.
|
|
1178
|
+
* Uniform blocks are what makes them interchangeable at all.
|
|
1179
|
+
*
|
|
1180
|
+
* Most recently freed first, deliberately: a few blocks then carry the whole
|
|
1181
|
+
* of a busy store's traffic and the rest age out of use, which is what makes
|
|
1182
|
+
* {@link SharedPieceStore#sweepFreeBlocks} able to tell a spare block from a
|
|
1183
|
+
* working one.
|
|
1184
|
+
*
|
|
1185
|
+
* @returns {SharedArrayBuffer}
|
|
1186
|
+
*/
|
|
1187
|
+
#takeBlock() {
|
|
1188
|
+
const spare = this.#freeBlocks.pop();
|
|
1189
|
+
if (spare !== undefined) {
|
|
1190
|
+
this.#noteReuseGap(Date.now() - spare.freedAt);
|
|
1191
|
+
return spare.buffer;
|
|
1192
|
+
}
|
|
1193
|
+
// Nothing spare and the pool is already as large as it is allowed to be.
|
|
1194
|
+
// This cannot happen while the accounting is sound: a block is taken only
|
|
1195
|
+
// after a slot has been claimed, and slots are exactly what the ceiling
|
|
1196
|
+
// counts. It is recorded rather than hidden because when it does happen the
|
|
1197
|
+
// pool grows without bound, and every block it then allocates is used once
|
|
1198
|
+
// and thrown to a collector that has no reason to run — the heap stays at
|
|
1199
|
+
// 50 MB of 2240 while the process reaches four gigabytes.
|
|
1200
|
+
// Strictly greater: reaching the ceiling exactly is what a full store looks
|
|
1201
|
+
// like, and a slot has just been claimed for this block. Being ALREADY past
|
|
1202
|
+
// it and allocating anyway is the state that runs away.
|
|
1203
|
+
if (this.#blocksAllocated > this.#growthCeiling) {
|
|
1204
|
+
this.#counters.blocksBeyondCeiling += 1;
|
|
1205
|
+
}
|
|
1206
|
+
this.#blocksAllocated += 1;
|
|
1207
|
+
return this.#watchForCollection(new SharedArrayBuffer(this.#chunkLength));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Put a block back for re-use, or give it up.
|
|
1212
|
+
*
|
|
1213
|
+
* Given up when the allowance has fallen below the number of blocks that
|
|
1214
|
+
* exist: keeping it would hold memory the machine has just been shown to
|
|
1215
|
+
* need. Otherwise it waits in the free list for the next piece.
|
|
1216
|
+
*
|
|
1217
|
+
* @param {SharedArrayBuffer | undefined} buffer
|
|
1218
|
+
* @returns {void}
|
|
1219
|
+
*/
|
|
1220
|
+
#returnBlock(buffer) {
|
|
1221
|
+
if (buffer === undefined) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (this.#closed || this.#blocksAllocated > this.#growthCeiling) {
|
|
1225
|
+
// Never below zero: `close` gives up every block at once, and a spill
|
|
1226
|
+
// that was already in flight resolves afterwards and arrives here.
|
|
1227
|
+
if (this.#blocksAllocated > 0) {
|
|
1228
|
+
this.#blocksAllocated -= 1;
|
|
1229
|
+
this.#counters.blocksReleased += 1;
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
this.#freeBlocks.push({ buffer, freedAt: Date.now() });
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Record how long a block waited to be used again.
|
|
1238
|
+
*
|
|
1239
|
+
* @param {number} gapMs
|
|
1240
|
+
* @returns {void}
|
|
1241
|
+
*/
|
|
1242
|
+
#noteReuseGap(gapMs) {
|
|
1243
|
+
this.#reuseGaps.push(Math.max(0, gapMs));
|
|
1244
|
+
if (this.#reuseGaps.length > REUSE_GAP_SAMPLES) {
|
|
1245
|
+
this.#reuseGaps.shift();
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* The longest a block has recently waited before being wanted again, or null
|
|
1251
|
+
* when no block has yet been re-used.
|
|
1252
|
+
*
|
|
1253
|
+
* This is the store's own working rhythm, measured rather than chosen: while
|
|
1254
|
+
* a film is being watched a block is taken again within milliseconds, because
|
|
1255
|
+
* one is taken for every piece that arrives. A block that has been sitting
|
|
1256
|
+
* longer than the longest of those waits is not part of the work.
|
|
1257
|
+
*
|
|
1258
|
+
* @returns {number | null}
|
|
1259
|
+
*/
|
|
1260
|
+
#reuseGapCeilingMs() {
|
|
1261
|
+
if (this.#reuseGaps.length === 0) {
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
return Math.max(...this.#reuseGaps);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Give up blocks that have sat unused longer than this store's own working
|
|
1269
|
+
* rhythm.
|
|
1270
|
+
*
|
|
1271
|
+
* The case it is for: a torrent whose peers have gone. Its readers are still
|
|
1272
|
+
* attached, so nothing removes the torrent — the pool's idle timer needs a
|
|
1273
|
+
* refcount of zero and never starts. Its allowance falls to what those
|
|
1274
|
+
* readers declared, the pieces beyond it are written out, and their blocks
|
|
1275
|
+
* would otherwise wait in the free list for peers that may not return.
|
|
1276
|
+
*
|
|
1277
|
+
* @param {number} [now]
|
|
1278
|
+
* @returns {number} Blocks given up.
|
|
1279
|
+
*/
|
|
1280
|
+
sweepFreeBlocks(now = Date.now()) {
|
|
1281
|
+
const ceiling = this.#reuseGapCeilingMs();
|
|
1282
|
+
if (ceiling === null) {
|
|
1283
|
+
return 0;
|
|
1284
|
+
}
|
|
1285
|
+
const keeping = [];
|
|
1286
|
+
let released = 0;
|
|
1287
|
+
for (const spare of this.#freeBlocks) {
|
|
1288
|
+
if (now - spare.freedAt <= ceiling) {
|
|
1289
|
+
keeping.push(spare);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
this.#blocksAllocated -= 1;
|
|
1293
|
+
this.#counters.blocksReleased += 1;
|
|
1294
|
+
released += 1;
|
|
1295
|
+
}
|
|
1296
|
+
this.#freeBlocks = keeping;
|
|
1297
|
+
return released;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Count this buffer as one this thread will have to let go of, and notice
|
|
1302
|
+
* when the collector takes it. See {@link pieceBufferCollection}.
|
|
1303
|
+
*
|
|
1304
|
+
* @param {SharedArrayBuffer} buffer
|
|
1305
|
+
* @returns {SharedArrayBuffer} The same buffer.
|
|
1306
|
+
*/
|
|
1307
|
+
#watchForCollection(buffer) {
|
|
1308
|
+
released.count += 1;
|
|
1309
|
+
collector?.register(buffer, null);
|
|
1310
|
+
return buffer;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* A fresh buffer holding this piece's bytes.
|
|
1315
|
+
*
|
|
1316
|
+
* @param {number} index
|
|
1317
|
+
* @param {Uint8Array} bytes
|
|
1318
|
+
* @returns {SharedArrayBuffer}
|
|
1319
|
+
*/
|
|
1320
|
+
#copyIntoNewBuffer(index, bytes) {
|
|
1321
|
+
const length = this.#lengthOf(index);
|
|
1322
|
+
const block = this.#takeBlock();
|
|
1323
|
+
const view = Buffer.from(block, 0, length);
|
|
1324
|
+
if (bytes.copy) {
|
|
1325
|
+
bytes.copy(view, 0, 0, length);
|
|
1326
|
+
} else {
|
|
1327
|
+
view.set(bytes.subarray(0, length), 0);
|
|
1328
|
+
}
|
|
1329
|
+
return block;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Drop the disk copy of a piece that memory now holds — after any spill of
|
|
1334
|
+
* that same piece has finished.
|
|
1335
|
+
*
|
|
1336
|
+
* `PieceDiskStore.write` records the index when it COMPLETES, so forgetting while a
|
|
1337
|
+
* spill of that index is still running let the completing write put it back,
|
|
1338
|
+
* and a later read then returned the stale bytes.
|
|
1339
|
+
*
|
|
1340
|
+
* @param {number} index
|
|
1341
|
+
* @returns {Promise<void>}
|
|
1342
|
+
*/
|
|
1343
|
+
async #forgetOnDisk(index) {
|
|
1344
|
+
const spill = this.#evicting.get(index);
|
|
1345
|
+
if (spill) {
|
|
1346
|
+
await spill.catch(() => undefined);
|
|
1347
|
+
}
|
|
1348
|
+
this.#disk.forget(index);
|
|
1349
|
+
this.#spilledAt.delete(index);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
put(index, bytes, callback = () => undefined) {
|
|
1353
|
+
if (this.#closed) {
|
|
1354
|
+
queueMicrotask(() => callback(new Error("Piece store is closed.")));
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const write = async () => {
|
|
1359
|
+
// Already resident: the buffer is replaced, not added, so no slot is
|
|
1360
|
+
// needed and none is claimed. A fresh buffer rather than a write into the
|
|
1361
|
+
// old one, so a reader holding the old reference cannot see a torn write.
|
|
1362
|
+
if (this.#buffers.has(index)) {
|
|
1363
|
+
const previous = this.#buffers.get(index);
|
|
1364
|
+
// A fresh block rather than a write into the old one, so a reader
|
|
1365
|
+
// holding the old reference cannot see a torn write.
|
|
1366
|
+
this.#buffers.set(index, this.#copyIntoNewBuffer(index, bytes));
|
|
1367
|
+
this.#lru.touch(index);
|
|
1368
|
+
// And the old block goes back for re-use only if nobody is reading it.
|
|
1369
|
+
// A pinned piece has a view onto its memory somewhere; that block is
|
|
1370
|
+
// given up instead, and the pool allocates another when it needs one.
|
|
1371
|
+
if (this.#lru.isPinned(index)) {
|
|
1372
|
+
if (this.#blocksAllocated > 0) {
|
|
1373
|
+
this.#blocksAllocated -= 1;
|
|
1374
|
+
this.#counters.blocksReleased += 1;
|
|
1375
|
+
}
|
|
1376
|
+
} else {
|
|
1377
|
+
this.#returnBlock(previous);
|
|
1378
|
+
}
|
|
1379
|
+
await this.#forgetOnDisk(index);
|
|
1380
|
+
this.#noteProgress();
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
this.#noteArrival();
|
|
1385
|
+
const declared = this.#lru.wants(index);
|
|
1386
|
+
if (declared) {
|
|
1387
|
+
this.#counters.admittedInsideWindow += 1;
|
|
1388
|
+
} else {
|
|
1389
|
+
this.#counters.admittedOutsideWindow += 1;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// A piece wanted LESS than the one it would displace goes straight to
|
|
1393
|
+
// disk instead of being admitted.
|
|
1394
|
+
//
|
|
1395
|
+
// The same comparison eviction makes, in the same order, from the other
|
|
1396
|
+
// end: the priority map's level first, and the distance to a read head
|
|
1397
|
+
// only between pieces the map wants equally. Answering the two from
|
|
1398
|
+
// different quantities would let the store admit a piece by one rule and
|
|
1399
|
+
// evict it again by the other in the same second.
|
|
1400
|
+
//
|
|
1401
|
+
// Two cases, and the second was missing until 2026-09-02. The first: the
|
|
1402
|
+
// arrival is in nobody's zone at all. The second: it IS in a zone, but
|
|
1403
|
+
// one the map wants less than the zone holding the piece the store would
|
|
1404
|
+
// have to evict for it. The field session had six readers whose windows
|
|
1405
|
+
// covered the whole file, so the first case never applied and `0 of those
|
|
1406
|
+
// went straight to disk` while the store spilled 233 pieces in a minute.
|
|
1407
|
+
//
|
|
1408
|
+
// Only when SOMETHING is stated: before that there is no basis for
|
|
1409
|
+
// calling one piece more wanted than another.
|
|
1410
|
+
const worseThanTheVictim = () => {
|
|
1411
|
+
const victim = this.#lru.nextVictim();
|
|
1412
|
+
if (victim.index === null) {
|
|
1413
|
+
return false;
|
|
1414
|
+
}
|
|
1415
|
+
const arrivingWant = this.#lru.wantAt(index);
|
|
1416
|
+
if (arrivingWant !== victim.want) {
|
|
1417
|
+
return arrivingWant > victim.want;
|
|
1418
|
+
}
|
|
1419
|
+
const arriving = this.#lru.waitFor(index);
|
|
1420
|
+
return arriving >= 0 && victim.wait >= 0 && arriving > victim.wait;
|
|
1421
|
+
};
|
|
1422
|
+
if (this.#lru.protectedCount > 0 && this.#isFullNow()
|
|
1423
|
+
&& (!declared || worseThanTheVictim())) {
|
|
1424
|
+
this.#counters.admittedToDisk += 1;
|
|
1425
|
+
await this.#writeThrough(index, bytes);
|
|
1426
|
+
this.#noteProgress();
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const release = await this.#claimSlot();
|
|
1431
|
+
try {
|
|
1432
|
+
this.#registerPiece(index, this.#copyIntoNewBuffer(index, bytes));
|
|
1433
|
+
await this.#forgetOnDisk(index);
|
|
1434
|
+
} finally {
|
|
1435
|
+
release();
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
write().then(() => callback(null), (error) => callback(error));
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
get(index, options, callback) {
|
|
1443
|
+
if (typeof options === "function") {
|
|
1444
|
+
return this.get(index, undefined, options);
|
|
1445
|
+
}
|
|
1446
|
+
const done = callback ?? (() => undefined);
|
|
1447
|
+
if (this.#closed) {
|
|
1448
|
+
queueMicrotask(() => done(new Error("Piece store is closed.")));
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const pieceLength = this.#lengthOf(index);
|
|
1453
|
+
const offset = options?.offset ?? 0;
|
|
1454
|
+
const length = options?.length ?? pieceLength - offset;
|
|
1455
|
+
|
|
1456
|
+
const fetch = async () => {
|
|
1457
|
+
const buffer = this.#buffers.get(index);
|
|
1458
|
+
if (buffer !== undefined) {
|
|
1459
|
+
this.#lru.touch(index);
|
|
1460
|
+
this.#counters.fromMemory += 1;
|
|
1461
|
+
return Buffer.from(Buffer.from(buffer, offset, length));
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const revived = await this.#revive(index);
|
|
1465
|
+
if (revived === null) {
|
|
1466
|
+
throw new Error(`Piece ${index} is not in the store.`);
|
|
1467
|
+
}
|
|
1468
|
+
return Buffer.from(Buffer.from(revived, offset, length));
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
fetch().then((bytes) => done(null, bytes), (error) => done(error));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* State a range of pieces and how much they are wanted. The number is the
|
|
1476
|
+
* priority map's own, and it is what eviction compares — see
|
|
1477
|
+
* {@link PieceLru#protect}.
|
|
1478
|
+
*
|
|
1479
|
+
* @param {string|number} readerId
|
|
1480
|
+
* @param {number} from
|
|
1481
|
+
* @param {number} to
|
|
1482
|
+
* @param {number} [urgency]
|
|
1483
|
+
* @returns {void}
|
|
1484
|
+
*/
|
|
1485
|
+
protectRange(readerId, from, to, urgency) {
|
|
1486
|
+
this.#lru.protect(readerId, from, to, urgency);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
protectedRanges() {
|
|
1490
|
+
return this.#lru.protectedRanges();
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
releaseProtection(readerId) {
|
|
1494
|
+
this.#lru.unprotect(readerId);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
warmRange(from, to, limit = Math.max(1, Math.floor(this.#growthCeiling / 4))) {
|
|
1498
|
+
if (this.#closed || !Number.isInteger(from) || !Number.isInteger(to)) {
|
|
1499
|
+
return 0;
|
|
1500
|
+
}
|
|
1501
|
+
let started = 0;
|
|
1502
|
+
for (let index = from; index <= to && started < limit; index += 1) {
|
|
1503
|
+
if (this.#buffers.has(index) || !this.#disk.has(index)) {
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
started += 1;
|
|
1507
|
+
void this.reside(index).catch(() => undefined);
|
|
1508
|
+
}
|
|
1509
|
+
return started;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
async reside(index) {
|
|
1513
|
+
if (this.#closed) {
|
|
1514
|
+
throw new Error("Piece store is closed.");
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const buffer = this.#buffers.get(index);
|
|
1518
|
+
if (buffer !== undefined) {
|
|
1519
|
+
this.#lru.touch(index);
|
|
1520
|
+
this.#counters.fromMemory += 1;
|
|
1521
|
+
return this.locate(index);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const revived = await this.#revive(index);
|
|
1525
|
+
if (revived === null) {
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
return { buffer: revived, offset: 0, length: this.#lengthOf(index) };
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
close(callback = () => undefined) {
|
|
1532
|
+
this.#closed = true;
|
|
1533
|
+
liveStores.delete(this);
|
|
1534
|
+
this.#buffers.clear();
|
|
1535
|
+
this.#spilledAt.clear();
|
|
1536
|
+
this.#counters.blocksReleased += this.#blocksAllocated;
|
|
1537
|
+
this.#blocksAllocated = 0;
|
|
1538
|
+
this.#freeBlocks = [];
|
|
1539
|
+
// Whoever is waiting for a slot is woken and finds the store closed, which
|
|
1540
|
+
// is an error they can report. Left asleep they simply never returned.
|
|
1541
|
+
this.#wake();
|
|
1542
|
+
this.#disk.close().then(() => callback(null), (error) => callback(error));
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
destroy(callback = () => undefined) {
|
|
1546
|
+
this.#closed = true;
|
|
1547
|
+
liveStores.delete(this);
|
|
1548
|
+
this.#buffers.clear();
|
|
1549
|
+
this.#spilledAt.clear();
|
|
1550
|
+
this.#counters.blocksReleased += this.#blocksAllocated;
|
|
1551
|
+
this.#blocksAllocated = 0;
|
|
1552
|
+
this.#freeBlocks = [];
|
|
1553
|
+
this.#wake();
|
|
1554
|
+
this.#disk.destroy().then(() => callback(null), (error) => callback(error));
|
|
1555
|
+
}
|
|
1556
|
+
}
|