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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.0.5",
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;
@@ -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
- const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
90
- await axios.put(putUrl, encrypted.cipherText, {
91
- headers: { 'Content-Type': 'application/octet-stream' },
92
- timeout: 30000
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: plannedReplicas.map((replica) => replica.nodeId)
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
  }