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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throttled terminal progress renderer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class ProgressReporter {
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} [options]
|
|
8
|
+
* @param {number} [options.intervalMs=150]
|
|
9
|
+
* @param {boolean} [options.enabled=true]
|
|
10
|
+
*/
|
|
11
|
+
constructor({ intervalMs = 150, enabled = true } = {}) {
|
|
12
|
+
this.intervalMs = intervalMs;
|
|
13
|
+
this.enabled = enabled && Boolean(process.stdout.isTTY || process.env.CI !== 'true');
|
|
14
|
+
|
|
15
|
+
this.fileName = '';
|
|
16
|
+
this.bytesDone = 0;
|
|
17
|
+
this.totalBytes = 0;
|
|
18
|
+
this.chunksDone = 0;
|
|
19
|
+
this.totalChunks = 0;
|
|
20
|
+
this.generationId = 0;
|
|
21
|
+
this.inflightGen = 0;
|
|
22
|
+
this.inflightBytes = 0;
|
|
23
|
+
this.muttafaQueue = 0;
|
|
24
|
+
this.raptiyeRTT = 0;
|
|
25
|
+
this.tcpPressure = 0;
|
|
26
|
+
|
|
27
|
+
this.startTime = Date.now();
|
|
28
|
+
this.lastRender = 0;
|
|
29
|
+
this._timer = null;
|
|
30
|
+
this._linesRendered = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
start() {
|
|
34
|
+
if (!this.enabled) return;
|
|
35
|
+
this.startTime = Date.now();
|
|
36
|
+
this._timer = setInterval(() => this.render(), this.intervalMs);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
stop() {
|
|
40
|
+
if (this._timer) {
|
|
41
|
+
clearInterval(this._timer);
|
|
42
|
+
this._timer = null;
|
|
43
|
+
}
|
|
44
|
+
if (this.enabled) {
|
|
45
|
+
this.render();
|
|
46
|
+
process.stdout.write('\n');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
update(state = {}) {
|
|
51
|
+
if (state.fileName !== undefined) this.fileName = state.fileName;
|
|
52
|
+
if (state.bytesDone !== undefined) this.bytesDone = state.bytesDone;
|
|
53
|
+
if (state.totalBytes !== undefined) this.totalBytes = state.totalBytes;
|
|
54
|
+
if (state.chunksDone !== undefined) this.chunksDone = state.chunksDone;
|
|
55
|
+
if (state.totalChunks !== undefined) this.totalChunks = state.totalChunks;
|
|
56
|
+
if (state.generationId !== undefined) this.generationId = state.generationId;
|
|
57
|
+
if (state.inflightGen !== undefined) this.inflightGen = state.inflightGen;
|
|
58
|
+
if (state.inflightBytes !== undefined) this.inflightBytes = state.inflightBytes;
|
|
59
|
+
if (state.muttafaQueue !== undefined) this.muttafaQueue = state.muttafaQueue;
|
|
60
|
+
if (state.raptiyeRTT !== undefined) this.raptiyeRTT = state.raptiyeRTT;
|
|
61
|
+
if (state.tcpPressure !== undefined) this.tcpPressure = state.tcpPressure;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
formatBytes(bytes) {
|
|
65
|
+
if (bytes >= 1024 * 1024 * 1024) {
|
|
66
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
67
|
+
}
|
|
68
|
+
if (bytes >= 1024 * 1024) {
|
|
69
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
70
|
+
}
|
|
71
|
+
if (bytes >= 1024) {
|
|
72
|
+
return `${(bytes / 1024).toFixed(2)} KB`;
|
|
73
|
+
}
|
|
74
|
+
return `${bytes} B`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
render() {
|
|
78
|
+
if (!this.enabled) return;
|
|
79
|
+
|
|
80
|
+
const elapsedSec = Math.max(0.001, (Date.now() - this.startTime) / 1000);
|
|
81
|
+
const throughputMBs = (this.bytesDone / (1024 * 1024)) / elapsedSec;
|
|
82
|
+
const percent = this.totalBytes > 0 ? ((this.bytesDone / this.totalBytes) * 100).toFixed(1) : '0.0';
|
|
83
|
+
|
|
84
|
+
let etaStr = '--:--';
|
|
85
|
+
if (throughputMBs > 0 && this.totalBytes > this.bytesDone) {
|
|
86
|
+
const remainingBytes = this.totalBytes - this.bytesDone;
|
|
87
|
+
const remainingSec = Math.round(remainingBytes / (throughputMBs * 1024 * 1024));
|
|
88
|
+
const mm = String(Math.floor(remainingSec / 60)).padStart(2, '0');
|
|
89
|
+
const ss = String(remainingSec % 60).padStart(2, '0');
|
|
90
|
+
etaStr = `${mm}:${ss}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lines = [
|
|
94
|
+
'\x1b[1m\x1b[36mTeyyare\x1b[0m',
|
|
95
|
+
'',
|
|
96
|
+
`\x1b[1m${this.fileName || 'Transferring...'}\x1b[0m`,
|
|
97
|
+
'',
|
|
98
|
+
`${this.formatBytes(this.bytesDone)} / ${this.formatBytes(this.totalBytes)}`,
|
|
99
|
+
`\x1b[32m${percent}%\x1b[0m`,
|
|
100
|
+
'',
|
|
101
|
+
`throughput \x1b[1m${throughputMBs.toFixed(1)} MB/s\x1b[0m`,
|
|
102
|
+
`ETA ${etaStr}`,
|
|
103
|
+
'',
|
|
104
|
+
`chunks ${this.chunksDone} / ${this.totalChunks}`,
|
|
105
|
+
`generation ${this.generationId}`,
|
|
106
|
+
`inflight gen ${this.inflightGen}`,
|
|
107
|
+
`inflight bytes ${this.formatBytes(this.inflightBytes)}`,
|
|
108
|
+
'',
|
|
109
|
+
`muttafa queue ${this.muttafaQueue}`,
|
|
110
|
+
`raptiye RTT ${this.raptiyeRTT} ms`,
|
|
111
|
+
`TCP pressure ${this.tcpPressure}`
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
if (process.stdout.isTTY) {
|
|
115
|
+
if (this._linesRendered > 0) {
|
|
116
|
+
process.stdout.write(`\x1b[${this._linesRendered}A\x1b[0J`);
|
|
117
|
+
}
|
|
118
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
119
|
+
this._linesRendered = lines.length;
|
|
120
|
+
} else {
|
|
121
|
+
// Non-interactive log
|
|
122
|
+
process.stdout.write(`[Teyyare] ${percent}% | ${throughputMBs.toFixed(1)} MB/s | Chunks: ${this.chunksDone}/${this.totalChunks} | ETA: ${etaStr}\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packed binary mutation protocol for Teyyare.
|
|
3
|
+
* Zero JSON on chunk hot-path.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
|
|
9
|
+
export const Opcodes = Object.freeze({
|
|
10
|
+
TRANSFER_BEGIN: 10,
|
|
11
|
+
FILE_BEGIN: 11,
|
|
12
|
+
CHUNK: 12,
|
|
13
|
+
FILE_END: 13,
|
|
14
|
+
TRANSFER_END: 14
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const CHUNK_META_SIZE = 25;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pack binary chunk metadata into a 25-byte buffer.
|
|
21
|
+
* Layout:
|
|
22
|
+
* 0..3: fileId (u32 BE)
|
|
23
|
+
* 4..7: chunkIndex (u32 BE)
|
|
24
|
+
* 8..15: offset (u64 BigInt BE)
|
|
25
|
+
* 16..19: length (u32 BE)
|
|
26
|
+
* 20..23: checksum (u32 BE, CRC32)
|
|
27
|
+
* 24: flags (u8)
|
|
28
|
+
*
|
|
29
|
+
* @param {object} meta
|
|
30
|
+
* @param {number} meta.fileId
|
|
31
|
+
* @param {number} meta.chunkIndex
|
|
32
|
+
* @param {number|bigint} meta.offset
|
|
33
|
+
* @param {number} meta.length
|
|
34
|
+
* @param {number} meta.checksum
|
|
35
|
+
* @param {number} [meta.flags=0]
|
|
36
|
+
* @returns {Uint8Array}
|
|
37
|
+
*/
|
|
38
|
+
export function packChunkMeta({ fileId, chunkIndex, offset, length, checksum, flags = 0 }) {
|
|
39
|
+
const buf = new Uint8Array(CHUNK_META_SIZE);
|
|
40
|
+
const view = new DataView(buf.buffer, buf.byteOffset, CHUNK_META_SIZE);
|
|
41
|
+
view.setUint32(0, fileId, false);
|
|
42
|
+
view.setUint32(4, chunkIndex, false);
|
|
43
|
+
view.setBigUint64(8, BigInt(offset), false);
|
|
44
|
+
view.setUint32(16, length, false);
|
|
45
|
+
view.setUint32(20, checksum >>> 0, false);
|
|
46
|
+
view.setUint8(24, flags);
|
|
47
|
+
return buf;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Unpack binary chunk metadata from a buffer slice.
|
|
52
|
+
* @param {Uint8Array} buf
|
|
53
|
+
* @param {number} [offset=0]
|
|
54
|
+
* @returns {object}
|
|
55
|
+
*/
|
|
56
|
+
export function unpackChunkMeta(buf, offset = 0) {
|
|
57
|
+
const view = new DataView(buf.buffer, buf.byteOffset + offset, CHUNK_META_SIZE);
|
|
58
|
+
return {
|
|
59
|
+
fileId: view.getUint32(0, false),
|
|
60
|
+
chunkIndex: view.getUint32(4, false),
|
|
61
|
+
offset: view.getBigUint64(8, false),
|
|
62
|
+
length: view.getUint32(16, false),
|
|
63
|
+
checksum: view.getUint32(20, false),
|
|
64
|
+
flags: view.getUint8(24)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Pack FILE_BEGIN metadata.
|
|
70
|
+
* Layout:
|
|
71
|
+
* 0..3: fileId (u32)
|
|
72
|
+
* 4..11: fileSize (u64)
|
|
73
|
+
* 12..13: pathLen (u16)
|
|
74
|
+
* 14..45: sha256 hex (32 bytes raw or 64 hex chars)
|
|
75
|
+
* 46..: path utf8
|
|
76
|
+
*/
|
|
77
|
+
export function packFileBegin({ fileId, fileSize, path, sha256 = '' }) {
|
|
78
|
+
const pathBytes = encoder.encode(path);
|
|
79
|
+
const shaBytes = encoder.encode(sha256.padEnd(64, '0').slice(0, 64));
|
|
80
|
+
const total = 14 + 64 + pathBytes.byteLength;
|
|
81
|
+
const buf = new Uint8Array(total);
|
|
82
|
+
const view = new DataView(buf.buffer, buf.byteOffset, total);
|
|
83
|
+
|
|
84
|
+
view.setUint32(0, fileId, false);
|
|
85
|
+
view.setBigUint64(4, BigInt(fileSize), false);
|
|
86
|
+
view.setUint16(12, pathBytes.byteLength, false);
|
|
87
|
+
buf.set(shaBytes, 14);
|
|
88
|
+
buf.set(pathBytes, 14 + 64);
|
|
89
|
+
return buf;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function unpackFileBegin(buf) {
|
|
93
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
94
|
+
const fileId = view.getUint32(0, false);
|
|
95
|
+
const fileSize = view.getBigUint64(4, false);
|
|
96
|
+
const pathLen = view.getUint16(12, false);
|
|
97
|
+
const sha256 = decoder.decode(buf.subarray(14, 14 + 64)).replace(/0+$/, '');
|
|
98
|
+
const path = decoder.decode(buf.subarray(14 + 64, 14 + 64 + pathLen));
|
|
99
|
+
return { fileId, fileSize, path, sha256 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pack TRANSFER_BEGIN metadata.
|
|
104
|
+
*/
|
|
105
|
+
export function packTransferBegin({ transferId, fileCount, totalBytes }) {
|
|
106
|
+
const idBytes = encoder.encode(transferId);
|
|
107
|
+
const buf = new Uint8Array(14 + idBytes.byteLength);
|
|
108
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
109
|
+
view.setUint32(0, fileCount, false);
|
|
110
|
+
view.setBigUint64(4, BigInt(totalBytes), false);
|
|
111
|
+
view.setUint16(12, idBytes.byteLength, false);
|
|
112
|
+
buf.set(idBytes, 14);
|
|
113
|
+
return buf;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function unpackTransferBegin(buf) {
|
|
117
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
118
|
+
const fileCount = view.getUint32(0, false);
|
|
119
|
+
const totalBytes = view.getBigUint64(4, false);
|
|
120
|
+
const idLen = view.getUint16(12, false);
|
|
121
|
+
const transferId = decoder.decode(buf.subarray(14, 14 + idLen));
|
|
122
|
+
return { transferId, fileCount, totalBytes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pack FILE_END metadata.
|
|
127
|
+
*/
|
|
128
|
+
export function packFileEnd({ fileId, sha256 = '' }) {
|
|
129
|
+
const shaBytes = encoder.encode(sha256.padEnd(64, '0').slice(0, 64));
|
|
130
|
+
const buf = new Uint8Array(4 + 64);
|
|
131
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
132
|
+
view.setUint32(0, fileId, false);
|
|
133
|
+
buf.set(shaBytes, 4);
|
|
134
|
+
return buf;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function unpackFileEnd(buf) {
|
|
138
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
139
|
+
const fileId = view.getUint32(0, false);
|
|
140
|
+
const sha256 = decoder.decode(buf.subarray(4, 4 + 64)).replace(/0+$/, '');
|
|
141
|
+
return { fileId, sha256 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pack TRANSFER_END metadata.
|
|
146
|
+
*/
|
|
147
|
+
export function packTransferEnd({ transferId, status = 0 }) {
|
|
148
|
+
const idBytes = encoder.encode(transferId);
|
|
149
|
+
const buf = new Uint8Array(3 + idBytes.byteLength);
|
|
150
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
151
|
+
view.setUint8(0, status);
|
|
152
|
+
view.setUint16(1, idBytes.byteLength, false);
|
|
153
|
+
buf.set(idBytes, 3);
|
|
154
|
+
return buf;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function unpackTransferEnd(buf) {
|
|
158
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
159
|
+
const status = view.getUint8(0);
|
|
160
|
+
const idLen = view.getUint16(1, false);
|
|
161
|
+
const transferId = decoder.decode(buf.subarray(3, 3 + idLen));
|
|
162
|
+
return { transferId, status };
|
|
163
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teyyare: High-performance resumable file transfer engine built on Muttafa and Raptiye.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { TCPTransport, ReplicationPipeline } from 'raptiye';
|
|
7
|
+
import { SenderPipeline } from './sender/pipeline.js';
|
|
8
|
+
import { ReceiverPipeline } from './receiver/pipeline.js';
|
|
9
|
+
import { TransferManifest } from './manifest/manifest.js';
|
|
10
|
+
import { FileResumeState } from './resume/state.js';
|
|
11
|
+
import { MetricsCollector } from './metrics/collector.js';
|
|
12
|
+
import { ProgressReporter } from './cli/progress.js';
|
|
13
|
+
import { Opcodes, packChunkMeta, unpackChunkMeta } from './core/protocol.js';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
SenderPipeline,
|
|
17
|
+
ReceiverPipeline,
|
|
18
|
+
TransferManifest,
|
|
19
|
+
FileResumeState,
|
|
20
|
+
MetricsCollector,
|
|
21
|
+
ProgressReporter,
|
|
22
|
+
Opcodes,
|
|
23
|
+
packChunkMeta,
|
|
24
|
+
unpackChunkMeta
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start a Teyyare receiver server.
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {number} [options.port=7421]
|
|
31
|
+
* @param {string} [options.host='0.0.0.0']
|
|
32
|
+
* @param {string} [options.destinationDir='./']
|
|
33
|
+
* @param {number} [options.writeDelayMs=0]
|
|
34
|
+
* @returns {Promise<{ close: () => Promise<void>, receiver: ReceiverPipeline, transport: TCPTransport, metrics: MetricsCollector }>}
|
|
35
|
+
*/
|
|
36
|
+
export async function serve({
|
|
37
|
+
port = 7421,
|
|
38
|
+
host = '0.0.0.0',
|
|
39
|
+
destinationDir = './',
|
|
40
|
+
writeDelayMs = 0
|
|
41
|
+
} = {}) {
|
|
42
|
+
const metrics = new MetricsCollector();
|
|
43
|
+
const transport = new TCPTransport({
|
|
44
|
+
id: 2,
|
|
45
|
+
port,
|
|
46
|
+
host,
|
|
47
|
+
peerAddresses: new Map()
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await transport.start();
|
|
51
|
+
|
|
52
|
+
const replication = new ReplicationPipeline({
|
|
53
|
+
transport,
|
|
54
|
+
localNode: 2,
|
|
55
|
+
remoteNode: 1,
|
|
56
|
+
maxInflightBatches: 16,
|
|
57
|
+
maxInflightBytes: 64 * 1024 * 1024
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const receiver = new ReceiverPipeline({
|
|
61
|
+
replication,
|
|
62
|
+
destinationDir,
|
|
63
|
+
metrics,
|
|
64
|
+
writeDelayMs
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
transport,
|
|
69
|
+
replication,
|
|
70
|
+
receiver,
|
|
71
|
+
metrics,
|
|
72
|
+
async close() {
|
|
73
|
+
metrics.stop();
|
|
74
|
+
await transport.close();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Send a file or directory to a remote Teyyare server.
|
|
81
|
+
* @param {object} options
|
|
82
|
+
* @param {string} options.sourcePath
|
|
83
|
+
* @param {string} [options.host='127.0.0.1']
|
|
84
|
+
* @param {number} [options.port=7421]
|
|
85
|
+
* @param {object} [options.pipelineOptions]
|
|
86
|
+
* @param {boolean} [options.showProgress=true]
|
|
87
|
+
* @param {Map<number, import('./resume/state.js').FileResumeState>} [options.knownResumeState]
|
|
88
|
+
* @returns {Promise<MetricsCollector>}
|
|
89
|
+
*/
|
|
90
|
+
export async function send({
|
|
91
|
+
sourcePath,
|
|
92
|
+
host = '127.0.0.1',
|
|
93
|
+
port = 7421,
|
|
94
|
+
pipelineOptions = {},
|
|
95
|
+
showProgress = true,
|
|
96
|
+
knownResumeState = new Map()
|
|
97
|
+
}) {
|
|
98
|
+
const metrics = new MetricsCollector();
|
|
99
|
+
const progress = showProgress ? new ProgressReporter() : null;
|
|
100
|
+
|
|
101
|
+
// Ephemeral sender port
|
|
102
|
+
const senderPort = 10000 + Math.floor(Math.random() * 20000);
|
|
103
|
+
const transport = new TCPTransport({
|
|
104
|
+
id: 1,
|
|
105
|
+
port: senderPort,
|
|
106
|
+
host: '127.0.0.1',
|
|
107
|
+
peerAddresses: new Map([[2, { host, port }]])
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await transport.start();
|
|
111
|
+
|
|
112
|
+
const replication = new ReplicationPipeline({
|
|
113
|
+
transport,
|
|
114
|
+
localNode: 1,
|
|
115
|
+
remoteNode: 2,
|
|
116
|
+
maxInflightBatches: pipelineOptions.maxInflightBatches || 8,
|
|
117
|
+
maxInflightBytes: pipelineOptions.maxInflightBytes || 32 * 1024 * 1024
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const manifest = await TransferManifest.build(sourcePath);
|
|
121
|
+
|
|
122
|
+
const sender = new SenderPipeline({
|
|
123
|
+
replication,
|
|
124
|
+
manifest,
|
|
125
|
+
options: pipelineOptions,
|
|
126
|
+
metrics,
|
|
127
|
+
progress,
|
|
128
|
+
knownResumeState
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await sender.transfer();
|
|
133
|
+
} finally {
|
|
134
|
+
metrics.stop();
|
|
135
|
+
await transport.close();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return metrics;
|
|
139
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest generator for files and directories.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export class TransferManifest {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} params
|
|
11
|
+
* @param {string} params.rootPath
|
|
12
|
+
* @param {boolean} params.isDirectory
|
|
13
|
+
* @param {Array<{ fileId: number, relativePath: string, size: number, mode?: number }>} params.files
|
|
14
|
+
*/
|
|
15
|
+
constructor({ rootPath, isDirectory, files = [] }) {
|
|
16
|
+
this.rootPath = rootPath;
|
|
17
|
+
this.isDirectory = isDirectory;
|
|
18
|
+
this.files = files;
|
|
19
|
+
|
|
20
|
+
this.totalFiles = files.length;
|
|
21
|
+
this.totalBytes = files.reduce((acc, f) => acc + f.size, 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a manifest for a single file or directory.
|
|
26
|
+
* @param {string} sourcePath
|
|
27
|
+
* @returns {Promise<TransferManifest>}
|
|
28
|
+
*/
|
|
29
|
+
static async build(sourcePath) {
|
|
30
|
+
const absPath = path.resolve(sourcePath);
|
|
31
|
+
const stat = await fs.stat(absPath);
|
|
32
|
+
|
|
33
|
+
if (stat.isFile()) {
|
|
34
|
+
const base = path.basename(absPath);
|
|
35
|
+
return new TransferManifest({
|
|
36
|
+
rootPath: absPath,
|
|
37
|
+
isDirectory: false,
|
|
38
|
+
files: [
|
|
39
|
+
{
|
|
40
|
+
fileId: 1,
|
|
41
|
+
relativePath: base,
|
|
42
|
+
size: stat.size,
|
|
43
|
+
mode: stat.mode
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (stat.isDirectory()) {
|
|
50
|
+
const files = [];
|
|
51
|
+
let fileId = 1;
|
|
52
|
+
|
|
53
|
+
async function walk(currentDir, relPrefix = '') {
|
|
54
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const entryPath = path.join(currentDir, entry.name);
|
|
57
|
+
const entryRel = relPrefix ? path.join(relPrefix, entry.name) : entry.name;
|
|
58
|
+
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
await walk(entryPath, entryRel);
|
|
61
|
+
} else if (entry.isFile()) {
|
|
62
|
+
const fstat = await fs.stat(entryPath);
|
|
63
|
+
files.push({
|
|
64
|
+
fileId: fileId++,
|
|
65
|
+
relativePath: entryRel,
|
|
66
|
+
size: fstat.size,
|
|
67
|
+
mode: fstat.mode
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await walk(absPath);
|
|
74
|
+
|
|
75
|
+
return new TransferManifest({
|
|
76
|
+
rootPath: absPath,
|
|
77
|
+
isDirectory: true,
|
|
78
|
+
files
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(`Source ${sourcePath} is neither a regular file nor a directory`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Metrics Collector across Teyyare, Muttafa, and Raptiye layers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class MetricsCollector {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.startTime = Date.now();
|
|
8
|
+
this.endTime = 0;
|
|
9
|
+
|
|
10
|
+
// Teyyare
|
|
11
|
+
this.fileBytesRead = 0;
|
|
12
|
+
this.fileBytesWritten = 0;
|
|
13
|
+
this.chunksCreated = 0;
|
|
14
|
+
this.chunksVerified = 0;
|
|
15
|
+
this.resumeHits = 0;
|
|
16
|
+
this.checksumTimeMs = 0;
|
|
17
|
+
this.diskReadTimeMs = 0;
|
|
18
|
+
this.diskWriteTimeMs = 0;
|
|
19
|
+
|
|
20
|
+
// Muttafa
|
|
21
|
+
this.mutations = 0;
|
|
22
|
+
this.generations = 0;
|
|
23
|
+
this.generationBytes = 0;
|
|
24
|
+
this.freezeTimeMs = 0;
|
|
25
|
+
this.queueDepth = 0;
|
|
26
|
+
this.backpressureCount = 0;
|
|
27
|
+
this.arenaBytes = 0;
|
|
28
|
+
|
|
29
|
+
// Raptiye
|
|
30
|
+
this.payloadBytes = 0;
|
|
31
|
+
this.wireBytes = 0;
|
|
32
|
+
this.frames = 0;
|
|
33
|
+
this.tcpWrites = 0;
|
|
34
|
+
this.tcpDrains = 0;
|
|
35
|
+
this.inflightBytes = 0;
|
|
36
|
+
this.inflightBatches = 0;
|
|
37
|
+
this.rttMs = 0;
|
|
38
|
+
this.ackLatencyMs = 0;
|
|
39
|
+
this.reconnects = 0;
|
|
40
|
+
|
|
41
|
+
// Resource tracking
|
|
42
|
+
this.peakRSS = process.memoryUsage().rss;
|
|
43
|
+
this._rssInterval = setInterval(() => {
|
|
44
|
+
const current = process.memoryUsage().rss;
|
|
45
|
+
if (current > this.peakRSS) {
|
|
46
|
+
this.peakRSS = current;
|
|
47
|
+
}
|
|
48
|
+
}, 100);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
stop() {
|
|
52
|
+
if (this._rssInterval) {
|
|
53
|
+
clearInterval(this._rssInterval);
|
|
54
|
+
this._rssInterval = null;
|
|
55
|
+
}
|
|
56
|
+
this.endTime = Date.now();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get elapsedMs() {
|
|
60
|
+
return (this.endTime || Date.now()) - this.startTime;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get throughputMBs() {
|
|
64
|
+
const sec = this.elapsedMs / 1000;
|
|
65
|
+
if (sec <= 0) return 0;
|
|
66
|
+
const bytes = Math.max(this.fileBytesRead, this.fileBytesWritten);
|
|
67
|
+
return (bytes / (1024 * 1024)) / sec;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get protocolOverheadPercent() {
|
|
71
|
+
if (this.wireBytes <= 0 || this.payloadBytes <= 0) return 0;
|
|
72
|
+
const diff = Math.max(0, this.wireBytes - this.payloadBytes);
|
|
73
|
+
return (diff / this.wireBytes) * 100;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
snapshot() {
|
|
77
|
+
const mem = process.memoryUsage();
|
|
78
|
+
return {
|
|
79
|
+
elapsedMs: this.elapsedMs,
|
|
80
|
+
throughputMBs: this.throughputMBs,
|
|
81
|
+
protocolOverheadPercent: this.protocolOverheadPercent,
|
|
82
|
+
peakRSS: this.peakRSS,
|
|
83
|
+
currentRSS: mem.rss,
|
|
84
|
+
heapUsed: mem.heapUsed,
|
|
85
|
+
external: mem.external,
|
|
86
|
+
|
|
87
|
+
// Teyyare
|
|
88
|
+
fileBytesRead: this.fileBytesRead,
|
|
89
|
+
fileBytesWritten: this.fileBytesWritten,
|
|
90
|
+
chunksCreated: this.chunksCreated,
|
|
91
|
+
chunksVerified: this.chunksVerified,
|
|
92
|
+
resumeHits: this.resumeHits,
|
|
93
|
+
checksumTimeMs: this.checksumTimeMs,
|
|
94
|
+
diskReadTimeMs: this.diskReadTimeMs,
|
|
95
|
+
diskWriteTimeMs: this.diskWriteTimeMs,
|
|
96
|
+
|
|
97
|
+
// Muttafa
|
|
98
|
+
mutations: this.mutations,
|
|
99
|
+
generations: this.generations,
|
|
100
|
+
generationBytes: this.generationBytes,
|
|
101
|
+
freezeTimeMs: this.freezeTimeMs,
|
|
102
|
+
queueDepth: this.queueDepth,
|
|
103
|
+
backpressureCount: this.backpressureCount,
|
|
104
|
+
arenaBytes: this.arenaBytes,
|
|
105
|
+
|
|
106
|
+
// Raptiye
|
|
107
|
+
payloadBytes: this.payloadBytes,
|
|
108
|
+
wireBytes: this.wireBytes,
|
|
109
|
+
frames: this.frames,
|
|
110
|
+
tcpWrites: this.tcpWrites,
|
|
111
|
+
tcpDrains: this.tcpDrains,
|
|
112
|
+
inflightBytes: this.inflightBytes,
|
|
113
|
+
inflightBatches: this.inflightBatches,
|
|
114
|
+
rttMs: this.rttMs,
|
|
115
|
+
ackLatencyMs: this.ackLatencyMs,
|
|
116
|
+
reconnects: this.reconnects
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|