@torrent-tv/proxy 2.69.1 → 2.70.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 +21 -0
- package/package.json +1 -1
- package/services/piece-store/piece-lru.js +109 -3
- package/services/piece-store/shared-piece-store.js +596 -36
- package/services/torrent-worker/worker.js +83 -10
- package/test/memory-budget.test.js +73 -29
- package/test/piece-lru.test.js +63 -0
- package/test/piece-store-eviction.test.js +269 -0
|
@@ -68,31 +68,134 @@ export function findSharedStore(torrent) {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The largest fall in the machine's available memory that this process has
|
|
73
|
+
* seen and did not cause itself.
|
|
74
|
+
*
|
|
75
|
+
* What has to be left for everything else on the machine. It cannot be a share
|
|
76
|
+
* of what is free — a share is a number chosen out of nothing, which is what
|
|
77
|
+
* `AVAILABLE_MEMORY_SHARE = 0.25`, `MIN_BUDGET_BYTES` and
|
|
78
|
+
* `MEMORY_BUDGET_CEILING_BYTES` were until 2026-09-02, all three traceable to
|
|
79
|
+
* one observation of one host on 2026-08-03. It is measured instead: between
|
|
80
|
+
* two readings, how much available memory went away beyond what the stores
|
|
81
|
+
* themselves took. On the field host that quantity is large — while the proxy
|
|
82
|
+
* held 76-133 MB overnight the machine's available memory fell from 2378 MB to
|
|
83
|
+
* 306 MB and came back — and on a quiet host it stays near zero, which is the
|
|
84
|
+
* right answer there.
|
|
85
|
+
*
|
|
86
|
+
* Starts at zero: nothing is reserved until somebody else has been seen to
|
|
87
|
+
* need it.
|
|
88
|
+
*/
|
|
89
|
+
const otherDemand = { falls: [], lastAvailableBytes: 0, lastStoreBytes: 0 };
|
|
73
90
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
/**
|
|
92
|
+
* How many observations of other processes' demand are kept.
|
|
93
|
+
*
|
|
94
|
+
* A window rather than a high-water, and for the reason the block re-use gap is
|
|
95
|
+
* one too: a single spike would otherwise stand for the life of the process and
|
|
96
|
+
* squeeze the stores against something that happened once, hours ago.
|
|
97
|
+
*/
|
|
98
|
+
const OTHER_DEMAND_SAMPLES = 60;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Note what the machine had, and how much of the change was not ours.
|
|
102
|
+
*
|
|
103
|
+
* @param {number} availableBytes
|
|
104
|
+
* @param {number} storeBytes - What the live stores hold right now.
|
|
105
|
+
* @returns {number} The reserve, in bytes.
|
|
106
|
+
*/
|
|
107
|
+
export function noteMachineMemory(availableBytes, storeBytes) {
|
|
108
|
+
if (otherDemand.lastAvailableBytes > 0) {
|
|
109
|
+
const fell = otherDemand.lastAvailableBytes - availableBytes;
|
|
110
|
+
const ours = storeBytes - otherDemand.lastStoreBytes;
|
|
111
|
+
otherDemand.falls.push(Math.max(0, fell - ours));
|
|
112
|
+
if (otherDemand.falls.length > OTHER_DEMAND_SAMPLES) {
|
|
113
|
+
otherDemand.falls.shift();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
otherDemand.lastAvailableBytes = availableBytes;
|
|
117
|
+
otherDemand.lastStoreBytes = storeBytes;
|
|
118
|
+
return machineReserveBytes();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What has recently been observed to be needed by everything that is not us. */
|
|
122
|
+
export function machineReserveBytes() {
|
|
123
|
+
return otherDemand.falls.length === 0 ? 0 : Math.max(...otherDemand.falls);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Forget what other processes have needed. For tests, which share a module. */
|
|
127
|
+
export function forgetMachineMemory() {
|
|
128
|
+
otherDemand.falls = [];
|
|
129
|
+
otherDemand.lastAvailableBytes = 0;
|
|
130
|
+
otherDemand.lastStoreBytes = 0;
|
|
77
131
|
}
|
|
78
132
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
133
|
+
/**
|
|
134
|
+
* How much memory the stores may hold between them.
|
|
135
|
+
*
|
|
136
|
+
* `MemAvailable` is what could be allocated on top of what is already held, so
|
|
137
|
+
* the stores' own bytes are added back: the pair is the ceiling the stores
|
|
138
|
+
* could reach. The reserve is what has been seen to be needed elsewhere.
|
|
139
|
+
*
|
|
140
|
+
* @param {number} availableBytes
|
|
141
|
+
* @param {number} storeBytes
|
|
142
|
+
* @param {number} reserveBytes
|
|
143
|
+
* @returns {number}
|
|
144
|
+
*/
|
|
145
|
+
export function machineAllowanceBytes(availableBytes, storeBytes, reserveBytes) {
|
|
146
|
+
return Math.max(0, Math.max(availableBytes, 0) + Math.max(storeBytes, 0) - Math.max(reserveBytes, 0));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Divide what the machine allows between the stores, by what each is asking
|
|
151
|
+
* for.
|
|
152
|
+
*
|
|
153
|
+
* A store asks for the pieces its readers have declared. When everyone's ask
|
|
154
|
+
* fits, everyone gets it and the machine's limit never binds — which is the
|
|
155
|
+
* usual case, since two readers of one film declare 32-192 MB against gigabytes
|
|
156
|
+
* of free memory. When the asks do not fit, each store is cut in proportion to
|
|
157
|
+
* what it asked, so a store wanting little is not cut to make room for one
|
|
158
|
+
* wanting much.
|
|
159
|
+
*
|
|
160
|
+
* @param {number[]} wantedBytes - What each store is asking for, in order.
|
|
161
|
+
* @param {number} allowanceBytes
|
|
162
|
+
* @returns {number[]} What each store may hold, in the same order.
|
|
163
|
+
*/
|
|
164
|
+
export function divideAllowance(wantedBytes, allowanceBytes) {
|
|
165
|
+
const total = wantedBytes.reduce((sum, want) => sum + Math.max(0, want), 0);
|
|
166
|
+
if (total <= allowanceBytes || total === 0) {
|
|
167
|
+
return wantedBytes.map((want) => Math.max(0, want));
|
|
168
|
+
}
|
|
169
|
+
return wantedBytes.map((want) => Math.floor(allowanceBytes * (Math.max(0, want) / total)));
|
|
83
170
|
}
|
|
84
171
|
|
|
85
172
|
export function reviseStoreBudgets() {
|
|
86
|
-
const
|
|
173
|
+
const stores = [...liveStores];
|
|
174
|
+
const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
|
|
175
|
+
const available = availableMemorySync();
|
|
176
|
+
const reserve = noteMachineMemory(available, held);
|
|
177
|
+
const allowance = machineAllowanceBytes(available, held, reserve);
|
|
178
|
+
const shares = divideAllowance(stores.map((store) => store.wantedBytes), allowance);
|
|
87
179
|
const revised = [];
|
|
88
|
-
for (const store of
|
|
89
|
-
revised.push(store.reviseGrowthCeiling(
|
|
180
|
+
for (const [position, store] of stores.entries()) {
|
|
181
|
+
revised.push(store.reviseGrowthCeiling(shares[position]));
|
|
90
182
|
}
|
|
91
183
|
return revised;
|
|
92
184
|
}
|
|
93
185
|
|
|
94
186
|
function defaultMemoryBytes() {
|
|
95
|
-
|
|
187
|
+
const stores = [...liveStores];
|
|
188
|
+
const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
|
|
189
|
+
const available = availableMemorySync();
|
|
190
|
+
const allowance = machineAllowanceBytes(available, held, machineReserveBytes());
|
|
191
|
+
// A store being created has no readers, so it has no demand to state and no
|
|
192
|
+
// basis for asking for more or less than the others. It opens on an equal
|
|
193
|
+
// share and the first revision — within a minute, and within seconds of a
|
|
194
|
+
// read starting — replaces that with what its readers actually declare.
|
|
195
|
+
// Deliberately not the whole allowance: on a machine with gigabytes free that
|
|
196
|
+
// would let a torrent nobody is reading yet fill memory before the first
|
|
197
|
+
// revision arrives.
|
|
198
|
+
return Math.floor(allowance / (stores.length + 1));
|
|
96
199
|
}
|
|
97
200
|
|
|
98
201
|
function availableMemorySync() {
|
|
@@ -108,8 +211,12 @@ function availableMemorySync() {
|
|
|
108
211
|
}
|
|
109
212
|
|
|
110
213
|
/**
|
|
111
|
-
* How many piece
|
|
112
|
-
* has actually taken back.
|
|
214
|
+
* How many blocks of piece memory this thread has let go of, and how many the
|
|
215
|
+
* collector has actually taken back.
|
|
216
|
+
*
|
|
217
|
+
* Since the store keeps a pool, one block serves many pieces, so this counts
|
|
218
|
+
* blocks and not pieces — and a healthy store allocates only as many as its
|
|
219
|
+
* allowance permits, so both numbers are now small and nearly equal.
|
|
113
220
|
*
|
|
114
221
|
* The one reading that separates the two explanations of the 700 MB nobody can
|
|
115
222
|
* account for (roadmap item 2, field 2026-08-31): if the two numbers track each
|
|
@@ -139,7 +246,6 @@ export function pieceBufferCollection() {
|
|
|
139
246
|
return { released: released.count, collected: released.collected };
|
|
140
247
|
}
|
|
141
248
|
|
|
142
|
-
const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
143
249
|
const MIN_RESIDENT_PIECES = 2;
|
|
144
250
|
/**
|
|
145
251
|
* How long a claim may go without ANYTHING moving before it gives up.
|
|
@@ -151,13 +257,45 @@ const MIN_RESIDENT_PIECES = 2;
|
|
|
151
257
|
* never failing (field 2026-08-31).
|
|
152
258
|
*/
|
|
153
259
|
const PINNED_WAIT_MS = 5_000;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* How many revival ages are kept for the median. A window, not a history: two
|
|
263
|
+
* hundred covers several minutes of the busiest session measured (7575
|
|
264
|
+
* revivals in 44 minutes) and costs two hundred numbers.
|
|
265
|
+
*/
|
|
266
|
+
const REVIVAL_AGE_SAMPLES = 200;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* How many block re-use gaps are kept. The same window as the revival ages, and
|
|
270
|
+
* for the same reason: what is wanted is the rhythm of recent work, not a
|
|
271
|
+
* history of it.
|
|
272
|
+
*/
|
|
273
|
+
const REUSE_GAP_SAMPLES = 200;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The middle value of a sample, or null when there is nothing to take a middle
|
|
277
|
+
* of. Null rather than zero: no revivals and instant revivals are different
|
|
278
|
+
* facts and must not print the same.
|
|
279
|
+
*
|
|
280
|
+
* @param {number[]} values
|
|
281
|
+
* @returns {number | null}
|
|
282
|
+
*/
|
|
283
|
+
function median(values) {
|
|
284
|
+
if (values.length === 0) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
288
|
+
const middle = Math.floor(sorted.length / 2);
|
|
289
|
+
return sorted.length % 2 === 0
|
|
290
|
+
? Math.round((sorted[middle - 1] + sorted[middle]) / 2)
|
|
291
|
+
: sorted[middle];
|
|
292
|
+
}
|
|
154
293
|
const CLAIM_RETRY_MS = 50;
|
|
155
294
|
|
|
156
295
|
export class SharedPieceStore {
|
|
157
296
|
#chunkLength;
|
|
158
297
|
#lastChunkLength;
|
|
159
298
|
#lastChunkIndex;
|
|
160
|
-
#capacity;
|
|
161
299
|
#growthCeiling;
|
|
162
300
|
/** Piece index → SharedArrayBuffer of that piece */
|
|
163
301
|
#buffers = new Map();
|
|
@@ -191,8 +329,69 @@ export class SharedPieceStore {
|
|
|
191
329
|
blockedByPins: 0,
|
|
192
330
|
waitedForPins: 0,
|
|
193
331
|
evictedOnRevise: 0,
|
|
194
|
-
spillFailures: 0
|
|
332
|
+
spillFailures: 0,
|
|
333
|
+
// Whether the store is doing its job or being asked to hold more than it
|
|
334
|
+
// has room for. An eviction that had to take a piece a reader declared it
|
|
335
|
+
// wants is the second, and it comes back from disk moments later
|
|
336
|
+
// (roadmap item 9).
|
|
337
|
+
evictedProtected: 0,
|
|
338
|
+
evictedDistanceSum: 0,
|
|
339
|
+
evictedWithDistance: 0,
|
|
340
|
+
// Where an arriving piece went. A piece nobody has declared is being
|
|
341
|
+
// downloaded ahead of every reader; putting it in memory pushes out one
|
|
342
|
+
// that IS declared, which is then read back from disk moments later
|
|
343
|
+
// (roadmap item 9).
|
|
344
|
+
admittedInsideWindow: 0,
|
|
345
|
+
admittedOutsideWindow: 0,
|
|
346
|
+
admittedToDisk: 0,
|
|
347
|
+
/** Blocks given back to the operating system. */
|
|
348
|
+
blocksReleased: 0,
|
|
349
|
+
/**
|
|
350
|
+
* A block put back for re-use while a reader still held the piece. Zero by
|
|
351
|
+
* construction — a pinned piece is never evicted, and a re-put of a pinned
|
|
352
|
+
* one drops its block instead of recycling it. Counted because if it ever
|
|
353
|
+
* stops being zero, a consumer is reading another piece's bytes, and that
|
|
354
|
+
* is invisible from anywhere else.
|
|
355
|
+
*/
|
|
356
|
+
returnedWhilePinned: 0,
|
|
357
|
+
/** Spills that found the disk already holding identical bytes. */
|
|
358
|
+
spillsSkipped: 0
|
|
195
359
|
};
|
|
360
|
+
/**
|
|
361
|
+
* Blocks that hold no piece, most recently freed last.
|
|
362
|
+
*
|
|
363
|
+
* A block is one piece's worth of memory. Allocating a new one for every
|
|
364
|
+
* piece meant 7575 allocations of 4 MiB in 44 minutes on 2026-09-02, each
|
|
365
|
+
* released only when the collector got to it — which is why the process held
|
|
366
|
+
* 1.86 GB while its own accounting said 352 MB. Blocks are taken from here
|
|
367
|
+
* and put back here instead (roadmap item 2).
|
|
368
|
+
*
|
|
369
|
+
* @type {{ buffer: SharedArrayBuffer, freedAt: number }[]}
|
|
370
|
+
*/
|
|
371
|
+
#freeBlocks = [];
|
|
372
|
+
/** Blocks that exist at all: free plus holding a piece. */
|
|
373
|
+
#blocksAllocated = 0;
|
|
374
|
+
/** Whether a reader has ever declared a window here. See `wantedBytes`. */
|
|
375
|
+
#everHadReader = false;
|
|
376
|
+
/**
|
|
377
|
+
* How long a block sat free before it was taken again, in milliseconds.
|
|
378
|
+
* Bounded, because what is wanted is the longest gap of RECENT work: an
|
|
379
|
+
* all-time maximum would be raised by one long pause and then never let a
|
|
380
|
+
* block go again.
|
|
381
|
+
*
|
|
382
|
+
* @type {number[]}
|
|
383
|
+
*/
|
|
384
|
+
#reuseGaps = [];
|
|
385
|
+
/** Piece index → when it was written out, for the age it comes back at. */
|
|
386
|
+
#spilledAt = new Map();
|
|
387
|
+
/**
|
|
388
|
+
* Ages, in milliseconds, of the last revivals — bounded, because the figure
|
|
389
|
+
* wanted is a median and not a history. A piece that comes back seconds
|
|
390
|
+
* after it left should not have left.
|
|
391
|
+
*
|
|
392
|
+
* @type {number[]}
|
|
393
|
+
*/
|
|
394
|
+
#revivalAges = [];
|
|
196
395
|
|
|
197
396
|
constructor(chunkLength, options = {}) {
|
|
198
397
|
if (!Number.isInteger(chunkLength) || chunkLength < 1) {
|
|
@@ -208,10 +407,11 @@ export class SharedPieceStore {
|
|
|
208
407
|
const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
|
|
209
408
|
? options.memoryBytes
|
|
210
409
|
: defaultMemoryBytes();
|
|
211
|
-
this
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
this.#
|
|
410
|
+
// Only where this store STARTS. It is not kept, so it cannot come back as a
|
|
411
|
+
// cap on the revision the way `#capacity` did.
|
|
412
|
+
const openingCeiling = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
|
|
413
|
+
this.#growthCeiling = openingCeiling;
|
|
414
|
+
this.#lru = new PieceLru(openingCeiling);
|
|
215
415
|
this.#name = options.name ?? "pieces";
|
|
216
416
|
// `options.disk` exists so a test can hold a write open or make one fail on
|
|
217
417
|
// purpose. Four of the defects fixed here live in what happens when the
|
|
@@ -234,8 +434,11 @@ export class SharedPieceStore {
|
|
|
234
434
|
resident,
|
|
235
435
|
capacity: this.#growthCeiling,
|
|
236
436
|
residentBytes,
|
|
237
|
-
allocatedSlots:
|
|
238
|
-
|
|
437
|
+
allocatedSlots: this.#blocksAllocated,
|
|
438
|
+
// What the process HOLDS, which with a pool is the blocks that exist —
|
|
439
|
+
// not the pieces in them. Holding and using are different quantities and
|
|
440
|
+
// the difference is the point of the reading.
|
|
441
|
+
committedBytes: this.#blocksAllocated * this.#chunkLength,
|
|
239
442
|
budgetBytes: this.#growthCeiling * this.#chunkLength,
|
|
240
443
|
pinned: this.#lru.pinnedCount,
|
|
241
444
|
// Slots claimed and not yet filled. Reported because a reservation that
|
|
@@ -244,16 +447,86 @@ export class SharedPieceStore {
|
|
|
244
447
|
outstanding: this.#outstandingPieces,
|
|
245
448
|
spilled: this.#disk.size,
|
|
246
449
|
spilledBytes: this.#disk.size * this.#chunkLength,
|
|
450
|
+
// What the readers between them are asking this store to keep, against
|
|
451
|
+
// what it may hold. A union wider than the capacity cannot be held
|
|
452
|
+
// however the eviction is ordered, and that is the difference between a
|
|
453
|
+
// policy to fix and arithmetic to accept (roadmap item 9).
|
|
454
|
+
demand: this.#lru.demand(),
|
|
455
|
+
// What the process actually holds for this store, which is not the same
|
|
456
|
+
// as what it is using: blocks are kept for re-use. The difference is the
|
|
457
|
+
// spare, and it is the whole of the question whether consumption only
|
|
458
|
+
// grows (roadmap item 2).
|
|
459
|
+
blocksAllocated: this.#blocksAllocated,
|
|
460
|
+
blocksFree: this.#freeBlocks.length,
|
|
461
|
+
blockBytes: this.#blocksAllocated * this.#chunkLength,
|
|
462
|
+
reuseGapMs: this.#reuseGapCeilingMs(),
|
|
463
|
+
revivalAgeMedianMs: median(this.#revivalAges),
|
|
464
|
+
revivalAgeSamples: this.#revivalAges.length,
|
|
465
|
+
revivedWithinFiveSeconds: this.#revivalAges.filter((age) => age <= 5_000).length,
|
|
247
466
|
...this.#counters
|
|
248
467
|
};
|
|
249
468
|
}
|
|
250
469
|
|
|
470
|
+
/** What this store holds right now, in bytes. */
|
|
471
|
+
get residentBytes() {
|
|
472
|
+
return this.#buffers.size * this.#chunkLength;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* What this store is asking to be allowed to hold, in bytes.
|
|
477
|
+
*
|
|
478
|
+
* The union of its readers' declared windows — what they have said they will
|
|
479
|
+
* need — never below the widest single window, because a store that cannot
|
|
480
|
+
* hold one reader's window cannot complete that reader's read at all: every
|
|
481
|
+
* resident piece ends up pinned, the read returns zero bytes and ffmpeg takes
|
|
482
|
+
* that for the end of the file (field 2026-08-15, roadmap item 9).
|
|
483
|
+
*
|
|
484
|
+
* With no reader declaring anything there is no demand to speak of, so the
|
|
485
|
+
* store asks for what the machine allows and the first revision after a read
|
|
486
|
+
* begins brings it down to what that read needs.
|
|
487
|
+
*/
|
|
488
|
+
get wantedBytes() {
|
|
489
|
+
const demand = this.#lru.demand();
|
|
490
|
+
if (demand.readers > 0) {
|
|
491
|
+
this.#everHadReader = true;
|
|
492
|
+
const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces);
|
|
493
|
+
return pieces * this.#chunkLength;
|
|
494
|
+
}
|
|
495
|
+
// Readers that have GONE are not the same as readers that have not arrived.
|
|
496
|
+
// A store whose readers ended has nothing to hold pieces for — its torrent
|
|
497
|
+
// sits until the pool's idle timer removes it, which needs a refcount of
|
|
498
|
+
// zero and can be a quarter of an hour away — so it asks for nothing and
|
|
499
|
+
// its memory goes back to the machine now. A store that has never had a
|
|
500
|
+
// reader is being filled for one that is on its way, and asks for what it
|
|
501
|
+
// was opened with until the first read says what it needs.
|
|
502
|
+
return this.#everHadReader
|
|
503
|
+
? MIN_RESIDENT_PIECES * this.#chunkLength
|
|
504
|
+
: this.#growthCeiling * this.#chunkLength;
|
|
505
|
+
}
|
|
506
|
+
|
|
251
507
|
reviseGrowthCeiling(allowedBytes) {
|
|
508
|
+
// No cap at what the machine could spare when this store was CREATED.
|
|
509
|
+
// `#capacity` was computed once in the constructor and used as an upper
|
|
510
|
+
// bound here, so the allowance could only ever fall: a torrent opened while
|
|
511
|
+
// the machine was full kept a small allowance for its whole life, however
|
|
512
|
+
// much memory was freed afterwards (roadmap item 2, 2026-09-02).
|
|
252
513
|
const wanted = Math.floor(Number(allowedBytes) / this.#chunkLength);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
514
|
+
// Never below one reader's whole window while a reader exists, even when
|
|
515
|
+
// the machine's share says less. A store that cannot hold the window of the
|
|
516
|
+
// read it is serving cannot complete that read at all: every resident piece
|
|
517
|
+
// ends up pinned, the read returns zero bytes and ffmpeg takes that for the
|
|
518
|
+
// end of the file, which killed every encoder on that file in the field on
|
|
519
|
+
// 2026-08-15. Exceeding the share is the lesser failure, and the line below
|
|
520
|
+
// says when it happens.
|
|
521
|
+
const demand = this.#lru.demand();
|
|
522
|
+
this.#growthCeiling = Math.max(
|
|
523
|
+
MIN_RESIDENT_PIECES,
|
|
524
|
+
demand.readers > 0 ? demand.widestPieces : MIN_RESIDENT_PIECES,
|
|
525
|
+
Number.isFinite(wanted) ? wanted : MIN_RESIDENT_PIECES
|
|
256
526
|
);
|
|
527
|
+
const belowAWindow = demand.readers > 0
|
|
528
|
+
&& Number.isFinite(wanted)
|
|
529
|
+
&& wanted < demand.widestPieces;
|
|
257
530
|
// The LRU is told too. It was constructed with the store's original
|
|
258
531
|
// capacity and never revised, so `isFull()` answered against a number that
|
|
259
532
|
// had not been the limit for some time — dormant only because nothing calls
|
|
@@ -263,7 +536,7 @@ export class SharedPieceStore {
|
|
|
263
536
|
// old growable pool. Eagerly evict excess to honour the new ceiling.
|
|
264
537
|
let evicted = 0;
|
|
265
538
|
while (this.#buffers.size > this.#growthCeiling) {
|
|
266
|
-
const victim = this.#lru.
|
|
539
|
+
const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
|
|
267
540
|
if (victim === null) {
|
|
268
541
|
break;
|
|
269
542
|
}
|
|
@@ -276,6 +549,7 @@ export class SharedPieceStore {
|
|
|
276
549
|
this.#lru.remove(victim);
|
|
277
550
|
evicted += 1;
|
|
278
551
|
this.#counters.evictedOnRevise += 1;
|
|
552
|
+
this.#noteEviction(protectionYielded, distance);
|
|
279
553
|
// Nobody awaits this spill, so its failure has to end here. Rethrowing
|
|
280
554
|
// made it an unhandled rejection, and an unhandled rejection in the
|
|
281
555
|
// torrent worker ends the thread — a second way to lose the torrent
|
|
@@ -284,11 +558,17 @@ export class SharedPieceStore {
|
|
|
284
558
|
this.#counters.spillFailures += 1;
|
|
285
559
|
});
|
|
286
560
|
}
|
|
561
|
+
// Blocks the store is no longer using and has waited long enough to give
|
|
562
|
+
// up. The allowance falling is the moment to ask, because that is when the
|
|
563
|
+
// machine has been shown to need the memory.
|
|
564
|
+
const releasedBlocks = this.sweepFreeBlocks();
|
|
287
565
|
return {
|
|
288
566
|
name: this.#name,
|
|
289
567
|
ceilingBytes: this.#growthCeiling * this.#chunkLength,
|
|
290
|
-
committedBytes: this.#
|
|
291
|
-
evicted
|
|
568
|
+
committedBytes: this.#blocksAllocated * this.#chunkLength,
|
|
569
|
+
evicted,
|
|
570
|
+
releasedBlocks,
|
|
571
|
+
belowAWindow
|
|
292
572
|
};
|
|
293
573
|
}
|
|
294
574
|
|
|
@@ -417,15 +697,39 @@ export class SharedPieceStore {
|
|
|
417
697
|
* @returns {Promise<void>}
|
|
418
698
|
*/
|
|
419
699
|
#spill(index, buffer) {
|
|
700
|
+
// The disk may already hold these very bytes. `#revive` reads a piece back
|
|
701
|
+
// into memory and leaves the copy on disk, and only `put` removes it — so a
|
|
702
|
+
// piece that was revived and not re-put is identical to what is already
|
|
703
|
+
// written, and writing it again is work for nothing. There were 7575
|
|
704
|
+
// revivals in one session on 2026-09-02, and every later eviction of one of
|
|
705
|
+
// them wrote a second time (roadmap item 66: 14.4 GB written in a single
|
|
706
|
+
// viewing).
|
|
707
|
+
if (this.#disk.has(index)) {
|
|
708
|
+
this.#counters.spills += 1;
|
|
709
|
+
this.#counters.spillsSkipped += 1;
|
|
710
|
+
this.#spilledAt.set(index, Date.now());
|
|
711
|
+
this.#returnBlock(buffer);
|
|
712
|
+
this.#noteProgress();
|
|
713
|
+
return Promise.resolve();
|
|
714
|
+
}
|
|
715
|
+
|
|
420
716
|
const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
|
|
421
717
|
const spill = this.#disk.write(index, bytes).then(
|
|
422
718
|
() => {
|
|
423
719
|
this.#counters.spills += 1;
|
|
720
|
+
this.#spilledAt.set(index, Date.now());
|
|
424
721
|
this.#evicting.delete(index);
|
|
722
|
+
// Only now. The write reads out of this block, so a block handed to
|
|
723
|
+
// another piece before the write finished would put that piece's bytes
|
|
724
|
+
// into this piece's place in the file.
|
|
725
|
+
this.#returnBlock(buffer);
|
|
425
726
|
this.#noteProgress();
|
|
426
727
|
},
|
|
427
728
|
(error) => {
|
|
428
729
|
this.#evicting.delete(index);
|
|
730
|
+
// The block is no longer holding anything either way; keeping it out of
|
|
731
|
+
// the pool because the write failed would lose it for good.
|
|
732
|
+
this.#returnBlock(buffer);
|
|
429
733
|
this.#noteProgress();
|
|
430
734
|
throw error;
|
|
431
735
|
}
|
|
@@ -440,6 +744,95 @@ export class SharedPieceStore {
|
|
|
440
744
|
this.#wake();
|
|
441
745
|
}
|
|
442
746
|
|
|
747
|
+
/**
|
|
748
|
+
* Whether admitting one more piece would need something evicted first.
|
|
749
|
+
*
|
|
750
|
+
* Reservations count: a slot claimed and not yet filled is as taken as a
|
|
751
|
+
* resident piece.
|
|
752
|
+
*
|
|
753
|
+
* @returns {boolean}
|
|
754
|
+
*/
|
|
755
|
+
#isFullNow() {
|
|
756
|
+
return this.#buffers.size + this.#outstandingPieces >= this.#growthCeiling;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Write an arriving piece straight to disk, without it ever occupying memory.
|
|
761
|
+
*
|
|
762
|
+
* Registered in `#evicting` like a spill, so a `get` for this index waits for
|
|
763
|
+
* the write instead of finding the piece on neither tier. Deliberately NOT
|
|
764
|
+
* recorded in `#spilledAt`: that clock measures how long an EVICTED piece
|
|
765
|
+
* stayed away, and a piece that was never resident has no such age.
|
|
766
|
+
*
|
|
767
|
+
* @param {number} index
|
|
768
|
+
* @param {Uint8Array} bytes
|
|
769
|
+
* @returns {Promise<void>}
|
|
770
|
+
*/
|
|
771
|
+
#writeThrough(index, bytes) {
|
|
772
|
+
// Copied, not viewed. `DiskTier.write` opens the file before it reads the
|
|
773
|
+
// bytes, so a view onto the caller's buffer could be written to in between
|
|
774
|
+
// and the file would get the wrong data. The spill path may pass a view
|
|
775
|
+
// because that memory is ours; this buffer belongs to the torrent client.
|
|
776
|
+
// It costs nothing extra: the piece was being copied into a shared buffer
|
|
777
|
+
// on this path before, and now it is copied here instead.
|
|
778
|
+
const copy = Buffer.from(bytes.subarray(0, Math.min(bytes.length, this.#lengthOf(index))));
|
|
779
|
+
const write = this.#disk.write(index, copy).then(
|
|
780
|
+
() => {
|
|
781
|
+
this.#evicting.delete(index);
|
|
782
|
+
this.#noteProgress();
|
|
783
|
+
},
|
|
784
|
+
(error) => {
|
|
785
|
+
this.#evicting.delete(index);
|
|
786
|
+
this.#noteProgress();
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
);
|
|
790
|
+
this.#evicting.set(index, write);
|
|
791
|
+
return write;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Record how long a piece stayed on disk before it was wanted again.
|
|
796
|
+
*
|
|
797
|
+
* A piece that comes back seconds after it left was evicted from a working
|
|
798
|
+
* set that does not fit, and the write and the read were both waste. Kept as
|
|
799
|
+
* a bounded window of ages because the figure wanted is a median, not a
|
|
800
|
+
* history.
|
|
801
|
+
*
|
|
802
|
+
* @param {number} index
|
|
803
|
+
* @returns {void}
|
|
804
|
+
*/
|
|
805
|
+
#noteRevival(index) {
|
|
806
|
+
const spilledAt = this.#spilledAt.get(index);
|
|
807
|
+
if (spilledAt === undefined) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
this.#spilledAt.delete(index);
|
|
811
|
+
this.#revivalAges.push(Date.now() - spilledAt);
|
|
812
|
+
if (this.#revivalAges.length > REVIVAL_AGE_SAMPLES) {
|
|
813
|
+
this.#revivalAges.shift();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Record what an eviction had to take.
|
|
819
|
+
*
|
|
820
|
+
* @param {boolean} protectionYielded - The victim was inside a window a
|
|
821
|
+
* reader had declared, and was taken anyway because nothing else was free.
|
|
822
|
+
* @param {number} distance - Pieces from the nearest declared window, -1 when
|
|
823
|
+
* no reader declared one.
|
|
824
|
+
* @returns {void}
|
|
825
|
+
*/
|
|
826
|
+
#noteEviction(protectionYielded, distance) {
|
|
827
|
+
if (protectionYielded) {
|
|
828
|
+
this.#counters.evictedProtected += 1;
|
|
829
|
+
}
|
|
830
|
+
if (distance >= 0) {
|
|
831
|
+
this.#counters.evictedDistanceSum += distance;
|
|
832
|
+
this.#counters.evictedWithDistance += 1;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
443
836
|
#wake() {
|
|
444
837
|
const waiting = this.#waiters;
|
|
445
838
|
this.#waiters = [];
|
|
@@ -456,7 +849,7 @@ export class SharedPieceStore {
|
|
|
456
849
|
return true;
|
|
457
850
|
}
|
|
458
851
|
|
|
459
|
-
const victim = this.#lru.
|
|
852
|
+
const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
|
|
460
853
|
if (victim === null) {
|
|
461
854
|
// Nothing may leave. Wait while the store is still MOVING — a spill
|
|
462
855
|
// completing, a piece admitted, a pin released — and give up when it has
|
|
@@ -491,6 +884,7 @@ export class SharedPieceStore {
|
|
|
491
884
|
this.#buffers.delete(victim);
|
|
492
885
|
this.#lru.remove(victim);
|
|
493
886
|
this.#outstandingPieces += 1;
|
|
887
|
+
this.#noteEviction(protectionYielded, distance);
|
|
494
888
|
|
|
495
889
|
try {
|
|
496
890
|
await this.#spill(victim, victimBuffer);
|
|
@@ -533,17 +927,141 @@ export class SharedPieceStore {
|
|
|
533
927
|
this.#counters.fromMemory += 1;
|
|
534
928
|
return already;
|
|
535
929
|
}
|
|
536
|
-
const target = this.#
|
|
537
|
-
|
|
930
|
+
const target = this.#takeBlock();
|
|
931
|
+
try {
|
|
932
|
+
// Only this piece's own length: the block is a full piece long and the
|
|
933
|
+
// last piece of a file is shorter, so reading the whole block would ask
|
|
934
|
+
// the file for bytes past its end.
|
|
935
|
+
await this.#disk.read(index, Buffer.from(target, 0, this.#lengthOf(index)));
|
|
936
|
+
} catch (error) {
|
|
937
|
+
this.#returnBlock(target);
|
|
938
|
+
throw error;
|
|
939
|
+
}
|
|
538
940
|
this.#registerPiece(index, target);
|
|
539
941
|
this.#counters.fromDisk += 1;
|
|
540
942
|
this.#counters.revivals += 1;
|
|
943
|
+
this.#noteRevival(index);
|
|
541
944
|
return target;
|
|
542
945
|
} finally {
|
|
543
946
|
release();
|
|
544
947
|
}
|
|
545
948
|
}
|
|
546
949
|
|
|
950
|
+
/**
|
|
951
|
+
* A block to hold one piece: the most recently freed one, or a new one.
|
|
952
|
+
*
|
|
953
|
+
* Every block is a full piece long, whatever piece will live in it. The last
|
|
954
|
+
* piece of a file is shorter, and it occupies a full block with only its own
|
|
955
|
+
* bytes meaningful — `#lengthOf` is what decides how much is ever read out.
|
|
956
|
+
* Uniform blocks are what makes them interchangeable at all.
|
|
957
|
+
*
|
|
958
|
+
* Most recently freed first, deliberately: a few blocks then carry the whole
|
|
959
|
+
* of a busy store's traffic and the rest age out of use, which is what makes
|
|
960
|
+
* {@link SharedPieceStore#sweepFreeBlocks} able to tell a spare block from a
|
|
961
|
+
* working one.
|
|
962
|
+
*
|
|
963
|
+
* @returns {SharedArrayBuffer}
|
|
964
|
+
*/
|
|
965
|
+
#takeBlock() {
|
|
966
|
+
const spare = this.#freeBlocks.pop();
|
|
967
|
+
if (spare !== undefined) {
|
|
968
|
+
this.#noteReuseGap(Date.now() - spare.freedAt);
|
|
969
|
+
return spare.buffer;
|
|
970
|
+
}
|
|
971
|
+
this.#blocksAllocated += 1;
|
|
972
|
+
return this.#watchForCollection(new SharedArrayBuffer(this.#chunkLength));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Put a block back for re-use, or give it up.
|
|
977
|
+
*
|
|
978
|
+
* Given up when the allowance has fallen below the number of blocks that
|
|
979
|
+
* exist: keeping it would hold memory the machine has just been shown to
|
|
980
|
+
* need. Otherwise it waits in the free list for the next piece.
|
|
981
|
+
*
|
|
982
|
+
* @param {SharedArrayBuffer | undefined} buffer
|
|
983
|
+
* @returns {void}
|
|
984
|
+
*/
|
|
985
|
+
#returnBlock(buffer) {
|
|
986
|
+
if (buffer === undefined) {
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (this.#closed || this.#blocksAllocated > this.#growthCeiling) {
|
|
990
|
+
// Never below zero: `close` gives up every block at once, and a spill
|
|
991
|
+
// that was already in flight resolves afterwards and arrives here.
|
|
992
|
+
if (this.#blocksAllocated > 0) {
|
|
993
|
+
this.#blocksAllocated -= 1;
|
|
994
|
+
this.#counters.blocksReleased += 1;
|
|
995
|
+
}
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
this.#freeBlocks.push({ buffer, freedAt: Date.now() });
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Record how long a block waited to be used again.
|
|
1003
|
+
*
|
|
1004
|
+
* @param {number} gapMs
|
|
1005
|
+
* @returns {void}
|
|
1006
|
+
*/
|
|
1007
|
+
#noteReuseGap(gapMs) {
|
|
1008
|
+
this.#reuseGaps.push(Math.max(0, gapMs));
|
|
1009
|
+
if (this.#reuseGaps.length > REUSE_GAP_SAMPLES) {
|
|
1010
|
+
this.#reuseGaps.shift();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* The longest a block has recently waited before being wanted again, or null
|
|
1016
|
+
* when no block has yet been re-used.
|
|
1017
|
+
*
|
|
1018
|
+
* This is the store's own working rhythm, measured rather than chosen: while
|
|
1019
|
+
* a film is being watched a block is taken again within milliseconds, because
|
|
1020
|
+
* one is taken for every piece that arrives. A block that has been sitting
|
|
1021
|
+
* longer than the longest of those waits is not part of the work.
|
|
1022
|
+
*
|
|
1023
|
+
* @returns {number | null}
|
|
1024
|
+
*/
|
|
1025
|
+
#reuseGapCeilingMs() {
|
|
1026
|
+
if (this.#reuseGaps.length === 0) {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
return Math.max(...this.#reuseGaps);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Give up blocks that have sat unused longer than this store's own working
|
|
1034
|
+
* rhythm.
|
|
1035
|
+
*
|
|
1036
|
+
* The case it is for: a torrent whose peers have gone. Its readers are still
|
|
1037
|
+
* attached, so nothing removes the torrent — the pool's idle timer needs a
|
|
1038
|
+
* refcount of zero and never starts. Its allowance falls to what those
|
|
1039
|
+
* readers declared, the pieces beyond it are written out, and their blocks
|
|
1040
|
+
* would otherwise wait in the free list for peers that may not return.
|
|
1041
|
+
*
|
|
1042
|
+
* @param {number} [now]
|
|
1043
|
+
* @returns {number} Blocks given up.
|
|
1044
|
+
*/
|
|
1045
|
+
sweepFreeBlocks(now = Date.now()) {
|
|
1046
|
+
const ceiling = this.#reuseGapCeilingMs();
|
|
1047
|
+
if (ceiling === null) {
|
|
1048
|
+
return 0;
|
|
1049
|
+
}
|
|
1050
|
+
const keeping = [];
|
|
1051
|
+
let released = 0;
|
|
1052
|
+
for (const spare of this.#freeBlocks) {
|
|
1053
|
+
if (now - spare.freedAt <= ceiling) {
|
|
1054
|
+
keeping.push(spare);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
this.#blocksAllocated -= 1;
|
|
1058
|
+
this.#counters.blocksReleased += 1;
|
|
1059
|
+
released += 1;
|
|
1060
|
+
}
|
|
1061
|
+
this.#freeBlocks = keeping;
|
|
1062
|
+
return released;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
547
1065
|
/**
|
|
548
1066
|
* Count this buffer as one this thread will have to let go of, and notice
|
|
549
1067
|
* when the collector takes it. See {@link pieceBufferCollection}.
|
|
@@ -566,14 +1084,14 @@ export class SharedPieceStore {
|
|
|
566
1084
|
*/
|
|
567
1085
|
#copyIntoNewBuffer(index, bytes) {
|
|
568
1086
|
const length = this.#lengthOf(index);
|
|
569
|
-
const
|
|
570
|
-
const view = Buffer.from(
|
|
1087
|
+
const block = this.#takeBlock();
|
|
1088
|
+
const view = Buffer.from(block, 0, length);
|
|
571
1089
|
if (bytes.copy) {
|
|
572
1090
|
bytes.copy(view, 0, 0, length);
|
|
573
1091
|
} else {
|
|
574
1092
|
view.set(bytes.subarray(0, length), 0);
|
|
575
1093
|
}
|
|
576
|
-
return
|
|
1094
|
+
return block;
|
|
577
1095
|
}
|
|
578
1096
|
|
|
579
1097
|
/**
|
|
@@ -593,6 +1111,7 @@ export class SharedPieceStore {
|
|
|
593
1111
|
await spill.catch(() => undefined);
|
|
594
1112
|
}
|
|
595
1113
|
this.#disk.forget(index);
|
|
1114
|
+
this.#spilledAt.delete(index);
|
|
596
1115
|
}
|
|
597
1116
|
|
|
598
1117
|
put(index, bytes, callback = () => undefined) {
|
|
@@ -606,13 +1125,46 @@ export class SharedPieceStore {
|
|
|
606
1125
|
// needed and none is claimed. A fresh buffer rather than a write into the
|
|
607
1126
|
// old one, so a reader holding the old reference cannot see a torn write.
|
|
608
1127
|
if (this.#buffers.has(index)) {
|
|
1128
|
+
const previous = this.#buffers.get(index);
|
|
1129
|
+
// A fresh block rather than a write into the old one, so a reader
|
|
1130
|
+
// holding the old reference cannot see a torn write.
|
|
609
1131
|
this.#buffers.set(index, this.#copyIntoNewBuffer(index, bytes));
|
|
610
1132
|
this.#lru.touch(index);
|
|
1133
|
+
// And the old block goes back for re-use only if nobody is reading it.
|
|
1134
|
+
// A pinned piece has a view onto its memory somewhere; that block is
|
|
1135
|
+
// given up instead, and the pool allocates another when it needs one.
|
|
1136
|
+
if (this.#lru.isPinned(index)) {
|
|
1137
|
+
if (this.#blocksAllocated > 0) {
|
|
1138
|
+
this.#blocksAllocated -= 1;
|
|
1139
|
+
this.#counters.blocksReleased += 1;
|
|
1140
|
+
}
|
|
1141
|
+
} else {
|
|
1142
|
+
this.#returnBlock(previous);
|
|
1143
|
+
}
|
|
611
1144
|
await this.#forgetOnDisk(index);
|
|
612
1145
|
this.#noteProgress();
|
|
613
1146
|
return;
|
|
614
1147
|
}
|
|
615
1148
|
|
|
1149
|
+
const declared = this.#lru.wants(index);
|
|
1150
|
+
if (declared) {
|
|
1151
|
+
this.#counters.admittedInsideWindow += 1;
|
|
1152
|
+
} else {
|
|
1153
|
+
this.#counters.admittedOutsideWindow += 1;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// A piece no reader has declared, arriving at a store with no room, goes
|
|
1157
|
+
// straight to disk. It costs the same one write it would have cost when
|
|
1158
|
+
// the next arrival evicted it, and it saves pushing out a piece a reader
|
|
1159
|
+
// is about to read. Only when SOMETHING is declared: before the first
|
|
1160
|
+
// read there is no basis for calling a piece unwanted.
|
|
1161
|
+
if (!declared && this.#lru.protectedCount > 0 && this.#isFullNow()) {
|
|
1162
|
+
this.#counters.admittedToDisk += 1;
|
|
1163
|
+
await this.#writeThrough(index, bytes);
|
|
1164
|
+
this.#noteProgress();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
616
1168
|
const release = await this.#claimSlot();
|
|
617
1169
|
try {
|
|
618
1170
|
this.#registerPiece(index, this.#copyIntoNewBuffer(index, bytes));
|
|
@@ -707,6 +1259,10 @@ export class SharedPieceStore {
|
|
|
707
1259
|
this.#closed = true;
|
|
708
1260
|
liveStores.delete(this);
|
|
709
1261
|
this.#buffers.clear();
|
|
1262
|
+
this.#spilledAt.clear();
|
|
1263
|
+
this.#counters.blocksReleased += this.#blocksAllocated;
|
|
1264
|
+
this.#blocksAllocated = 0;
|
|
1265
|
+
this.#freeBlocks = [];
|
|
710
1266
|
// Whoever is waiting for a slot is woken and finds the store closed, which
|
|
711
1267
|
// is an error they can report. Left asleep they simply never returned.
|
|
712
1268
|
this.#wake();
|
|
@@ -717,6 +1273,10 @@ export class SharedPieceStore {
|
|
|
717
1273
|
this.#closed = true;
|
|
718
1274
|
liveStores.delete(this);
|
|
719
1275
|
this.#buffers.clear();
|
|
1276
|
+
this.#spilledAt.clear();
|
|
1277
|
+
this.#counters.blocksReleased += this.#blocksAllocated;
|
|
1278
|
+
this.#blocksAllocated = 0;
|
|
1279
|
+
this.#freeBlocks = [];
|
|
720
1280
|
this.#wake();
|
|
721
1281
|
this.#disk.destroy().then(() => callback(null), (error) => callback(error));
|
|
722
1282
|
}
|