nodio-cli 1.0.6 → 1.0.8
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/config.js +1 -1
- package/src/server/models.js +33 -2
- package/src/server/routes.js +190 -2
- package/src/user/commands.js +120 -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/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
|
);
|
|
@@ -77,10 +79,39 @@ replicationTaskSchema.index(
|
|
|
77
79
|
}
|
|
78
80
|
);
|
|
79
81
|
|
|
82
|
+
const relayTaskSchema = new mongoose.Schema(
|
|
83
|
+
{
|
|
84
|
+
opId: { type: String, required: true, index: true },
|
|
85
|
+
taskType: {
|
|
86
|
+
type: String,
|
|
87
|
+
enum: ['store', 'fetch'],
|
|
88
|
+
required: true,
|
|
89
|
+
index: true
|
|
90
|
+
},
|
|
91
|
+
nodeId: { type: String, required: true, index: true },
|
|
92
|
+
shardId: { type: String, required: true, index: true },
|
|
93
|
+
fileId: { type: String, default: null, index: true },
|
|
94
|
+
dataBase64: { type: String, default: null },
|
|
95
|
+
resultDataBase64: { type: String, default: null },
|
|
96
|
+
status: {
|
|
97
|
+
type: String,
|
|
98
|
+
enum: ['pending', 'in_progress', 'completed', 'failed'],
|
|
99
|
+
default: 'pending',
|
|
100
|
+
index: true
|
|
101
|
+
},
|
|
102
|
+
errorMessage: { type: String, default: null },
|
|
103
|
+
attempts: { type: Number, default: 0 }
|
|
104
|
+
},
|
|
105
|
+
{ timestamps: true }
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
relayTaskSchema.index({ opId: 1, nodeId: 1, taskType: 1 });
|
|
109
|
+
|
|
80
110
|
module.exports = {
|
|
81
111
|
NodeModel: mongoose.model('Node', nodeSchema),
|
|
82
112
|
FileModel: mongoose.model('File', fileSchema),
|
|
83
113
|
ShardModel: mongoose.model('Shard', shardSchema),
|
|
84
114
|
ShardPlacementModel: mongoose.model('ShardPlacement', shardPlacementSchema),
|
|
85
|
-
ReplicationTaskModel: mongoose.model('ReplicationTask', replicationTaskSchema)
|
|
115
|
+
ReplicationTaskModel: mongoose.model('ReplicationTask', replicationTaskSchema),
|
|
116
|
+
RelayTaskModel: mongoose.model('RelayTask', relayTaskSchema)
|
|
86
117
|
};
|
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,16 +237,57 @@ 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);
|
|
246
271
|
}
|
|
247
272
|
});
|
|
248
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
|
+
|
|
249
291
|
router.post('/replication-tasks/:taskId/complete', async (req, res, next) => {
|
|
250
292
|
try {
|
|
251
293
|
const { taskId } = req.params;
|
|
@@ -293,6 +335,146 @@ function buildRoutes(config) {
|
|
|
293
335
|
}
|
|
294
336
|
});
|
|
295
337
|
|
|
338
|
+
router.post('/relay-tasks/:taskId/complete', async (req, res, next) => {
|
|
339
|
+
try {
|
|
340
|
+
const { taskId } = req.params;
|
|
341
|
+
const { nodeId, success, errorMessage, resultDataBase64 } = req.body;
|
|
342
|
+
|
|
343
|
+
const task = await RelayTaskModel.findById(taskId);
|
|
344
|
+
if (!task) {
|
|
345
|
+
return res.status(404).json({ error: 'relay task not found' });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (task.nodeId !== nodeId) {
|
|
349
|
+
return res.status(403).json({ error: 'nodeId does not match relay task target' });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (success) {
|
|
353
|
+
task.status = 'completed';
|
|
354
|
+
task.errorMessage = null;
|
|
355
|
+
task.resultDataBase64 = typeof resultDataBase64 === 'string' ? resultDataBase64 : null;
|
|
356
|
+
} else {
|
|
357
|
+
task.status = 'failed';
|
|
358
|
+
task.errorMessage = errorMessage || 'relay task failed';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await task.save();
|
|
362
|
+
res.json({ ok: true });
|
|
363
|
+
} catch (error) {
|
|
364
|
+
next(error);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
router.post('/relay/shards/store', async (req, res, next) => {
|
|
369
|
+
try {
|
|
370
|
+
const { opId, shardId, fileId, nodeIds, dataBase64 } = req.body;
|
|
371
|
+
if (!shardId || !Array.isArray(nodeIds) || nodeIds.length === 0 || typeof dataBase64 !== 'string') {
|
|
372
|
+
return res.status(400).json({ error: 'shardId, nodeIds and dataBase64 are required' });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const normalizedNodeIds = [...new Set(nodeIds.filter((value) => typeof value === 'string' && value.length > 0))];
|
|
376
|
+
if (normalizedNodeIds.length === 0) {
|
|
377
|
+
return res.status(400).json({ error: 'nodeIds must contain at least one valid nodeId' });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const operationId = opId || uuidv4();
|
|
381
|
+
const docs = normalizedNodeIds.map((nodeId) => ({
|
|
382
|
+
opId: operationId,
|
|
383
|
+
taskType: 'store',
|
|
384
|
+
nodeId,
|
|
385
|
+
shardId,
|
|
386
|
+
fileId: fileId || null,
|
|
387
|
+
dataBase64,
|
|
388
|
+
status: 'pending'
|
|
389
|
+
}));
|
|
390
|
+
|
|
391
|
+
await RelayTaskModel.insertMany(docs);
|
|
392
|
+
res.json({ ok: true, opId: operationId, queued: docs.length });
|
|
393
|
+
} catch (error) {
|
|
394
|
+
next(error);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
router.get('/relay/shards/store/:opId', async (req, res, next) => {
|
|
399
|
+
try {
|
|
400
|
+
const { opId } = req.params;
|
|
401
|
+
const tasks = await RelayTaskModel.find({ opId, taskType: 'store' }).lean();
|
|
402
|
+
if (tasks.length === 0) {
|
|
403
|
+
return res.status(404).json({ error: 'relay store operation not found' });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const successfulNodeIds = tasks.filter((task) => task.status === 'completed').map((task) => task.nodeId);
|
|
407
|
+
const failed = tasks
|
|
408
|
+
.filter((task) => task.status === 'failed')
|
|
409
|
+
.map((task) => ({ nodeId: task.nodeId, errorMessage: task.errorMessage || 'failed' }));
|
|
410
|
+
|
|
411
|
+
res.json({
|
|
412
|
+
ok: true,
|
|
413
|
+
opId,
|
|
414
|
+
pendingCount: tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress').length,
|
|
415
|
+
successfulNodeIds,
|
|
416
|
+
failed
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
next(error);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
router.post('/relay/shards/fetch', async (req, res, next) => {
|
|
424
|
+
try {
|
|
425
|
+
const { opId, shardId, nodeIds } = req.body;
|
|
426
|
+
if (!shardId || !Array.isArray(nodeIds) || nodeIds.length === 0) {
|
|
427
|
+
return res.status(400).json({ error: 'shardId and nodeIds are required' });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const normalizedNodeIds = [...new Set(nodeIds.filter((value) => typeof value === 'string' && value.length > 0))];
|
|
431
|
+
if (normalizedNodeIds.length === 0) {
|
|
432
|
+
return res.status(400).json({ error: 'nodeIds must contain at least one valid nodeId' });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const operationId = opId || uuidv4();
|
|
436
|
+
const docs = normalizedNodeIds.map((nodeId) => ({
|
|
437
|
+
opId: operationId,
|
|
438
|
+
taskType: 'fetch',
|
|
439
|
+
nodeId,
|
|
440
|
+
shardId,
|
|
441
|
+
status: 'pending'
|
|
442
|
+
}));
|
|
443
|
+
|
|
444
|
+
await RelayTaskModel.insertMany(docs);
|
|
445
|
+
res.json({ ok: true, opId: operationId, queued: docs.length });
|
|
446
|
+
} catch (error) {
|
|
447
|
+
next(error);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
router.get('/relay/shards/fetch/:opId', async (req, res, next) => {
|
|
452
|
+
try {
|
|
453
|
+
const { opId } = req.params;
|
|
454
|
+
const tasks = await RelayTaskModel.find({ opId, taskType: 'fetch' }).lean();
|
|
455
|
+
if (tasks.length === 0) {
|
|
456
|
+
return res.status(404).json({ error: 'relay fetch operation not found' });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const completed = tasks.find((task) => task.status === 'completed' && task.resultDataBase64);
|
|
460
|
+
const failed = tasks
|
|
461
|
+
.filter((task) => task.status === 'failed')
|
|
462
|
+
.map((task) => ({ nodeId: task.nodeId, errorMessage: task.errorMessage || 'failed' }));
|
|
463
|
+
|
|
464
|
+
res.json({
|
|
465
|
+
ok: true,
|
|
466
|
+
opId,
|
|
467
|
+
pendingCount: tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress').length,
|
|
468
|
+
hasResult: Boolean(completed),
|
|
469
|
+
nodeId: completed?.nodeId || null,
|
|
470
|
+
resultDataBase64: completed?.resultDataBase64 || null,
|
|
471
|
+
failed
|
|
472
|
+
});
|
|
473
|
+
} catch (error) {
|
|
474
|
+
next(error);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
296
478
|
router.post('/files/register', async (req, res, next) => {
|
|
297
479
|
try {
|
|
298
480
|
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
|
|
@@ -446,6 +628,12 @@ function buildRoutes(config) {
|
|
|
446
628
|
{ shardId: { $in: shardIds } }
|
|
447
629
|
]
|
|
448
630
|
});
|
|
631
|
+
await RelayTaskModel.deleteMany({
|
|
632
|
+
$or: [
|
|
633
|
+
{ fileId },
|
|
634
|
+
{ shardId: { $in: shardIds } }
|
|
635
|
+
]
|
|
636
|
+
});
|
|
449
637
|
await ShardPlacementModel.deleteMany({ fileId });
|
|
450
638
|
await ShardModel.deleteMany({ fileId });
|
|
451
639
|
await FileModel.deleteOne({ fileId });
|
package/src/user/commands.js
CHANGED
|
@@ -27,6 +27,94 @@ 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 = 90000, pollMs = 500 }) {
|
|
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
|
+
// 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
|
+
|
|
54
|
+
const deadline = Date.now() + timeoutMs;
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
const response = await api.get(`/relay/shards/store/${opId}`);
|
|
57
|
+
const payload = response.data || {};
|
|
58
|
+
if (Number(payload.pendingCount || 0) === 0) {
|
|
59
|
+
return {
|
|
60
|
+
successfulNodeIds: payload.successfulNodeIds || [],
|
|
61
|
+
failed: payload.failed || []
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Polls every 500ms; with 10s heartbeat, donor should respond within 10s+network latency
|
|
65
|
+
// eslint-disable-next-line no-await-in-loop
|
|
66
|
+
await sleep(pollMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new Error(`relay store timed out for shard ${shardId}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 90000, pollMs = 500 }) {
|
|
73
|
+
const opId = uuidv4();
|
|
74
|
+
await api.post('/relay/shards/fetch', {
|
|
75
|
+
opId,
|
|
76
|
+
shardId,
|
|
77
|
+
nodeIds
|
|
78
|
+
});
|
|
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
|
+
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
const response = await api.get(`/relay/shards/fetch/${opId}`);
|
|
93
|
+
const payload = response.data || {};
|
|
94
|
+
if (payload.hasResult && payload.resultDataBase64) {
|
|
95
|
+
return {
|
|
96
|
+
nodeId: payload.nodeId,
|
|
97
|
+
data: Buffer.from(payload.resultDataBase64, 'base64'),
|
|
98
|
+
failed: payload.failed || []
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (Number(payload.pendingCount || 0) === 0) {
|
|
103
|
+
return {
|
|
104
|
+
nodeId: null,
|
|
105
|
+
data: null,
|
|
106
|
+
failed: payload.failed || []
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Wait for donor heartbeats to pull relay tasks.
|
|
111
|
+
// eslint-disable-next-line no-await-in-loop
|
|
112
|
+
await sleep(pollMs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw new Error(`relay fetch timed out for shard ${shardId}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
30
118
|
async function uploadFile(options) {
|
|
31
119
|
const filePath = path.resolve(options.file);
|
|
32
120
|
const serverUrl = options.server;
|
|
@@ -101,12 +189,28 @@ async function uploadFile(options) {
|
|
|
101
189
|
}
|
|
102
190
|
}
|
|
103
191
|
|
|
192
|
+
if (failedReplicas.length > 0) {
|
|
193
|
+
try {
|
|
194
|
+
const relayResult = await relayStoreShard(api, {
|
|
195
|
+
shardId,
|
|
196
|
+
fileId,
|
|
197
|
+
nodeIds: failedReplicas.map((replica) => replica.nodeId),
|
|
198
|
+
dataBuffer: encrypted.cipherText
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const promotedRelayReplicas = plannedReplicas.filter((replica) => relayResult.successfulNodeIds.includes(replica.nodeId));
|
|
202
|
+
successfulReplicas.push(...promotedRelayReplicas.filter((replica) => !successfulReplicas.some((item) => item.nodeId === replica.nodeId)));
|
|
203
|
+
} catch (relayError) {
|
|
204
|
+
console.warn(`[upload] relay store failed for shard ${shardId}: ${relayError.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
104
208
|
if (successfulReplicas.length === 0) {
|
|
105
209
|
throw new Error(`failed to write shard ${shardId} to any replica: ${failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ')}`);
|
|
106
210
|
}
|
|
107
211
|
|
|
108
212
|
if (failedReplicas.length > 0) {
|
|
109
|
-
console.warn(`[upload] shard ${shardId} failed on ${failedReplicas.length} replica(s),
|
|
213
|
+
console.warn(`[upload] shard ${shardId} failed on ${failedReplicas.length} direct replica(s), final successes=${successfulReplicas.length}`);
|
|
110
214
|
}
|
|
111
215
|
|
|
112
216
|
await api.post('/shards/register', {
|
|
@@ -197,6 +301,21 @@ async function downloadFile(options) {
|
|
|
197
301
|
}
|
|
198
302
|
}
|
|
199
303
|
|
|
304
|
+
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
|
305
|
+
try {
|
|
306
|
+
const relayResult = await relayFetchShard(api, {
|
|
307
|
+
shardId: shard.shardId,
|
|
308
|
+
nodeIds: shard.replicas.map((replica) => replica.nodeId)
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (relayResult.data && sha256Hex(relayResult.data) === shard.checksum) {
|
|
312
|
+
encryptedBuffer = relayResult.data;
|
|
313
|
+
}
|
|
314
|
+
} catch (relayError) {
|
|
315
|
+
console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
200
319
|
if (!encryptedBuffer) {
|
|
201
320
|
throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
|
|
202
321
|
}
|