nodio-cli 1.0.7 → 1.0.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/package.json +1 -1
- package/src/server/config.js +1 -1
- package/src/server/models.js +3 -1
- package/src/server/routes.js +17 -0
- package/src/user/commands.js +62 -23
- package/src/user/index.js +2 -0
package/package.json
CHANGED
package/src/server/config.js
CHANGED
|
@@ -5,7 +5,7 @@ function getServerConfig() {
|
|
|
5
5
|
return {
|
|
6
6
|
port: Number(process.env.PORT || process.env.NODIO_SERVER_PORT || 4000),
|
|
7
7
|
mongoUri: process.env.NODIO_MONGO_URI || 'mongodb://127.0.0.1:27017/nodio',
|
|
8
|
-
heartbeatIntervalMs: Number(process.env.NODIO_HEARTBEAT_INTERVAL_MS ||
|
|
8
|
+
heartbeatIntervalMs: Number(process.env.NODIO_HEARTBEAT_INTERVAL_MS || 10000),
|
|
9
9
|
offlineAfterMisses: Number(process.env.NODIO_OFFLINE_AFTER_MISSES || 3),
|
|
10
10
|
minReplicas: Number(process.env.NODIO_MIN_REPLICAS || 5),
|
|
11
11
|
emergencyReplicaFloor: Number(process.env.NODIO_EMERGENCY_REPLICA_FLOOR || 2)
|
package/src/server/models.js
CHANGED
|
@@ -9,7 +9,9 @@ const nodeSchema = new mongoose.Schema(
|
|
|
9
9
|
capacityBytes: { type: Number, required: true, min: 1 },
|
|
10
10
|
freeBytes: { type: Number, required: true, min: 0 },
|
|
11
11
|
status: { type: String, enum: ['online', 'offline'], default: 'online', index: true },
|
|
12
|
-
lastHeartbeatAt: { type: Date, default: Date.now, index: true }
|
|
12
|
+
lastHeartbeatAt: { type: Date, default: Date.now, index: true },
|
|
13
|
+
pendingRelayAlert: { type: Boolean, default: false },
|
|
14
|
+
pendingRelayAlertAt: { type: Date, default: null }
|
|
13
15
|
},
|
|
14
16
|
{ timestamps: true }
|
|
15
17
|
);
|
package/src/server/routes.js
CHANGED
|
@@ -271,6 +271,23 @@ function buildRoutes(config) {
|
|
|
271
271
|
}
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
+
router.post('/nodes/:nodeId/alert-relay-pending', async (req, res, next) => {
|
|
275
|
+
try {
|
|
276
|
+
const { nodeId } = req.params;
|
|
277
|
+
const node = await NodeModel.findOne({ nodeId });
|
|
278
|
+
if (!node) {
|
|
279
|
+
return res.status(404).json({ error: 'node not found' });
|
|
280
|
+
}
|
|
281
|
+
// Set pending relay flag so donor knows to check urgently
|
|
282
|
+
node.pendingRelayAlert = true;
|
|
283
|
+
node.pendingRelayAlertAt = new Date();
|
|
284
|
+
await node.save();
|
|
285
|
+
res.json({ ok: true, message: 'relay pending alert sent' });
|
|
286
|
+
} catch (error) {
|
|
287
|
+
next(error);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
274
291
|
router.post('/replication-tasks/:taskId/complete', async (req, res, next) => {
|
|
275
292
|
try {
|
|
276
293
|
const { taskId } = req.params;
|
package/src/user/commands.js
CHANGED
|
@@ -31,7 +31,7 @@ function sleep(ms) {
|
|
|
31
31
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs =
|
|
34
|
+
async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 90000, pollMs = 500 }) {
|
|
35
35
|
const opId = uuidv4();
|
|
36
36
|
await api.post('/relay/shards/store', {
|
|
37
37
|
opId,
|
|
@@ -41,6 +41,16 @@ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, time
|
|
|
41
41
|
dataBase64: dataBuffer.toString('base64')
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
// Alert server that relay tasks are pending so donors check urgently
|
|
45
|
+
try {
|
|
46
|
+
for (const nodeId of nodeIds) {
|
|
47
|
+
// eslint-disable-next-line no-await-in-loop
|
|
48
|
+
await api.post(`/nodes/${nodeId}/alert-relay-pending`);
|
|
49
|
+
}
|
|
50
|
+
} catch (alertError) {
|
|
51
|
+
console.warn('relay alert failed:', alertError.message);
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
const deadline = Date.now() + timeoutMs;
|
|
45
55
|
while (Date.now() < deadline) {
|
|
46
56
|
const response = await api.get(`/relay/shards/store/${opId}`);
|
|
@@ -51,7 +61,7 @@ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, time
|
|
|
51
61
|
failed: payload.failed || []
|
|
52
62
|
};
|
|
53
63
|
}
|
|
54
|
-
//
|
|
64
|
+
// Polls every 500ms; with 10s heartbeat, donor should respond within 10s+network latency
|
|
55
65
|
// eslint-disable-next-line no-await-in-loop
|
|
56
66
|
await sleep(pollMs);
|
|
57
67
|
}
|
|
@@ -59,7 +69,7 @@ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, time
|
|
|
59
69
|
throw new Error(`relay store timed out for shard ${shardId}`);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
async function relayFetchShard(api, { shardId, nodeIds, timeoutMs =
|
|
72
|
+
async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 90000, pollMs = 500 }) {
|
|
63
73
|
const opId = uuidv4();
|
|
64
74
|
await api.post('/relay/shards/fetch', {
|
|
65
75
|
opId,
|
|
@@ -67,6 +77,16 @@ async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 120000, poll
|
|
|
67
77
|
nodeIds
|
|
68
78
|
});
|
|
69
79
|
|
|
80
|
+
// Alert server that relay tasks are pending so donors check urgently
|
|
81
|
+
try {
|
|
82
|
+
for (const nodeId of nodeIds) {
|
|
83
|
+
// eslint-disable-next-line no-await-in-loop
|
|
84
|
+
await api.post(`/nodes/${nodeId}/alert-relay-pending`);
|
|
85
|
+
}
|
|
86
|
+
} catch (alertError) {
|
|
87
|
+
console.warn('relay alert failed:', alertError.message);
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
const deadline = Date.now() + timeoutMs;
|
|
71
91
|
while (Date.now() < deadline) {
|
|
72
92
|
const response = await api.get(`/relay/shards/fetch/${opId}`);
|
|
@@ -100,6 +120,7 @@ async function uploadFile(options) {
|
|
|
100
120
|
const serverUrl = options.server;
|
|
101
121
|
const shardSizeMb = Number(options.shardSizeMb || 1);
|
|
102
122
|
const replicas = Number(options.replicas || 5);
|
|
123
|
+
const directTimeoutMs = Number(options.directTimeoutMs || 4000);
|
|
103
124
|
|
|
104
125
|
if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
|
|
105
126
|
throw new Error('shard-size-mb must be greater than 0');
|
|
@@ -107,6 +128,9 @@ async function uploadFile(options) {
|
|
|
107
128
|
if (!Number.isInteger(replicas) || replicas < 5) {
|
|
108
129
|
throw new Error('replicas must be an integer >= 5');
|
|
109
130
|
}
|
|
131
|
+
if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
|
|
132
|
+
throw new Error('direct-timeout-ms must be greater than 0');
|
|
133
|
+
}
|
|
110
134
|
|
|
111
135
|
const shardSizeBytes = Math.floor(shardSizeMb * 1024 * 1024);
|
|
112
136
|
const plainBuffer = await fs.readFile(filePath);
|
|
@@ -153,21 +177,29 @@ async function uploadFile(options) {
|
|
|
153
177
|
throw new Error(`placement failed for ${shardId}: expected ${replicas} replicas`);
|
|
154
178
|
}
|
|
155
179
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
for (const replica of plannedReplicas) {
|
|
160
|
-
try {
|
|
180
|
+
const directWriteResults = await Promise.allSettled(
|
|
181
|
+
plannedReplicas.map(async (replica) => {
|
|
161
182
|
const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
|
|
162
183
|
await axios.put(putUrl, encrypted.cipherText, {
|
|
163
184
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
164
|
-
timeout:
|
|
185
|
+
timeout: directTimeoutMs
|
|
165
186
|
});
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
return replica;
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const successfulReplicas = directWriteResults
|
|
192
|
+
.filter((result) => result.status === 'fulfilled')
|
|
193
|
+
.map((result) => result.value);
|
|
194
|
+
|
|
195
|
+
const failedReplicas = directWriteResults
|
|
196
|
+
.map((result, index) => ({ result, replica: plannedReplicas[index] }))
|
|
197
|
+
.filter((entry) => entry.result.status === 'rejected')
|
|
198
|
+
.map((entry) => ({
|
|
199
|
+
nodeId: entry.replica.nodeId,
|
|
200
|
+
url: entry.replica.url,
|
|
201
|
+
error: entry.result.reason?.message || 'direct write failed'
|
|
202
|
+
}));
|
|
171
203
|
|
|
172
204
|
if (failedReplicas.length > 0) {
|
|
173
205
|
try {
|
|
@@ -240,6 +272,11 @@ async function downloadFile(options) {
|
|
|
240
272
|
const serverUrl = options.server;
|
|
241
273
|
const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
|
|
242
274
|
const keyBuffer = parseAesKey(options.keyBase64);
|
|
275
|
+
const directTimeoutMs = Number(options.directTimeoutMs || 4000);
|
|
276
|
+
|
|
277
|
+
if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
|
|
278
|
+
throw new Error('direct-timeout-ms must be greater than 0');
|
|
279
|
+
}
|
|
243
280
|
|
|
244
281
|
const api = createApiClient(serverUrl);
|
|
245
282
|
const manifestResponse = await api.get(`/files/${fileId}/manifest`);
|
|
@@ -263,22 +300,24 @@ async function downloadFile(options) {
|
|
|
263
300
|
}
|
|
264
301
|
|
|
265
302
|
let encryptedBuffer = null;
|
|
266
|
-
|
|
267
|
-
|
|
303
|
+
const directFetchResults = await Promise.allSettled(
|
|
304
|
+
(shard.replicas || []).map(async (replica) => {
|
|
268
305
|
const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
|
|
269
306
|
const response = await axios.get(getUrl, {
|
|
270
307
|
responseType: 'arraybuffer',
|
|
271
|
-
timeout:
|
|
308
|
+
timeout: directTimeoutMs
|
|
272
309
|
});
|
|
273
310
|
const candidate = Buffer.from(response.data);
|
|
274
311
|
if (sha256Hex(candidate) !== shard.checksum) {
|
|
275
|
-
|
|
312
|
+
throw new Error('checksum mismatch');
|
|
276
313
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
314
|
+
return candidate;
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
|
|
319
|
+
if (successfulDirectFetch) {
|
|
320
|
+
encryptedBuffer = successfulDirectFetch.value;
|
|
282
321
|
}
|
|
283
322
|
|
|
284
323
|
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
package/src/user/index.js
CHANGED
|
@@ -14,6 +14,7 @@ program
|
|
|
14
14
|
.option('--file-id <id>', 'custom file ID (optional)')
|
|
15
15
|
.option('--shard-size-mb <mb>', 'plaintext shard size in MB', '1')
|
|
16
16
|
.option('--replicas <count>', 'replicas per shard (minimum 5)', '5')
|
|
17
|
+
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '4000')
|
|
17
18
|
.option('--key-base64 <key>', '32-byte AES key in base64 (optional)')
|
|
18
19
|
.action(async (options) => {
|
|
19
20
|
await uploadFile(options);
|
|
@@ -26,6 +27,7 @@ program
|
|
|
26
27
|
.requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
|
|
27
28
|
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
28
29
|
.option('--output <path>', 'output file path')
|
|
30
|
+
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '4000')
|
|
29
31
|
.action(async (options) => {
|
|
30
32
|
await downloadFile(options);
|
|
31
33
|
});
|