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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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 = {
@@ -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 || 30000),
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)
@@ -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
  };
@@ -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 });
@@ -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), but succeeded on ${successfulReplicas.length}`);
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
  }