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.
@@ -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
+ });