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.
- package/README.md +97 -57
- package/bin/teyyare.js +36 -3
- package/package.json +6 -2
- package/src/core/protocol.js +39 -6
- package/src/index.js +119 -57
- package/src/receiver/pipeline.js +272 -57
- package/src/sender/pipeline.js +51 -9
- package/test/e2e/e2e.js +276 -0
- package/test/integration/pull.test.js +51 -0
- package/test/integration/resume.test.js +62 -0
- package/test/integration/single-file.test.js +85 -0
- package/test/unit/protocol.test.js +20 -0
package/src/receiver/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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({
|
|
335
|
+
pending.push({ meta, val: new Uint8Array(val) });
|
|
269
336
|
return;
|
|
270
337
|
}
|
|
271
338
|
|
|
272
|
-
await this.
|
|
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
|
-
|
|
277
|
-
|
|
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.
|
|
280
|
-
this.metrics.chunksVerified++;
|
|
404
|
+
this.metrics.checksumTimeMs += (Date.now() - t0);
|
|
281
405
|
}
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
406
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 (
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
file
|
|
504
|
+
// 4. Trigger debounced state save
|
|
505
|
+
this._triggerStateSave(file);
|
|
331
506
|
|
|
332
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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)
|
|
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) {
|
package/src/sender/pipeline.js
CHANGED
|
@@ -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 ||
|
|
108
|
-
this.maxConcurrentFlushes = options.maxConcurrentFlushes || Math.min(this.maxImmutableBuffers,
|
|
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: () =>
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
267
|
-
|
|
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
|
}
|