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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Nodio distributed storage network",
5
5
  "main": "src/server/index.js",
6
6
  "type": "commonjs",
@@ -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 = {
@@ -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
  };
@@ -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 });
@@ -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), but succeeded on ${successfulReplicas.length}`);
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
  }