nodio-cli 1.0.6 → 1.0.7
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/node/runtime.js +56 -1
- package/src/server/models.js +30 -1
- package/src/server/routes.js +173 -2
- package/src/user/commands.js +100 -1
package/package.json
CHANGED
package/src/node/runtime.js
CHANGED
|
@@ -146,8 +146,13 @@ class NodioNodeRuntime {
|
|
|
146
146
|
await this.executeReplicationTask(task);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
const relayTasks = response.data.relayTasks || [];
|
|
150
|
+
for (const task of relayTasks) {
|
|
151
|
+
await this.executeRelayTask(task);
|
|
152
|
+
}
|
|
153
|
+
|
|
149
154
|
console.log(
|
|
150
|
-
`[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length}`
|
|
155
|
+
`[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length} | relayTasks=${relayTasks.length}`
|
|
151
156
|
);
|
|
152
157
|
}
|
|
153
158
|
|
|
@@ -180,6 +185,56 @@ class NodioNodeRuntime {
|
|
|
180
185
|
console.error(`[replication] failed task=${task.taskId} shard=${task.shardId}: ${message}`);
|
|
181
186
|
}
|
|
182
187
|
}
|
|
188
|
+
|
|
189
|
+
async executeRelayTask(task) {
|
|
190
|
+
try {
|
|
191
|
+
if (task.taskType === 'store') {
|
|
192
|
+
if (typeof task.dataBase64 !== 'string') {
|
|
193
|
+
throw new Error('relay store payload is missing dataBase64');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const payload = Buffer.from(task.dataBase64, 'base64');
|
|
197
|
+
await this.shardStore.saveShard(task.shardId, payload);
|
|
198
|
+
|
|
199
|
+
await axios.post(`${this.serverUrl}/api/relay-tasks/${task.taskId}/complete`, {
|
|
200
|
+
nodeId: this.nodeId,
|
|
201
|
+
success: true
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
console.log(`[relay] stored shard=${task.shardId} task=${task.taskId}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (task.taskType === 'fetch') {
|
|
209
|
+
if (!(await this.shardStore.hasShard(task.shardId))) {
|
|
210
|
+
throw new Error('shard not found on donor');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const payload = await this.shardStore.readShard(task.shardId);
|
|
214
|
+
await axios.post(`${this.serverUrl}/api/relay-tasks/${task.taskId}/complete`, {
|
|
215
|
+
nodeId: this.nodeId,
|
|
216
|
+
success: true,
|
|
217
|
+
resultDataBase64: payload.toString('base64')
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
console.log(`[relay] fetched shard=${task.shardId} task=${task.taskId}`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
throw new Error(`unsupported relay task type: ${task.taskType}`);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const message = error.response?.data?.error || error.message;
|
|
227
|
+
await axios
|
|
228
|
+
.post(`${this.serverUrl}/api/relay-tasks/${task.taskId}/complete`, {
|
|
229
|
+
nodeId: this.nodeId,
|
|
230
|
+
success: false,
|
|
231
|
+
errorMessage: message
|
|
232
|
+
})
|
|
233
|
+
.catch(() => null);
|
|
234
|
+
|
|
235
|
+
console.error(`[relay] failed task=${task.taskId} shard=${task.shardId}: ${message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
183
238
|
}
|
|
184
239
|
|
|
185
240
|
module.exports = {
|
package/src/server/models.js
CHANGED
|
@@ -77,10 +77,39 @@ replicationTaskSchema.index(
|
|
|
77
77
|
}
|
|
78
78
|
);
|
|
79
79
|
|
|
80
|
+
const relayTaskSchema = new mongoose.Schema(
|
|
81
|
+
{
|
|
82
|
+
opId: { type: String, required: true, index: true },
|
|
83
|
+
taskType: {
|
|
84
|
+
type: String,
|
|
85
|
+
enum: ['store', 'fetch'],
|
|
86
|
+
required: true,
|
|
87
|
+
index: true
|
|
88
|
+
},
|
|
89
|
+
nodeId: { type: String, required: true, index: true },
|
|
90
|
+
shardId: { type: String, required: true, index: true },
|
|
91
|
+
fileId: { type: String, default: null, index: true },
|
|
92
|
+
dataBase64: { type: String, default: null },
|
|
93
|
+
resultDataBase64: { type: String, default: null },
|
|
94
|
+
status: {
|
|
95
|
+
type: String,
|
|
96
|
+
enum: ['pending', 'in_progress', 'completed', 'failed'],
|
|
97
|
+
default: 'pending',
|
|
98
|
+
index: true
|
|
99
|
+
},
|
|
100
|
+
errorMessage: { type: String, default: null },
|
|
101
|
+
attempts: { type: Number, default: 0 }
|
|
102
|
+
},
|
|
103
|
+
{ timestamps: true }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
relayTaskSchema.index({ opId: 1, nodeId: 1, taskType: 1 });
|
|
107
|
+
|
|
80
108
|
module.exports = {
|
|
81
109
|
NodeModel: mongoose.model('Node', nodeSchema),
|
|
82
110
|
FileModel: mongoose.model('File', fileSchema),
|
|
83
111
|
ShardModel: mongoose.model('Shard', shardSchema),
|
|
84
112
|
ShardPlacementModel: mongoose.model('ShardPlacement', shardPlacementSchema),
|
|
85
|
-
ReplicationTaskModel: mongoose.model('ReplicationTask', replicationTaskSchema)
|
|
113
|
+
ReplicationTaskModel: mongoose.model('ReplicationTask', replicationTaskSchema),
|
|
114
|
+
RelayTaskModel: mongoose.model('RelayTask', relayTaskSchema)
|
|
86
115
|
};
|
package/src/server/routes.js
CHANGED
|
@@ -6,7 +6,8 @@ const {
|
|
|
6
6
|
FileModel,
|
|
7
7
|
ShardModel,
|
|
8
8
|
ShardPlacementModel,
|
|
9
|
-
ReplicationTaskModel
|
|
9
|
+
ReplicationTaskModel,
|
|
10
|
+
RelayTaskModel
|
|
10
11
|
} = require('./models');
|
|
11
12
|
const {
|
|
12
13
|
chooseDistinctOnlineNodes,
|
|
@@ -236,10 +237,34 @@ function buildRoutes(config) {
|
|
|
236
237
|
});
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
const pendingRelayTasks = await RelayTaskModel.find({
|
|
241
|
+
nodeId,
|
|
242
|
+
status: 'pending'
|
|
243
|
+
})
|
|
244
|
+
.sort({ createdAt: 1 })
|
|
245
|
+
.limit(10)
|
|
246
|
+
.lean();
|
|
247
|
+
|
|
248
|
+
if (pendingRelayTasks.length > 0) {
|
|
249
|
+
await RelayTaskModel.updateMany(
|
|
250
|
+
{ _id: { $in: pendingRelayTasks.map((task) => task._id) } },
|
|
251
|
+
{ $set: { status: 'in_progress' }, $inc: { attempts: 1 } }
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const relayTasks = pendingRelayTasks.map((task) => ({
|
|
256
|
+
taskId: task._id.toString(),
|
|
257
|
+
taskType: task.taskType,
|
|
258
|
+
shardId: task.shardId,
|
|
259
|
+
fileId: task.fileId,
|
|
260
|
+
dataBase64: task.dataBase64
|
|
261
|
+
}));
|
|
262
|
+
|
|
239
263
|
res.json({
|
|
240
264
|
ok: true,
|
|
241
265
|
now: new Date().toISOString(),
|
|
242
|
-
replicationTasks: tasksWithSourceUrl
|
|
266
|
+
replicationTasks: tasksWithSourceUrl,
|
|
267
|
+
relayTasks
|
|
243
268
|
});
|
|
244
269
|
} catch (error) {
|
|
245
270
|
next(error);
|
|
@@ -293,6 +318,146 @@ function buildRoutes(config) {
|
|
|
293
318
|
}
|
|
294
319
|
});
|
|
295
320
|
|
|
321
|
+
router.post('/relay-tasks/:taskId/complete', async (req, res, next) => {
|
|
322
|
+
try {
|
|
323
|
+
const { taskId } = req.params;
|
|
324
|
+
const { nodeId, success, errorMessage, resultDataBase64 } = req.body;
|
|
325
|
+
|
|
326
|
+
const task = await RelayTaskModel.findById(taskId);
|
|
327
|
+
if (!task) {
|
|
328
|
+
return res.status(404).json({ error: 'relay task not found' });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (task.nodeId !== nodeId) {
|
|
332
|
+
return res.status(403).json({ error: 'nodeId does not match relay task target' });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (success) {
|
|
336
|
+
task.status = 'completed';
|
|
337
|
+
task.errorMessage = null;
|
|
338
|
+
task.resultDataBase64 = typeof resultDataBase64 === 'string' ? resultDataBase64 : null;
|
|
339
|
+
} else {
|
|
340
|
+
task.status = 'failed';
|
|
341
|
+
task.errorMessage = errorMessage || 'relay task failed';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await task.save();
|
|
345
|
+
res.json({ ok: true });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
next(error);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
router.post('/relay/shards/store', async (req, res, next) => {
|
|
352
|
+
try {
|
|
353
|
+
const { opId, shardId, fileId, nodeIds, dataBase64 } = req.body;
|
|
354
|
+
if (!shardId || !Array.isArray(nodeIds) || nodeIds.length === 0 || typeof dataBase64 !== 'string') {
|
|
355
|
+
return res.status(400).json({ error: 'shardId, nodeIds and dataBase64 are required' });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const normalizedNodeIds = [...new Set(nodeIds.filter((value) => typeof value === 'string' && value.length > 0))];
|
|
359
|
+
if (normalizedNodeIds.length === 0) {
|
|
360
|
+
return res.status(400).json({ error: 'nodeIds must contain at least one valid nodeId' });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const operationId = opId || uuidv4();
|
|
364
|
+
const docs = normalizedNodeIds.map((nodeId) => ({
|
|
365
|
+
opId: operationId,
|
|
366
|
+
taskType: 'store',
|
|
367
|
+
nodeId,
|
|
368
|
+
shardId,
|
|
369
|
+
fileId: fileId || null,
|
|
370
|
+
dataBase64,
|
|
371
|
+
status: 'pending'
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
await RelayTaskModel.insertMany(docs);
|
|
375
|
+
res.json({ ok: true, opId: operationId, queued: docs.length });
|
|
376
|
+
} catch (error) {
|
|
377
|
+
next(error);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
router.get('/relay/shards/store/:opId', async (req, res, next) => {
|
|
382
|
+
try {
|
|
383
|
+
const { opId } = req.params;
|
|
384
|
+
const tasks = await RelayTaskModel.find({ opId, taskType: 'store' }).lean();
|
|
385
|
+
if (tasks.length === 0) {
|
|
386
|
+
return res.status(404).json({ error: 'relay store operation not found' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const successfulNodeIds = tasks.filter((task) => task.status === 'completed').map((task) => task.nodeId);
|
|
390
|
+
const failed = tasks
|
|
391
|
+
.filter((task) => task.status === 'failed')
|
|
392
|
+
.map((task) => ({ nodeId: task.nodeId, errorMessage: task.errorMessage || 'failed' }));
|
|
393
|
+
|
|
394
|
+
res.json({
|
|
395
|
+
ok: true,
|
|
396
|
+
opId,
|
|
397
|
+
pendingCount: tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress').length,
|
|
398
|
+
successfulNodeIds,
|
|
399
|
+
failed
|
|
400
|
+
});
|
|
401
|
+
} catch (error) {
|
|
402
|
+
next(error);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
router.post('/relay/shards/fetch', async (req, res, next) => {
|
|
407
|
+
try {
|
|
408
|
+
const { opId, shardId, nodeIds } = req.body;
|
|
409
|
+
if (!shardId || !Array.isArray(nodeIds) || nodeIds.length === 0) {
|
|
410
|
+
return res.status(400).json({ error: 'shardId and nodeIds are required' });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const normalizedNodeIds = [...new Set(nodeIds.filter((value) => typeof value === 'string' && value.length > 0))];
|
|
414
|
+
if (normalizedNodeIds.length === 0) {
|
|
415
|
+
return res.status(400).json({ error: 'nodeIds must contain at least one valid nodeId' });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const operationId = opId || uuidv4();
|
|
419
|
+
const docs = normalizedNodeIds.map((nodeId) => ({
|
|
420
|
+
opId: operationId,
|
|
421
|
+
taskType: 'fetch',
|
|
422
|
+
nodeId,
|
|
423
|
+
shardId,
|
|
424
|
+
status: 'pending'
|
|
425
|
+
}));
|
|
426
|
+
|
|
427
|
+
await RelayTaskModel.insertMany(docs);
|
|
428
|
+
res.json({ ok: true, opId: operationId, queued: docs.length });
|
|
429
|
+
} catch (error) {
|
|
430
|
+
next(error);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
router.get('/relay/shards/fetch/:opId', async (req, res, next) => {
|
|
435
|
+
try {
|
|
436
|
+
const { opId } = req.params;
|
|
437
|
+
const tasks = await RelayTaskModel.find({ opId, taskType: 'fetch' }).lean();
|
|
438
|
+
if (tasks.length === 0) {
|
|
439
|
+
return res.status(404).json({ error: 'relay fetch operation not found' });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const completed = tasks.find((task) => task.status === 'completed' && task.resultDataBase64);
|
|
443
|
+
const failed = tasks
|
|
444
|
+
.filter((task) => task.status === 'failed')
|
|
445
|
+
.map((task) => ({ nodeId: task.nodeId, errorMessage: task.errorMessage || 'failed' }));
|
|
446
|
+
|
|
447
|
+
res.json({
|
|
448
|
+
ok: true,
|
|
449
|
+
opId,
|
|
450
|
+
pendingCount: tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress').length,
|
|
451
|
+
hasResult: Boolean(completed),
|
|
452
|
+
nodeId: completed?.nodeId || null,
|
|
453
|
+
resultDataBase64: completed?.resultDataBase64 || null,
|
|
454
|
+
failed
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
next(error);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
296
461
|
router.post('/files/register', async (req, res, next) => {
|
|
297
462
|
try {
|
|
298
463
|
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
|
|
@@ -446,6 +611,12 @@ function buildRoutes(config) {
|
|
|
446
611
|
{ shardId: { $in: shardIds } }
|
|
447
612
|
]
|
|
448
613
|
});
|
|
614
|
+
await RelayTaskModel.deleteMany({
|
|
615
|
+
$or: [
|
|
616
|
+
{ fileId },
|
|
617
|
+
{ shardId: { $in: shardIds } }
|
|
618
|
+
]
|
|
619
|
+
});
|
|
449
620
|
await ShardPlacementModel.deleteMany({ fileId });
|
|
450
621
|
await ShardModel.deleteMany({ fileId });
|
|
451
622
|
await FileModel.deleteOne({ fileId });
|
package/src/user/commands.js
CHANGED
|
@@ -27,6 +27,74 @@ function parseAesKey(keyBase64) {
|
|
|
27
27
|
return key;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function sleep(ms) {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 120000, pollMs = 1500 }) {
|
|
35
|
+
const opId = uuidv4();
|
|
36
|
+
await api.post('/relay/shards/store', {
|
|
37
|
+
opId,
|
|
38
|
+
shardId,
|
|
39
|
+
fileId,
|
|
40
|
+
nodeIds,
|
|
41
|
+
dataBase64: dataBuffer.toString('base64')
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const deadline = Date.now() + timeoutMs;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
const response = await api.get(`/relay/shards/store/${opId}`);
|
|
47
|
+
const payload = response.data || {};
|
|
48
|
+
if (Number(payload.pendingCount || 0) === 0) {
|
|
49
|
+
return {
|
|
50
|
+
successfulNodeIds: payload.successfulNodeIds || [],
|
|
51
|
+
failed: payload.failed || []
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Wait for donor heartbeats to pull relay tasks.
|
|
55
|
+
// eslint-disable-next-line no-await-in-loop
|
|
56
|
+
await sleep(pollMs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(`relay store timed out for shard ${shardId}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 120000, pollMs = 1500 }) {
|
|
63
|
+
const opId = uuidv4();
|
|
64
|
+
await api.post('/relay/shards/fetch', {
|
|
65
|
+
opId,
|
|
66
|
+
shardId,
|
|
67
|
+
nodeIds
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
const response = await api.get(`/relay/shards/fetch/${opId}`);
|
|
73
|
+
const payload = response.data || {};
|
|
74
|
+
if (payload.hasResult && payload.resultDataBase64) {
|
|
75
|
+
return {
|
|
76
|
+
nodeId: payload.nodeId,
|
|
77
|
+
data: Buffer.from(payload.resultDataBase64, 'base64'),
|
|
78
|
+
failed: payload.failed || []
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (Number(payload.pendingCount || 0) === 0) {
|
|
83
|
+
return {
|
|
84
|
+
nodeId: null,
|
|
85
|
+
data: null,
|
|
86
|
+
failed: payload.failed || []
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Wait for donor heartbeats to pull relay tasks.
|
|
91
|
+
// eslint-disable-next-line no-await-in-loop
|
|
92
|
+
await sleep(pollMs);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error(`relay fetch timed out for shard ${shardId}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
30
98
|
async function uploadFile(options) {
|
|
31
99
|
const filePath = path.resolve(options.file);
|
|
32
100
|
const serverUrl = options.server;
|
|
@@ -101,12 +169,28 @@ async function uploadFile(options) {
|
|
|
101
169
|
}
|
|
102
170
|
}
|
|
103
171
|
|
|
172
|
+
if (failedReplicas.length > 0) {
|
|
173
|
+
try {
|
|
174
|
+
const relayResult = await relayStoreShard(api, {
|
|
175
|
+
shardId,
|
|
176
|
+
fileId,
|
|
177
|
+
nodeIds: failedReplicas.map((replica) => replica.nodeId),
|
|
178
|
+
dataBuffer: encrypted.cipherText
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const promotedRelayReplicas = plannedReplicas.filter((replica) => relayResult.successfulNodeIds.includes(replica.nodeId));
|
|
182
|
+
successfulReplicas.push(...promotedRelayReplicas.filter((replica) => !successfulReplicas.some((item) => item.nodeId === replica.nodeId)));
|
|
183
|
+
} catch (relayError) {
|
|
184
|
+
console.warn(`[upload] relay store failed for shard ${shardId}: ${relayError.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
104
188
|
if (successfulReplicas.length === 0) {
|
|
105
189
|
throw new Error(`failed to write shard ${shardId} to any replica: ${failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ')}`);
|
|
106
190
|
}
|
|
107
191
|
|
|
108
192
|
if (failedReplicas.length > 0) {
|
|
109
|
-
console.warn(`[upload] shard ${shardId} failed on ${failedReplicas.length} replica(s),
|
|
193
|
+
console.warn(`[upload] shard ${shardId} failed on ${failedReplicas.length} direct replica(s), final successes=${successfulReplicas.length}`);
|
|
110
194
|
}
|
|
111
195
|
|
|
112
196
|
await api.post('/shards/register', {
|
|
@@ -197,6 +281,21 @@ async function downloadFile(options) {
|
|
|
197
281
|
}
|
|
198
282
|
}
|
|
199
283
|
|
|
284
|
+
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
|
285
|
+
try {
|
|
286
|
+
const relayResult = await relayFetchShard(api, {
|
|
287
|
+
shardId: shard.shardId,
|
|
288
|
+
nodeIds: shard.replicas.map((replica) => replica.nodeId)
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (relayResult.data && sha256Hex(relayResult.data) === shard.checksum) {
|
|
292
|
+
encryptedBuffer = relayResult.data;
|
|
293
|
+
}
|
|
294
|
+
} catch (relayError) {
|
|
295
|
+
console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
200
299
|
if (!encryptedBuffer) {
|
|
201
300
|
throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
|
|
202
301
|
}
|