nodio-cli 1.0.5 → 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 +121 -6
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;
|
|
@@ -85,12 +153,44 @@ async function uploadFile(options) {
|
|
|
85
153
|
throw new Error(`placement failed for ${shardId}: expected ${replicas} replicas`);
|
|
86
154
|
}
|
|
87
155
|
|
|
156
|
+
const successfulReplicas = [];
|
|
157
|
+
const failedReplicas = [];
|
|
158
|
+
|
|
88
159
|
for (const replica of plannedReplicas) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
160
|
+
try {
|
|
161
|
+
const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
|
|
162
|
+
await axios.put(putUrl, encrypted.cipherText, {
|
|
163
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
164
|
+
timeout: 30000
|
|
165
|
+
});
|
|
166
|
+
successfulReplicas.push(replica);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
failedReplicas.push({ nodeId: replica.nodeId, url: replica.url, error: error.message });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
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
|
+
|
|
188
|
+
if (successfulReplicas.length === 0) {
|
|
189
|
+
throw new Error(`failed to write shard ${shardId} to any replica: ${failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ')}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (failedReplicas.length > 0) {
|
|
193
|
+
console.warn(`[upload] shard ${shardId} failed on ${failedReplicas.length} direct replica(s), final successes=${successfulReplicas.length}`);
|
|
94
194
|
}
|
|
95
195
|
|
|
96
196
|
await api.post('/shards/register', {
|
|
@@ -99,7 +199,7 @@ async function uploadFile(options) {
|
|
|
99
199
|
order,
|
|
100
200
|
sizeBytes: encrypted.cipherText.length,
|
|
101
201
|
checksum,
|
|
102
|
-
nodeIds:
|
|
202
|
+
nodeIds: successfulReplicas.map((replica) => replica.nodeId)
|
|
103
203
|
});
|
|
104
204
|
|
|
105
205
|
encryptionShardMeta.push({
|
|
@@ -181,6 +281,21 @@ async function downloadFile(options) {
|
|
|
181
281
|
}
|
|
182
282
|
}
|
|
183
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
|
+
|
|
184
299
|
if (!encryptedBuffer) {
|
|
185
300
|
throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
|
|
186
301
|
}
|