teyyare 0.2.7 → 0.2.9

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.
@@ -9,7 +9,7 @@ import { existsSync, createReadStream } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import crypto from 'node:crypto';
11
11
  import { crc32 } from '../core/crc32.js';
12
- import { Opcodes, unpackChunkMeta, unpackFileBegin, unpackTransferBegin, unpackFileEnd, unpackPullError } from '../core/protocol.js';
12
+ import { Opcodes, unpackChunkMeta, unpackFileBegin, unpackTransferBegin, unpackFileEnd, unpackTransferEnd, unpackPullError } from '../core/protocol.js';
13
13
  import { unpackGeneration } from '../core/generation.js';
14
14
  import { FileResumeState } from '../resume/state.js';
15
15
 
@@ -38,11 +38,15 @@ export class ReceiverPipeline {
38
38
  this.activeFiles = new Map(); // fileId -> { destPath, partPath, statePath, fileHandle, resumeState, ... }
39
39
  this.completedFiles = new Set();
40
40
  this._pendingChunks = new Map(); // fileId -> Array<{ key, val }>
41
+ this._pendingFileEnds = new Map(); // fileId -> unpacked FileEnd meta
41
42
  this._pendingTransferEnd = false;
42
43
  this.transferFinished = false;
43
44
  this.transferError = null;
44
45
  this._finishResolvers = [];
45
46
  this._errorRejecters = [];
47
+ this.currentTransferId = null;
48
+ this.expectedFileCount = null;
49
+ this._completedFileCount = 0;
46
50
 
47
51
  // Processing queue to ensure strictly ordered, serialized generation processing
48
52
  this._batchQueue = [];
@@ -126,7 +130,8 @@ export class ReceiverPipeline {
126
130
  const entries = unpackGeneration(metaBuf, payloadBuf);
127
131
  const count = entries.length;
128
132
 
129
- for (let i = 0; i < count; i++) {
133
+ let i = 0;
134
+ while (i < count) {
130
135
  const { type, key, val } = entries[i];
131
136
 
132
137
  if (this.metrics) {
@@ -135,34 +140,59 @@ export class ReceiverPipeline {
135
140
 
136
141
  switch (type) {
137
142
  case Opcodes.TRANSFER_BEGIN: {
138
- this.transferFinished = false;
139
- this.transferError = null;
140
- this._pendingTransferEnd = false;
141
143
  const tinfo = unpackTransferBegin(key);
144
+ if (this.currentTransferId !== tinfo.transferId) {
145
+ if (!this.currentTransferId) {
146
+ this.currentTransferId = tinfo.transferId;
147
+ } else {
148
+ this.currentTransferId = tinfo.transferId;
149
+ this.transferFinished = false;
150
+ this.transferError = null;
151
+ this._pendingTransferEnd = false;
152
+ this.completedFiles.clear();
153
+ this._completedFileCount = 0;
154
+ }
155
+ }
156
+ this.expectedFileCount = tinfo.fileCount;
157
+ this.expectedTotalBytes = tinfo.totalBytes;
158
+ this._checkTransferCompletion();
159
+ i++;
142
160
  break;
143
161
  }
144
162
 
145
163
  case Opcodes.FILE_BEGIN: {
146
164
  await this._handleFileBegin(key);
165
+ i++;
147
166
  break;
148
167
  }
149
168
 
150
169
  case Opcodes.CHUNK: {
151
- await this._handleChunk(key, val);
170
+ const chunkBatch = [];
171
+ while (i < count && entries[i].type === Opcodes.CHUNK) {
172
+ chunkBatch.push(entries[i]);
173
+ if (chunkBatch.length > 1 && this.metrics) {
174
+ this.metrics.mutations++;
175
+ }
176
+ i++;
177
+ }
178
+ await this._handleChunks(chunkBatch);
152
179
  break;
153
180
  }
154
181
 
155
182
  case Opcodes.FILE_END: {
156
183
  await this._handleFileEnd(key);
184
+ i++;
157
185
  break;
158
186
  }
159
187
 
160
188
  case Opcodes.TRANSFER_END: {
161
- if (this.activeFiles.size === 0) {
162
- this._finishTransfer();
163
- } else {
164
- this._pendingTransferEnd = true;
189
+ const tend = unpackTransferEnd(key);
190
+ if (!this.currentTransferId) {
191
+ this.currentTransferId = tend.transferId;
165
192
  }
193
+ this._pendingTransferEnd = true;
194
+ this._checkTransferCompletion();
195
+ i++;
166
196
  break;
167
197
  }
168
198
 
@@ -177,10 +207,24 @@ export class ReceiverPipeline {
177
207
  this._errorRejecters = [];
178
208
  return;
179
209
  }
210
+
211
+ default:
212
+ i++;
213
+ break;
180
214
  }
181
215
  }
182
216
  }
183
217
 
218
+ _checkTransferCompletion() {
219
+ if (!this._pendingTransferEnd) return;
220
+ if (this.activeFiles.size > 0) return;
221
+ if (this._pendingFileEnds.size > 0) return;
222
+ if (this.expectedFileCount !== null && this._completedFileCount < this.expectedFileCount) {
223
+ return;
224
+ }
225
+ this._finishTransfer();
226
+ }
227
+
184
228
  _finishTransfer() {
185
229
  this.transferFinished = true;
186
230
  for (const resolve of this._finishResolvers) {
@@ -204,12 +248,22 @@ export class ReceiverPipeline {
204
248
  resumeState = new FileResumeState({
205
249
  fileId: info.fileId,
206
250
  fileSize: Number(info.fileSize),
251
+ chunkSize: info.chunkSize || 1048576,
207
252
  sha256: info.sha256
208
253
  });
209
254
  await resumeState.save(statePath);
255
+ } else if (info.chunkSize && resumeState.chunkSize !== info.chunkSize) {
256
+ resumeState.chunkSize = info.chunkSize;
210
257
  }
211
258
 
212
259
  const fileHandle = await fs.open(partPath, 'a+');
260
+ const targetSize = Number(info.fileSize);
261
+ if (targetSize > 0) {
262
+ const currentStat = await fileHandle.stat();
263
+ if (currentStat.size < targetSize) {
264
+ await fileHandle.truncate(targetSize);
265
+ }
266
+ }
213
267
 
214
268
  const file = {
215
269
  fileId: info.fileId,
@@ -222,6 +276,9 @@ export class ReceiverPipeline {
222
276
  fileSize: Number(info.fileSize),
223
277
  sha256: info.sha256,
224
278
  chunksWrittenSinceSave: 0,
279
+ lastStateSaveTime: Date.now(),
280
+ _stateSaving: false,
281
+ _pendingStateSave: false,
225
282
  pendingEnd: false,
226
283
  endMeta: null
227
284
  };
@@ -246,9 +303,19 @@ export class ReceiverPipeline {
246
303
  const pending = this._pendingChunks.get(info.fileId);
247
304
  if (pending && pending.length > 0) {
248
305
  this._pendingChunks.delete(info.fileId);
249
- for (const item of pending) {
250
- const meta = unpackChunkMeta(item.key);
251
- await this._writeChunk(file, meta, item.val);
306
+ await this._writeChunkRun(file, pending);
307
+ }
308
+
309
+ // Check if FILE_END arrived out-of-order before FILE_BEGIN
310
+ const pendingEnd = this._pendingFileEnds.get(info.fileId);
311
+ if (pendingEnd) {
312
+ this._pendingFileEnds.delete(info.fileId);
313
+ if (pendingEnd.sha256) file.sha256 = pendingEnd.sha256;
314
+ if (file.fileSize === 0 || file.resumeState.isComplete()) {
315
+ await this._finalizeFile(file);
316
+ } else {
317
+ file.pendingEnd = true;
318
+ file.endMeta = pendingEnd;
252
319
  }
253
320
  }
254
321
  }
@@ -265,56 +332,164 @@ export class ReceiverPipeline {
265
332
  pending = [];
266
333
  this._pendingChunks.set(meta.fileId, pending);
267
334
  }
268
- pending.push({ key, val: new Uint8Array(val) });
335
+ pending.push({ meta, val: new Uint8Array(val) });
269
336
  return;
270
337
  }
271
338
 
272
- await this._writeChunk(file, meta, val);
339
+ await this._writeChunkRun(file, [{ meta, val }]);
340
+ }
341
+
342
+ async _handleChunks(items) {
343
+ if (!items || items.length === 0) return;
344
+
345
+ // Group items by fileId
346
+ const groups = new Map();
347
+ for (const item of items) {
348
+ const meta = item.meta || unpackChunkMeta(item.key);
349
+ let list = groups.get(meta.fileId);
350
+ if (!list) {
351
+ list = [];
352
+ groups.set(meta.fileId, list);
353
+ }
354
+ list.push({ meta, val: item.val });
355
+ }
356
+
357
+ for (const [fileId, chunkList] of groups.entries()) {
358
+ const file = this.activeFiles.get(fileId);
359
+ if (!file) {
360
+ if (this.completedFiles.has(fileId)) {
361
+ continue;
362
+ }
363
+ let pending = this._pendingChunks.get(fileId);
364
+ if (!pending) {
365
+ pending = [];
366
+ this._pendingChunks.set(fileId, pending);
367
+ }
368
+ for (const c of chunkList) {
369
+ pending.push({ meta: c.meta, val: new Uint8Array(c.val) });
370
+ }
371
+ continue;
372
+ }
373
+
374
+ await this._writeChunkRun(file, chunkList);
375
+ }
273
376
  }
274
377
 
275
378
  async _writeChunk(file, meta, val) {
276
- // Idempotency / resume hit check
277
- if (file.resumeState.isVerified(meta.chunkIndex)) {
379
+ return this._writeChunkRun(file, [{ meta, val }]);
380
+ }
381
+
382
+ async _writeChunkRun(file, items) {
383
+ if (!items || items.length === 0) return;
384
+
385
+ // 1. Unpack, verify CRC, and filter out resume hits
386
+ const toWrite = [];
387
+ for (const item of items) {
388
+ const meta = item.meta || unpackChunkMeta(item.key);
389
+ const val = item.val;
390
+
391
+ // Idempotency / resume hit check
392
+ if (file.resumeState.isVerified(meta.chunkIndex)) {
393
+ if (this.metrics) {
394
+ this.metrics.resumeHits++;
395
+ this.metrics.chunksVerified++;
396
+ }
397
+ continue;
398
+ }
399
+
400
+ // Per-chunk corruption detection
401
+ const t0 = Date.now();
402
+ const computedCrc = crc32(val);
278
403
  if (this.metrics) {
279
- this.metrics.resumeHits++;
280
- this.metrics.chunksVerified++;
404
+ this.metrics.checksumTimeMs += (Date.now() - t0);
281
405
  }
282
- return;
283
- }
284
406
 
285
- // Per-chunk corruption detection
286
- const t0 = Date.now();
287
- const computedCrc = crc32(val);
288
- if (this.metrics) {
289
- this.metrics.checksumTimeMs += (Date.now() - t0);
407
+ if (computedCrc !== meta.checksum) {
408
+ throw new Error(
409
+ `Chunk ${meta.chunkIndex} corruption: CRC 0x${computedCrc.toString(16)} !== 0x${meta.checksum.toString(16)}`
410
+ );
411
+ }
412
+
413
+ toWrite.push({ meta, val });
290
414
  }
291
415
 
292
- if (computedCrc !== meta.checksum) {
293
- throw new Error(
294
- `Chunk ${meta.chunkIndex} corruption: CRC 0x${computedCrc.toString(16)} !== 0x${meta.checksum.toString(16)}`
295
- );
416
+ if (toWrite.length === 0) {
417
+ if (file.pendingEnd && (file.fileSize === 0 || file.resumeState.isComplete())) {
418
+ await this._finalizeFile(file);
419
+ }
420
+ return;
296
421
  }
297
422
 
298
- // Artificial write delay for slow-receiver backpressure tests
299
- if (this.writeDelayMs > 0) {
300
- await new Promise((r) => setTimeout(r, this.writeDelayMs));
423
+ // 2. Sort by offset to form maximal contiguous runs
424
+ toWrite.sort((a, b) => (a.meta.offset < b.meta.offset ? -1 : (a.meta.offset > b.meta.offset ? 1 : 0)));
425
+
426
+ let currentRun = [toWrite[0]];
427
+ let currentStartOffset = Number(toWrite[0].meta.offset);
428
+ let expectedNextOffset = currentStartOffset + toWrite[0].meta.length;
429
+
430
+ const flushRun = async (run, startOffset) => {
431
+ // Artificial write delay for slow-receiver backpressure tests
432
+ if (this.writeDelayMs > 0) {
433
+ await new Promise((r) => setTimeout(r, this.writeDelayMs));
434
+ }
435
+
436
+ const writeStart = Date.now();
437
+ let runBytes = 0;
438
+ for (let i = 0; i < run.length; i++) {
439
+ runBytes += run[i].meta.length;
440
+ }
441
+
442
+ if (run.length === 1) {
443
+ const item = run[0];
444
+ await file.fileHandle.write(item.val, 0, item.meta.length, Number(item.meta.offset));
445
+ } else {
446
+ const bufs = new Array(run.length);
447
+ for (let i = 0; i < run.length; i++) {
448
+ bufs[i] = run[i].val;
449
+ }
450
+ await file.fileHandle.writev(bufs, startOffset);
451
+ }
452
+
453
+ if (this.metrics) {
454
+ this.metrics.diskWriteTimeMs += (Date.now() - writeStart);
455
+ this.metrics.fileBytesWritten += runBytes;
456
+ this.metrics.chunksCreated += run.length;
457
+ this.metrics.chunksVerified += run.length;
458
+ }
459
+
460
+ for (let i = 0; i < run.length; i++) {
461
+ file.resumeState.markVerified(run[i].meta.chunkIndex);
462
+ file.chunksWrittenSinceSave++;
463
+ }
464
+ };
465
+
466
+ for (let i = 1; i < toWrite.length; i++) {
467
+ const item = toWrite[i];
468
+ const itemOffset = Number(item.meta.offset);
469
+
470
+ if (itemOffset === expectedNextOffset) {
471
+ currentRun.push(item);
472
+ expectedNextOffset = itemOffset + item.meta.length;
473
+ } else {
474
+ await flushRun(currentRun, currentStartOffset);
475
+ currentRun = [item];
476
+ currentStartOffset = itemOffset;
477
+ expectedNextOffset = itemOffset + item.meta.length;
478
+ }
301
479
  }
302
480
 
303
- // Direct random-access write at specific byte offset
304
- const writeStart = Date.now();
305
- await file.fileHandle.write(val, 0, meta.length, Number(meta.offset));
306
- if (this.metrics) {
307
- this.metrics.diskWriteTimeMs += (Date.now() - writeStart);
308
- this.metrics.fileBytesWritten += meta.length;
309
- this.metrics.chunksCreated++;
310
- this.metrics.chunksVerified++;
481
+ if (currentRun.length > 0) {
482
+ await flushRun(currentRun, currentStartOffset);
311
483
  }
312
484
 
485
+ // 3. Update progress reporter
313
486
  if (this.progress) {
314
487
  const diskTimeSec = Math.max(0.001, (this.metrics ? this.metrics.diskWriteTimeMs : 1) / 1000);
315
488
  const writtenMB = (this.metrics ? this.metrics.fileBytesWritten : 0) / (1024 * 1024);
316
489
  const diskMBs = Number((writtenMB / diskTimeSec).toFixed(1));
317
- file.maxChunkSize = Math.max(file.maxChunkSize || 0, meta.length);
490
+ for (const item of toWrite) {
491
+ file.maxChunkSize = Math.max(file.maxChunkSize || 0, item.meta.length);
492
+ }
318
493
  const realTotalChunks = file.maxChunkSize > 0 && file.fileSize > 0 ? Math.ceil(file.fileSize / file.maxChunkSize) : 0;
319
494
  this.progress.update({
320
495
  fileName: file.relativePath,
@@ -326,27 +501,56 @@ export class ReceiverPipeline {
326
501
  });
327
502
  }
328
503
 
329
- file.resumeState.markVerified(meta.chunkIndex);
330
- file.chunksWrittenSinceSave++;
504
+ // 4. Trigger debounced state save
505
+ this._triggerStateSave(file);
331
506
 
332
- if (file.chunksWrittenSinceSave >= 32) {
333
- file.chunksWrittenSinceSave = 0;
334
- await file.resumeState.save(file.statePath);
335
- }
336
-
337
- // If FILE_END arrived earlier while chunks were still arriving
507
+ // 5. If FILE_END arrived earlier while chunks were still arriving
338
508
  if (file.pendingEnd && (file.fileSize === 0 || file.resumeState.isComplete())) {
339
509
  await this._finalizeFile(file);
340
- if (this._pendingTransferEnd && this.activeFiles.size === 0) {
341
- this._finishTransfer();
342
- }
510
+ }
511
+ }
512
+
513
+ _triggerStateSave(file, force = false) {
514
+ const now = Date.now();
515
+ const elapsed = now - file.lastStateSaveTime;
516
+ const shouldSave = force || file.chunksWrittenSinceSave >= 128 || (file.chunksWrittenSinceSave >= 16 && elapsed >= 1500);
517
+ if (!shouldSave) return;
518
+
519
+ if (file._stateSaving) {
520
+ file._pendingStateSave = true;
521
+ return;
522
+ }
523
+
524
+ file.chunksWrittenSinceSave = 0;
525
+ file.lastStateSaveTime = now;
526
+ file._stateSaving = true;
527
+
528
+ file.resumeState.save(file.statePath)
529
+ .catch(() => {})
530
+ .finally(() => {
531
+ file._stateSaving = false;
532
+ if (file._pendingStateSave) {
533
+ file._pendingStateSave = false;
534
+ this._triggerStateSave(file, true);
535
+ }
536
+ });
537
+ }
538
+
539
+ async _drainFileStateSave(file) {
540
+ while (file._stateSaving) {
541
+ await new Promise((r) => setTimeout(r, 5));
343
542
  }
344
543
  }
345
544
 
346
545
  async _handleFileEnd(key) {
347
546
  const meta = unpackFileEnd(key);
348
547
  const file = this.activeFiles.get(meta.fileId);
349
- if (!file) return;
548
+ if (!file) {
549
+ if (!this.completedFiles.has(meta.fileId)) {
550
+ this._pendingFileEnds.set(meta.fileId, meta);
551
+ }
552
+ return;
553
+ }
350
554
 
351
555
  if (meta.sha256) {
352
556
  file.sha256 = meta.sha256;
@@ -354,9 +558,6 @@ export class ReceiverPipeline {
354
558
 
355
559
  if (file.fileSize === 0 || file.resumeState.isComplete()) {
356
560
  await this._finalizeFile(file);
357
- if (this._pendingTransferEnd && this.activeFiles.size === 0) {
358
- this._finishTransfer();
359
- }
360
561
  } else {
361
562
  file.pendingEnd = true;
362
563
  file.endMeta = meta;
@@ -364,6 +565,9 @@ export class ReceiverPipeline {
364
565
  }
365
566
 
366
567
  async _finalizeFile(file) {
568
+ // Await any in-flight background state save before final save
569
+ await this._drainFileStateSave(file);
570
+
367
571
  // Save final state before rename
368
572
  await file.resumeState.save(file.statePath);
369
573
 
@@ -388,10 +592,20 @@ export class ReceiverPipeline {
388
592
  this.activeFiles.delete(file.fileId);
389
593
  this.completedFiles.add(file.fileId);
390
594
  this.completedFiles.add(file.destPath);
595
+ this._completedFileCount++;
596
+
597
+ this._checkTransferCompletion();
391
598
  }
392
599
 
393
600
  async close() {
394
601
  for (const [fileId, file] of this.activeFiles.entries()) {
602
+ await this._drainFileStateSave(file);
603
+ if (file.chunksWrittenSinceSave > 0) {
604
+ try {
605
+ await file.resumeState.save(file.statePath);
606
+ } catch {}
607
+ file.chunksWrittenSinceSave = 0;
608
+ }
395
609
  if (file.fileHandle) {
396
610
  try {
397
611
  await file.fileHandle.close();
@@ -401,6 +615,7 @@ export class ReceiverPipeline {
401
615
  }
402
616
  this.activeFiles.clear();
403
617
  this._pendingChunks.clear();
618
+ this._pendingFileEnds.clear();
404
619
  }
405
620
 
406
621
  async _computeFileSha256(filePath) {
@@ -20,6 +20,25 @@ import {
20
20
  import { extractGenerationBuffers } from '../core/generation.js';
21
21
  import { FileResumeState } from '../resume/state.js';
22
22
 
23
+ /**
24
+ * Reusable bump allocator that returns its ArrayBuffer to a pool upon reset/release.
25
+ */
26
+ class PooledBumpAllocator extends BumpAllocator {
27
+ constructor(capacity, pool = null) {
28
+ super(capacity);
29
+ this._pool = pool;
30
+ }
31
+
32
+ reset() {
33
+ super.reset();
34
+ if (this._pool && this._pool.length < 16 && !this._pool.includes(this)) {
35
+ this._pool.push(this);
36
+ }
37
+ }
38
+ }
39
+
40
+ const defaultAllocatorPool = [];
41
+
23
42
  class RaptiyeSink extends Sink {
24
43
  /**
25
44
  * @param {import('raptiye').ReplicationPipeline} replication
@@ -104,11 +123,13 @@ export class SenderPipeline {
104
123
  this.chunkSize = options.chunkSize || 1024 * 1024;
105
124
  this.maxEntries = options.maxEntries || 8;
106
125
  this.maxBytes = options.maxBytes || 8 * 1024 * 1024;
107
- this.maxImmutableBuffers = options.maxImmutableBuffers || 8;
108
- this.maxConcurrentFlushes = options.maxConcurrentFlushes || Math.min(this.maxImmutableBuffers, 8);
126
+ this.maxImmutableBuffers = options.maxImmutableBuffers || 32;
127
+ this.maxConcurrentFlushes = options.maxConcurrentFlushes || Math.min(this.maxImmutableBuffers, 32);
109
128
  this.metrics = metrics;
110
129
  this.progress = progress;
111
130
  this.knownResumeState = knownResumeState;
131
+ this._allocatorPool = options.allocatorPool || defaultAllocatorPool;
132
+ this._readScratch = null;
112
133
 
113
134
  this.transferId = crypto.randomUUID();
114
135
 
@@ -119,10 +140,22 @@ export class SenderPipeline {
119
140
  this.sink = new RaptiyeSink(this.replication, this.metrics, this.progress);
120
141
  this.engine = new MutationEngine({
121
142
  sink: this.sink,
122
- createStore: () => new ArenaStore({
123
- chunkSize: Math.max(64, this.maxEntries),
124
- allocator: new BumpAllocator(arenaCapacity)
125
- }),
143
+ createStore: () => {
144
+ let allocator = null;
145
+ if (this._allocatorPool && this._allocatorPool.length > 0) {
146
+ const idx = this._allocatorPool.findIndex((a) => a.capacity >= arenaCapacity);
147
+ if (idx !== -1) {
148
+ allocator = this._allocatorPool.splice(idx, 1)[0];
149
+ }
150
+ }
151
+ if (!allocator) {
152
+ allocator = new PooledBumpAllocator(arenaCapacity, this._allocatorPool);
153
+ }
154
+ return new ArenaStore({
155
+ chunkSize: Math.max(64, this.maxEntries),
156
+ allocator
157
+ });
158
+ },
126
159
  maxEntries: this.maxEntries,
127
160
  maxBytes: this.maxBytes,
128
161
  maxImmutableBuffers: this.maxImmutableBuffers,
@@ -148,6 +181,7 @@ export class SenderPipeline {
148
181
 
149
182
  async abort() {
150
183
  this.aborted = true;
184
+ this._readScratch = null;
151
185
  for (const waiter of this._abortWaiters) {
152
186
  waiter();
153
187
  }
@@ -233,7 +267,8 @@ export class SenderPipeline {
233
267
  fileId: file.fileId,
234
268
  fileSize: file.size,
235
269
  path: file.relativePath,
236
- sha256: file.sha256 || ''
270
+ sha256: file.sha256 || '',
271
+ chunkSize: this.chunkSize
237
272
  });
238
273
  await this._append(Opcodes.FILE_BEGIN, fbegin);
239
274
 
@@ -263,8 +298,14 @@ export class SenderPipeline {
263
298
  continue;
264
299
  }
265
300
 
266
- // Direct read into fresh chunk buffer
267
- const chunkBuf = new Uint8Array(chunkLen);
301
+ // Direct read into reusable chunk buffer (Muttafa copies synchronously into its arena on append)
302
+ if (!this._readScratch || this._readScratch.byteLength < chunkLen) {
303
+ this._readScratch = new Uint8Array(Math.max(this.chunkSize, chunkLen));
304
+ }
305
+ const chunkBuf = (chunkLen === this._readScratch.byteLength)
306
+ ? this._readScratch
307
+ : this._readScratch.subarray(0, chunkLen);
308
+
268
309
  const readStart = Date.now();
269
310
  await handle.read(chunkBuf, 0, chunkLen, Number(offset));
270
311
  if (this.metrics) {
@@ -339,5 +380,6 @@ export class SenderPipeline {
339
380
  });
340
381
  this.progress.stop();
341
382
  }
383
+ this._readScratch = null;
342
384
  }
343
385
  }