teyyare 0.1.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/README.md +180 -0
- package/bench/ablation.bench.js +229 -0
- package/bench/matrix.bench.js +86 -0
- package/bench/run-all.js +19 -0
- package/bin/teyyare.js +124 -0
- package/package.json +27 -0
- package/src/cli/progress.js +125 -0
- package/src/core/protocol.js +163 -0
- package/src/index.js +139 -0
- package/src/manifest/manifest.js +84 -0
- package/src/metrics/collector.js +119 -0
- package/src/receiver/pipeline.js +234 -0
- package/src/resume/state.js +125 -0
- package/src/sender/pipeline.js +288 -0
- package/test/integration/backpressure.test.js +63 -0
- package/test/integration/chaos.test.js +104 -0
- package/test/integration/directory.test.js +75 -0
- package/test/integration/resume.test.js +97 -0
- package/test/integration/single-file.test.js +64 -0
- package/test/unit/ownership.test.js +74 -0
- package/test/unit/protocol.test.js +73 -0
- package/test/unit/state.test.js +49 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teyyare Receiver Engine.
|
|
3
|
+
* Consumes delivered Muttafa generations from Raptiye, executes direct random-access writes,
|
|
4
|
+
* manages resume state, performs whole-file verification, and atomically finalizes files.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import { existsSync, createReadStream } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { FrozenArenaStore } from 'muttafa';
|
|
12
|
+
import { crc32 } from 'raptiye';
|
|
13
|
+
import { Opcodes, unpackChunkMeta, unpackFileBegin, unpackTransferBegin, unpackFileEnd } from '../core/protocol.js';
|
|
14
|
+
import { FileResumeState } from '../resume/state.js';
|
|
15
|
+
|
|
16
|
+
export class ReceiverPipeline {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} config
|
|
19
|
+
* @param {import('raptiye').ReplicationPipeline} config.replication
|
|
20
|
+
* @param {string} config.destinationDir
|
|
21
|
+
* @param {import('../metrics/collector.js').MetricsCollector} [config.metrics]
|
|
22
|
+
* @param {number} [config.writeDelayMs=0] For artificial slow-receiver backpressure benchmarking
|
|
23
|
+
*/
|
|
24
|
+
constructor({
|
|
25
|
+
replication,
|
|
26
|
+
destinationDir,
|
|
27
|
+
metrics = null,
|
|
28
|
+
writeDelayMs = 0
|
|
29
|
+
}) {
|
|
30
|
+
this.replication = replication;
|
|
31
|
+
this.destinationDir = path.resolve(destinationDir);
|
|
32
|
+
this.metrics = metrics;
|
|
33
|
+
this.writeDelayMs = writeDelayMs;
|
|
34
|
+
|
|
35
|
+
this.activeFiles = new Map(); // fileId -> { destPath, partPath, statePath, fileHandle, resumeState, ... }
|
|
36
|
+
this.completedFiles = new Set();
|
|
37
|
+
this.transferFinished = false;
|
|
38
|
+
this._finishResolvers = [];
|
|
39
|
+
|
|
40
|
+
// Hook up Raptiye batch handler
|
|
41
|
+
this.replication.onBatch(async (msg) => {
|
|
42
|
+
await this._processGenerationBatch(msg);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async waitForCompletion() {
|
|
47
|
+
if (this.transferFinished) return;
|
|
48
|
+
return new Promise((resolve) => this._finishResolvers.push(resolve));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async _processGenerationBatch(msg) {
|
|
52
|
+
if (!msg.buffers || msg.buffers.length < 2) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [metaBuf, payloadBuf] = msg.buffers;
|
|
57
|
+
if (this.metrics) {
|
|
58
|
+
this.metrics.generations++;
|
|
59
|
+
this.metrics.generationBytes += (metaBuf.byteLength + payloadBuf.byteLength);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Zero-copy reconstruction of Muttafa generation
|
|
63
|
+
const store = FrozenArenaStore.fromBuffers(metaBuf, payloadBuf);
|
|
64
|
+
const count = store.length;
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < count; i++) {
|
|
67
|
+
const type = store.getType(i);
|
|
68
|
+
const key = store.getKey(i);
|
|
69
|
+
const val = store.getValue(i);
|
|
70
|
+
|
|
71
|
+
if (this.metrics) {
|
|
72
|
+
this.metrics.mutations++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
switch (type) {
|
|
76
|
+
case Opcodes.TRANSFER_BEGIN: {
|
|
77
|
+
const tinfo = unpackTransferBegin(key);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case Opcodes.FILE_BEGIN: {
|
|
82
|
+
await this._handleFileBegin(key);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case Opcodes.CHUNK: {
|
|
87
|
+
await this._handleChunk(key, val);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case Opcodes.FILE_END: {
|
|
92
|
+
await this._handleFileEnd(key);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case Opcodes.TRANSFER_END: {
|
|
97
|
+
this.transferFinished = true;
|
|
98
|
+
for (const resolve of this._finishResolvers) {
|
|
99
|
+
resolve();
|
|
100
|
+
}
|
|
101
|
+
this._finishResolvers = [];
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async _handleFileBegin(key) {
|
|
109
|
+
const info = unpackFileBegin(key);
|
|
110
|
+
const destPath = path.resolve(this.destinationDir, info.path);
|
|
111
|
+
const parentDir = path.dirname(destPath);
|
|
112
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
113
|
+
|
|
114
|
+
const partPath = `${destPath}.teyyare-part`;
|
|
115
|
+
const statePath = `${destPath}.teyyare-state`;
|
|
116
|
+
|
|
117
|
+
let resumeState = await FileResumeState.load(statePath);
|
|
118
|
+
if (!resumeState) {
|
|
119
|
+
resumeState = new FileResumeState({
|
|
120
|
+
fileId: info.fileId,
|
|
121
|
+
fileSize: Number(info.fileSize),
|
|
122
|
+
sha256: info.sha256
|
|
123
|
+
});
|
|
124
|
+
await resumeState.save(statePath);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const fileHandle = await fs.open(partPath, 'a+');
|
|
128
|
+
|
|
129
|
+
this.activeFiles.set(info.fileId, {
|
|
130
|
+
fileId: info.fileId,
|
|
131
|
+
relativePath: info.path,
|
|
132
|
+
destPath,
|
|
133
|
+
partPath,
|
|
134
|
+
statePath,
|
|
135
|
+
fileHandle,
|
|
136
|
+
resumeState,
|
|
137
|
+
fileSize: Number(info.fileSize),
|
|
138
|
+
sha256: info.sha256,
|
|
139
|
+
chunksWrittenSinceSave: 0
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async _handleChunk(key, val) {
|
|
144
|
+
const meta = unpackChunkMeta(key);
|
|
145
|
+
const file = this.activeFiles.get(meta.fileId);
|
|
146
|
+
if (!file) {
|
|
147
|
+
throw new Error(`Unknown fileId ${meta.fileId} in CHUNK mutation`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Idempotency / resume hit check
|
|
151
|
+
if (file.resumeState.isVerified(meta.chunkIndex)) {
|
|
152
|
+
if (this.metrics) {
|
|
153
|
+
this.metrics.resumeHits++;
|
|
154
|
+
this.metrics.chunksVerified++;
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Per-chunk corruption detection
|
|
160
|
+
const t0 = Date.now();
|
|
161
|
+
const computedCrc = crc32(val);
|
|
162
|
+
if (this.metrics) {
|
|
163
|
+
this.metrics.checksumTimeMs += (Date.now() - t0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (computedCrc !== meta.checksum) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Chunk ${meta.chunkIndex} corruption: CRC 0x${computedCrc.toString(16)} !== 0x${meta.checksum.toString(16)}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Artificial write delay for slow-receiver backpressure tests
|
|
173
|
+
if (this.writeDelayMs > 0) {
|
|
174
|
+
await new Promise((r) => setTimeout(r, this.writeDelayMs));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Direct random-access write at specific byte offset
|
|
178
|
+
const writeStart = Date.now();
|
|
179
|
+
await file.fileHandle.write(val, 0, meta.length, Number(meta.offset));
|
|
180
|
+
if (this.metrics) {
|
|
181
|
+
this.metrics.diskWriteTimeMs += (Date.now() - writeStart);
|
|
182
|
+
this.metrics.fileBytesWritten += meta.length;
|
|
183
|
+
this.metrics.chunksCreated++;
|
|
184
|
+
this.metrics.chunksVerified++;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
file.resumeState.markVerified(meta.chunkIndex);
|
|
188
|
+
file.chunksWrittenSinceSave++;
|
|
189
|
+
|
|
190
|
+
if (file.chunksWrittenSinceSave >= 32) {
|
|
191
|
+
file.chunksWrittenSinceSave = 0;
|
|
192
|
+
await file.resumeState.save(file.statePath);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async _handleFileEnd(key) {
|
|
197
|
+
const meta = unpackFileEnd(key);
|
|
198
|
+
const file = this.activeFiles.get(meta.fileId);
|
|
199
|
+
if (!file) return;
|
|
200
|
+
|
|
201
|
+
// Save final state before rename
|
|
202
|
+
await file.resumeState.save(file.statePath);
|
|
203
|
+
|
|
204
|
+
// Sync disk buffers
|
|
205
|
+
await file.fileHandle.sync();
|
|
206
|
+
await file.fileHandle.close();
|
|
207
|
+
|
|
208
|
+
// Verify whole-file checksum if available
|
|
209
|
+
const expectedSha = meta.sha256 || file.sha256;
|
|
210
|
+
if (expectedSha) {
|
|
211
|
+
const actualSha = await this._computeFileSha256(file.partPath);
|
|
212
|
+
if (actualSha !== expectedSha) {
|
|
213
|
+
throw new Error(`Whole-file SHA-256 mismatch for ${file.relativePath}: actual ${actualSha} !== expected ${expectedSha}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Atomic finalization
|
|
218
|
+
await fs.rename(file.partPath, file.destPath);
|
|
219
|
+
await FileResumeState.cleanup(file.statePath);
|
|
220
|
+
|
|
221
|
+
this.activeFiles.delete(meta.fileId);
|
|
222
|
+
this.completedFiles.add(file.destPath);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async _computeFileSha256(filePath) {
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const hash = crypto.createHash('sha256');
|
|
228
|
+
const stream = createReadStream(filePath);
|
|
229
|
+
stream.on('data', (d) => hash.update(d));
|
|
230
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
231
|
+
stream.on('error', reject);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resumable transfer state management.
|
|
3
|
+
* Persists verified chunk bitmaps/sets with atomic file replacement.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
export class FileResumeState {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} params
|
|
12
|
+
* @param {string} [params.transferId='']
|
|
13
|
+
* @param {number} [params.fileId=1]
|
|
14
|
+
* @param {number} [params.fileSize=0]
|
|
15
|
+
* @param {number} [params.chunkSize=1048576]
|
|
16
|
+
* @param {string} [params.sha256='']
|
|
17
|
+
* @param {Set<number>} [params.verifiedChunks=new Set()]
|
|
18
|
+
*/
|
|
19
|
+
constructor({
|
|
20
|
+
transferId = '',
|
|
21
|
+
fileId = 1,
|
|
22
|
+
fileSize = 0,
|
|
23
|
+
chunkSize = 1048576,
|
|
24
|
+
sha256 = '',
|
|
25
|
+
verifiedChunks = new Set()
|
|
26
|
+
} = {}) {
|
|
27
|
+
this.transferId = transferId;
|
|
28
|
+
this.fileId = fileId;
|
|
29
|
+
this.fileSize = fileSize;
|
|
30
|
+
this.chunkSize = chunkSize;
|
|
31
|
+
this.sha256 = sha256;
|
|
32
|
+
this.verifiedChunks = verifiedChunks;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get verifiedCount() {
|
|
36
|
+
return this.verifiedChunks.size;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
isVerified(chunkIndex) {
|
|
40
|
+
return this.verifiedChunks.has(chunkIndex);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
markVerified(chunkIndex) {
|
|
44
|
+
this.verifiedChunks.add(chunkIndex);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get totalChunks() {
|
|
48
|
+
if (this.fileSize === 0) return 0;
|
|
49
|
+
return Math.ceil(this.fileSize / this.chunkSize);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isComplete() {
|
|
53
|
+
return this.totalChunks > 0 && this.verifiedChunks.size >= this.totalChunks;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return array of missing chunk indices.
|
|
58
|
+
* @returns {number[]}
|
|
59
|
+
*/
|
|
60
|
+
getMissingChunks() {
|
|
61
|
+
const missing = [];
|
|
62
|
+
const total = this.totalChunks;
|
|
63
|
+
for (let i = 0; i < total; i++) {
|
|
64
|
+
if (!this.verifiedChunks.has(i)) {
|
|
65
|
+
missing.push(i);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return missing;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Atomically save state to disk.
|
|
73
|
+
* @param {string} stateFilePath
|
|
74
|
+
*/
|
|
75
|
+
async save(stateFilePath) {
|
|
76
|
+
const data = {
|
|
77
|
+
transferId: this.transferId,
|
|
78
|
+
fileId: this.fileId,
|
|
79
|
+
fileSize: this.fileSize,
|
|
80
|
+
chunkSize: this.chunkSize,
|
|
81
|
+
sha256: this.sha256,
|
|
82
|
+
verifiedChunks: Array.from(this.verifiedChunks)
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const tmpPath = `${stateFilePath}.tmp`;
|
|
86
|
+
await fs.writeFile(tmpPath, JSON.stringify(data), 'utf8');
|
|
87
|
+
await fs.rename(tmpPath, stateFilePath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load state from disk if exists.
|
|
92
|
+
* @param {string} stateFilePath
|
|
93
|
+
* @returns {Promise<FileResumeState|null>}
|
|
94
|
+
*/
|
|
95
|
+
static async load(stateFilePath) {
|
|
96
|
+
if (!existsSync(stateFilePath)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const raw = await fs.readFile(stateFilePath, 'utf8');
|
|
102
|
+
const data = JSON.parse(raw);
|
|
103
|
+
return new FileResumeState({
|
|
104
|
+
transferId: data.transferId,
|
|
105
|
+
fileId: data.fileId,
|
|
106
|
+
fileSize: data.fileSize,
|
|
107
|
+
chunkSize: data.chunkSize,
|
|
108
|
+
sha256: data.sha256,
|
|
109
|
+
verifiedChunks: new Set(data.verifiedChunks || [])
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Clean up state file.
|
|
118
|
+
* @param {string} stateFilePath
|
|
119
|
+
*/
|
|
120
|
+
static async cleanup(stateFilePath) {
|
|
121
|
+
try {
|
|
122
|
+
await fs.unlink(stateFilePath);
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teyyare Sender Engine.
|
|
3
|
+
* Reads source files, computes per-chunk checksums, appends mutations into Muttafa
|
|
4
|
+
* MutableGeneration, manages generation freeze, submits scatter/gather FrozenGenerations
|
|
5
|
+
* to Raptiye, and propagates end-to-end backpressure from Raptiye/TCP to disk reads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { MutationEngine, ArenaStore, BumpAllocator, Sink, BackpressurePolicy } from 'muttafa';
|
|
11
|
+
import { crc32 } from 'raptiye';
|
|
12
|
+
import {
|
|
13
|
+
Opcodes,
|
|
14
|
+
packChunkMeta,
|
|
15
|
+
packFileBegin,
|
|
16
|
+
packFileEnd,
|
|
17
|
+
packTransferBegin,
|
|
18
|
+
packTransferEnd
|
|
19
|
+
} from '../core/protocol.js';
|
|
20
|
+
|
|
21
|
+
class RaptiyeSink extends Sink {
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('raptiye').ReplicationPipeline} replication
|
|
24
|
+
* @param {import('../metrics/collector.js').MetricsCollector} [metrics]
|
|
25
|
+
* @param {import('../cli/progress.js').ProgressReporter} [progress]
|
|
26
|
+
*/
|
|
27
|
+
constructor(replication, metrics = null, progress = null) {
|
|
28
|
+
super();
|
|
29
|
+
this.replication = replication;
|
|
30
|
+
this.metrics = metrics;
|
|
31
|
+
this.progress = progress;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Flush frozen generation to Raptiye replication pipeline.
|
|
36
|
+
* @param {import('muttafa').ImmutableBuffer} batch
|
|
37
|
+
*/
|
|
38
|
+
async flush(batch) {
|
|
39
|
+
const buffers = batch.buffers();
|
|
40
|
+
const t0 = Date.now();
|
|
41
|
+
|
|
42
|
+
if (this.metrics) {
|
|
43
|
+
this.metrics.generations++;
|
|
44
|
+
this.metrics.generationBytes += batch.byteLength;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const receipt = await this.replication.submitBatch({
|
|
48
|
+
generationId: batch.generationId,
|
|
49
|
+
firstSequence: batch.firstSequence,
|
|
50
|
+
lastSequence: batch.lastSequence,
|
|
51
|
+
entryCount: batch.entryCount,
|
|
52
|
+
byteLength: batch.byteLength,
|
|
53
|
+
buffers
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (this.progress) {
|
|
57
|
+
const stats = this.replication.getStats();
|
|
58
|
+
this.progress.update({
|
|
59
|
+
generationId: batch.generationId,
|
|
60
|
+
inflightGen: stats.inflightBatches,
|
|
61
|
+
inflightBytes: stats.inflightBytes,
|
|
62
|
+
raptiyeRTT: stats.rttMs
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Wait until remote peer ACKs receipt
|
|
67
|
+
await receipt.delivered();
|
|
68
|
+
|
|
69
|
+
if (this.metrics) {
|
|
70
|
+
this.metrics.freezeTimeMs += (Date.now() - t0);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class SenderPipeline {
|
|
76
|
+
/**
|
|
77
|
+
* @param {object} config
|
|
78
|
+
* @param {import('raptiye').ReplicationPipeline} config.replication
|
|
79
|
+
* @param {import('../manifest/manifest.js').TransferManifest} config.manifest
|
|
80
|
+
* @param {object} [config.options]
|
|
81
|
+
* @param {number} [config.options.chunkSize=1048576] 1 MiB chunks default
|
|
82
|
+
* @param {number} [config.options.maxEntries=8] 8 chunks per generation
|
|
83
|
+
* @param {number} [config.options.maxBytes=8388608] 8 MiB per generation
|
|
84
|
+
* @param {number} [config.options.maxImmutableBuffers=4] Bounded queue depth
|
|
85
|
+
* @param {import('../metrics/collector.js').MetricsCollector} [config.metrics]
|
|
86
|
+
* @param {import('../cli/progress.js').ProgressReporter} [config.progress]
|
|
87
|
+
* @param {Map<number, import('../resume/state.js').FileResumeState>} [config.knownResumeState]
|
|
88
|
+
*/
|
|
89
|
+
constructor({
|
|
90
|
+
replication,
|
|
91
|
+
manifest,
|
|
92
|
+
options = {},
|
|
93
|
+
metrics = null,
|
|
94
|
+
progress = null,
|
|
95
|
+
knownResumeState = new Map()
|
|
96
|
+
}) {
|
|
97
|
+
this.replication = replication;
|
|
98
|
+
this.manifest = manifest;
|
|
99
|
+
this.chunkSize = options.chunkSize || 1024 * 1024;
|
|
100
|
+
this.maxEntries = options.maxEntries || 8;
|
|
101
|
+
this.maxBytes = options.maxBytes || 8 * 1024 * 1024;
|
|
102
|
+
this.maxImmutableBuffers = options.maxImmutableBuffers || 4;
|
|
103
|
+
this.metrics = metrics;
|
|
104
|
+
this.progress = progress;
|
|
105
|
+
this.knownResumeState = knownResumeState;
|
|
106
|
+
|
|
107
|
+
this.transferId = crypto.randomUUID();
|
|
108
|
+
|
|
109
|
+
// Dynamically size arena capacity according to generation maxBytes
|
|
110
|
+
const arenaCapacity = Math.max(16 * 1024 * 1024, this.maxBytes + (this.maxEntries * 256) + 1024 * 1024);
|
|
111
|
+
|
|
112
|
+
// Muttafa low-level mutation buffering engine
|
|
113
|
+
this.sink = new RaptiyeSink(this.replication, this.metrics, this.progress);
|
|
114
|
+
this.engine = new MutationEngine({
|
|
115
|
+
sink: this.sink,
|
|
116
|
+
createStore: () => new ArenaStore({
|
|
117
|
+
chunkSize: Math.max(64, this.maxEntries),
|
|
118
|
+
allocator: new BumpAllocator(arenaCapacity)
|
|
119
|
+
}),
|
|
120
|
+
maxEntries: this.maxEntries,
|
|
121
|
+
maxBytes: this.maxBytes,
|
|
122
|
+
maxImmutableBuffers: this.maxImmutableBuffers,
|
|
123
|
+
backpressurePolicy: BackpressurePolicy.WAIT,
|
|
124
|
+
onBackpressure: (active) => {
|
|
125
|
+
if (this.metrics && active) {
|
|
126
|
+
this.metrics.backpressureCount++;
|
|
127
|
+
}
|
|
128
|
+
if (this.progress) {
|
|
129
|
+
this.progress.update({
|
|
130
|
+
muttafaQueue: this.engine._queue.length,
|
|
131
|
+
tcpPressure: active ? 1 : 0
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async _append(opcode, key, value = undefined, keyBytes = 0, valueBytes = 0) {
|
|
139
|
+
while (this.engine.isBackpressured()) {
|
|
140
|
+
await this.engine.ready();
|
|
141
|
+
await new Promise((r) => setImmediate(r));
|
|
142
|
+
}
|
|
143
|
+
const kLen = keyBytes || (key ? key.byteLength : 0);
|
|
144
|
+
const vLen = valueBytes || (value ? value.byteLength : 0);
|
|
145
|
+
return this.engine.custom(opcode, key, value, kLen, vLen);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Execute the full transfer pipeline with streaming reads, checksums,
|
|
150
|
+
* Muttafa mutation buffering, and end-to-end backpressure.
|
|
151
|
+
*/
|
|
152
|
+
async transfer() {
|
|
153
|
+
if (this.progress) {
|
|
154
|
+
this.progress.start();
|
|
155
|
+
this.progress.update({
|
|
156
|
+
totalBytes: this.manifest.totalBytes,
|
|
157
|
+
totalChunks: Math.ceil(this.manifest.totalBytes / this.chunkSize)
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 1. OP_TRANSFER_BEGIN
|
|
162
|
+
const tbegin = packTransferBegin({
|
|
163
|
+
transferId: this.transferId,
|
|
164
|
+
fileCount: this.manifest.totalFiles,
|
|
165
|
+
totalBytes: this.manifest.totalBytes
|
|
166
|
+
});
|
|
167
|
+
await this._append(Opcodes.TRANSFER_BEGIN, tbegin);
|
|
168
|
+
|
|
169
|
+
let bytesSent = 0;
|
|
170
|
+
let chunksSent = 0;
|
|
171
|
+
|
|
172
|
+
// 2. Iterate files
|
|
173
|
+
for (const file of this.manifest.files) {
|
|
174
|
+
const filePath = this.manifest.isDirectory
|
|
175
|
+
? `${this.manifest.rootPath}/${file.relativePath}`
|
|
176
|
+
: this.manifest.rootPath;
|
|
177
|
+
|
|
178
|
+
if (this.progress) {
|
|
179
|
+
this.progress.update({ fileName: file.relativePath });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check if resume state exists for this file
|
|
183
|
+
const resume = this.knownResumeState.get(file.fileId);
|
|
184
|
+
|
|
185
|
+
// OP_FILE_BEGIN
|
|
186
|
+
const fbegin = packFileBegin({
|
|
187
|
+
fileId: file.fileId,
|
|
188
|
+
fileSize: file.size,
|
|
189
|
+
path: file.relativePath,
|
|
190
|
+
sha256: file.sha256 || ''
|
|
191
|
+
});
|
|
192
|
+
await this._append(Opcodes.FILE_BEGIN, fbegin);
|
|
193
|
+
|
|
194
|
+
const totalFileChunks = file.size === 0 ? 0 : Math.ceil(file.size / this.chunkSize);
|
|
195
|
+
|
|
196
|
+
if (file.size > 0) {
|
|
197
|
+
const handle = await fs.open(filePath, 'r');
|
|
198
|
+
try {
|
|
199
|
+
for (let i = 0; i < totalFileChunks; i++) {
|
|
200
|
+
const offset = BigInt(i * this.chunkSize);
|
|
201
|
+
const remaining = file.size - (i * this.chunkSize);
|
|
202
|
+
const chunkLen = Math.min(this.chunkSize, remaining);
|
|
203
|
+
|
|
204
|
+
// Resume check: if receiver already has verified chunk, skip reading and sending!
|
|
205
|
+
if (resume && resume.isVerified(i)) {
|
|
206
|
+
if (this.metrics) {
|
|
207
|
+
this.metrics.resumeHits++;
|
|
208
|
+
this.metrics.chunksVerified++;
|
|
209
|
+
}
|
|
210
|
+
bytesSent += chunkLen;
|
|
211
|
+
chunksSent++;
|
|
212
|
+
if (this.progress) {
|
|
213
|
+
this.progress.update({ bytesDone: bytesSent, chunksDone: chunksSent });
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Direct read into fresh chunk buffer
|
|
219
|
+
const chunkBuf = new Uint8Array(chunkLen);
|
|
220
|
+
const readStart = Date.now();
|
|
221
|
+
await handle.read(chunkBuf, 0, chunkLen, Number(offset));
|
|
222
|
+
if (this.metrics) {
|
|
223
|
+
this.metrics.diskReadTimeMs += (Date.now() - readStart);
|
|
224
|
+
this.metrics.fileBytesRead += chunkLen;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Checksum
|
|
228
|
+
const crcStart = Date.now();
|
|
229
|
+
const checksum = crc32(chunkBuf);
|
|
230
|
+
if (this.metrics) {
|
|
231
|
+
this.metrics.checksumTimeMs += (Date.now() - crcStart);
|
|
232
|
+
this.metrics.chunksCreated++;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Pack compact binary metadata
|
|
236
|
+
const meta = packChunkMeta({
|
|
237
|
+
fileId: file.fileId,
|
|
238
|
+
chunkIndex: i,
|
|
239
|
+
offset,
|
|
240
|
+
length: chunkLen,
|
|
241
|
+
checksum
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Fast append to Muttafa MutableGeneration with opcode
|
|
245
|
+
await this._append(Opcodes.CHUNK, meta, chunkBuf, meta.byteLength, chunkBuf.byteLength);
|
|
246
|
+
|
|
247
|
+
bytesSent += chunkLen;
|
|
248
|
+
chunksSent++;
|
|
249
|
+
|
|
250
|
+
if (this.progress) {
|
|
251
|
+
const rStats = this.replication.getStats();
|
|
252
|
+
this.progress.update({
|
|
253
|
+
bytesDone: bytesSent,
|
|
254
|
+
chunksDone: chunksSent,
|
|
255
|
+
muttafaQueue: this.engine._queue.length,
|
|
256
|
+
inflightGen: rStats.inflightBatches,
|
|
257
|
+
inflightBytes: rStats.inflightBytes,
|
|
258
|
+
raptiyeRTT: rStats.rttMs
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} finally {
|
|
263
|
+
await handle.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// OP_FILE_END
|
|
268
|
+
const fend = packFileEnd({ fileId: file.fileId, sha256: file.sha256 || '' });
|
|
269
|
+
await this._append(Opcodes.FILE_END, fend);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 3. OP_TRANSFER_END
|
|
273
|
+
const tend = packTransferEnd({ transferId: this.transferId, status: 0 });
|
|
274
|
+
await this._append(Opcodes.TRANSFER_END, tend);
|
|
275
|
+
|
|
276
|
+
// 4. Flush remaining mutations in Muttafa and wait for all deliveries
|
|
277
|
+
await this.engine.flush();
|
|
278
|
+
await this.replication.drain();
|
|
279
|
+
|
|
280
|
+
if (this.progress) {
|
|
281
|
+
this.progress.update({
|
|
282
|
+
bytesDone: this.manifest.totalBytes,
|
|
283
|
+
chunksDone: Math.ceil(this.manifest.totalBytes / this.chunkSize)
|
|
284
|
+
});
|
|
285
|
+
this.progress.stop();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { serve, send } from '../../src/index.js';
|
|
9
|
+
|
|
10
|
+
test('Integration - slow receiver backpressure: disk throttle triggers Muttafa backpressure and memory plateaus', async () => {
|
|
11
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teyyare-backpressure-test-'));
|
|
12
|
+
const sourceFile = path.join(tmpDir, 'throttle.bin');
|
|
13
|
+
const destDir = path.join(tmpDir, 'dest');
|
|
14
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
// 6 MB file
|
|
17
|
+
const size = 6 * 1024 * 1024;
|
|
18
|
+
const data = crypto.randomBytes(size);
|
|
19
|
+
await fs.writeFile(sourceFile, data);
|
|
20
|
+
const sourceHash = crypto.createHash('sha256').update(data).digest('hex');
|
|
21
|
+
|
|
22
|
+
const port = 7525;
|
|
23
|
+
// Artificially delay receiver disk write by 30ms per chunk
|
|
24
|
+
const server = await serve({
|
|
25
|
+
port,
|
|
26
|
+
destinationDir: destDir,
|
|
27
|
+
writeDelayMs: 30
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const senderMetrics = await send({
|
|
31
|
+
sourcePath: sourceFile,
|
|
32
|
+
host: '127.0.0.1',
|
|
33
|
+
port,
|
|
34
|
+
pipelineOptions: {
|
|
35
|
+
chunkSize: 512 * 1024, // 512 KB chunks (12 chunks)
|
|
36
|
+
maxEntries: 2, // 1 MB generations
|
|
37
|
+
maxBytes: 1024 * 1024,
|
|
38
|
+
maxImmutableBuffers: 2, // Queue capacity of only 2 generations
|
|
39
|
+
maxInflightBatches: 2 // Raptiye inflight capacity of 2
|
|
40
|
+
},
|
|
41
|
+
showProgress: false
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await server.receiver.waitForCompletion();
|
|
45
|
+
await server.close();
|
|
46
|
+
|
|
47
|
+
const destFile = path.join(destDir, 'throttle.bin');
|
|
48
|
+
assert.ok(existsSync(destFile), 'Destination file must exist');
|
|
49
|
+
|
|
50
|
+
const destData = await fs.readFile(destFile);
|
|
51
|
+
const destHash = crypto.createHash('sha256').update(destData).digest('hex');
|
|
52
|
+
assert.strictEqual(destHash, sourceHash, 'SHA-256 must match despite backpressure throttling');
|
|
53
|
+
|
|
54
|
+
const snap = senderMetrics.snapshot();
|
|
55
|
+
console.log(`Backpressure activations: ${snap.backpressureCount}, Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB`);
|
|
56
|
+
|
|
57
|
+
// Verify backpressure was triggered
|
|
58
|
+
assert.ok(snap.backpressureCount > 0, 'Muttafa backpressure must be triggered by slow receiver');
|
|
59
|
+
// Verify peak RSS did not explode
|
|
60
|
+
assert.ok(snap.peakRSS < 200 * 1024 * 1024, 'RSS memory must remain bounded under backpressure');
|
|
61
|
+
|
|
62
|
+
await fs.rm(tmpDir, { recursive: true });
|
|
63
|
+
});
|