nodio-cli 1.0.8 → 1.0.10

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.8",
3
+ "version": "1.0.10",
4
4
  "description": "Nodio distributed storage network",
5
5
  "main": "src/server/index.js",
6
6
  "type": "commonjs",
package/src/node/index.js CHANGED
@@ -80,7 +80,8 @@ program
80
80
  .option('--capacity-gb <gb>', 'donated capacity in GB', '10')
81
81
  .option('--auto-port-start <port>', 'start of auto-port range', '5001')
82
82
  .option('--auto-port-end <port>', 'end of auto-port range', '5999')
83
- .option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000');
83
+ .option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000')
84
+ .option('--relay-poll-ms <ms>', 'relay task poll interval in milliseconds', '1000');
84
85
 
85
86
  program.action(async (capacityArg, options) => {
86
87
  const autoPortStart = Number(options.autoPortStart);
@@ -92,6 +93,7 @@ program.action(async (capacityArg, options) => {
92
93
  const parsedCapacityArg = parseCapacityGb(capacityArg);
93
94
  const capacityGb = parsedCapacityArg ?? Number(options.capacityGb);
94
95
  const heartbeatMs = Number(options.heartbeatMs);
96
+ const relayPollMs = Number(options.relayPollMs);
95
97
  const advertisedHost = options.host === 'auto' ? detectAdvertisedHost() : options.host;
96
98
  const storageDir = options.storageDir
97
99
  ? path.resolve(options.storageDir)
@@ -109,6 +111,9 @@ program.action(async (capacityArg, options) => {
109
111
  if (!Number.isFinite(heartbeatMs) || heartbeatMs <= 0) {
110
112
  throw new Error('heartbeat-ms must be greater than 0');
111
113
  }
114
+ if (!Number.isFinite(relayPollMs) || relayPollMs <= 0) {
115
+ throw new Error('relay-poll-ms must be greater than 0');
116
+ }
112
117
 
113
118
  if (isLoopbackHost(advertisedHost)) {
114
119
  console.warn('[nodio-node] warning: loopback host is advertised; only this machine can reach this donor');
@@ -121,7 +126,8 @@ program.action(async (capacityArg, options) => {
121
126
  port,
122
127
  storageDir,
123
128
  capacityBytes: Math.floor(capacityGb * 1024 * 1024 * 1024),
124
- heartbeatIntervalMs: heartbeatMs
129
+ heartbeatIntervalMs: heartbeatMs,
130
+ relayPollIntervalMs: relayPollMs
125
131
  });
126
132
 
127
133
  await runtime.start();
@@ -15,8 +15,11 @@ class NodioNodeRuntime {
15
15
  this.port = Number(options.port);
16
16
  this.capacityBytes = Number(options.capacityBytes);
17
17
  this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || 30000);
18
+ this.relayPollIntervalMs = Number(options.relayPollIntervalMs || 1000);
18
19
  this.shardStore = new LocalShardStore(options.storageDir);
19
20
  this.heartbeatTimer = null;
21
+ this.relayPollTimer = null;
22
+ this.relayPullInFlight = false;
20
23
  }
21
24
 
22
25
  async start() {
@@ -37,6 +40,14 @@ class NodioNodeRuntime {
37
40
  console.error('[heartbeat]', error.message);
38
41
  }
39
42
  }, this.heartbeatIntervalMs);
43
+
44
+ this.relayPollTimer = setInterval(async () => {
45
+ try {
46
+ await this.pollRelayTasks();
47
+ } catch (error) {
48
+ console.error('[relay-pull]', error.message);
49
+ }
50
+ }, this.relayPollIntervalMs);
40
51
  }
41
52
 
42
53
  async startShardServer() {
@@ -128,6 +139,11 @@ class NodioNodeRuntime {
128
139
  this.heartbeatIntervalMs = interval;
129
140
  }
130
141
 
142
+ const relayInterval = Number(response.data.relayPollIntervalMs);
143
+ if (Number.isFinite(relayInterval) && relayInterval > 0) {
144
+ this.relayPollIntervalMs = relayInterval;
145
+ }
146
+
131
147
  console.log(
132
148
  `Registered node ${this.nodeId} | min replicas: ${response.data.minReplicas} | emergency floor: ${response.data.emergencyReplicaFloor}`
133
149
  );
@@ -146,16 +162,36 @@ class NodioNodeRuntime {
146
162
  await this.executeReplicationTask(task);
147
163
  }
148
164
 
149
- const relayTasks = response.data.relayTasks || [];
150
- for (const task of relayTasks) {
151
- await this.executeRelayTask(task);
152
- }
153
-
154
165
  console.log(
155
- `[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length} | relayTasks=${relayTasks.length}`
166
+ `[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length}`
156
167
  );
157
168
  }
158
169
 
170
+ async pollRelayTasks() {
171
+ if (this.relayPullInFlight) {
172
+ return;
173
+ }
174
+
175
+ this.relayPullInFlight = true;
176
+ try {
177
+ const response = await axios.post(`${this.serverUrl}/api/nodes/relay-pull`, {
178
+ nodeId: this.nodeId
179
+ });
180
+
181
+ const relayTasks = response.data.relayTasks || [];
182
+ for (const task of relayTasks) {
183
+ // eslint-disable-next-line no-await-in-loop
184
+ await this.executeRelayTask(task);
185
+ }
186
+
187
+ if (relayTasks.length > 0) {
188
+ console.log(`[relay-pull] ${new Date().toISOString()} | relayTasks=${relayTasks.length}`);
189
+ }
190
+ } finally {
191
+ this.relayPullInFlight = false;
192
+ }
193
+ }
194
+
159
195
  async executeReplicationTask(task) {
160
196
  const sourceShardUrl = `${normalizeUrl(task.sourceUrl)}/shards/${task.shardId}`;
161
197
  try {
@@ -6,6 +6,7 @@ function getServerConfig() {
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
8
  heartbeatIntervalMs: Number(process.env.NODIO_HEARTBEAT_INTERVAL_MS || 10000),
9
+ relayPollIntervalMs: Number(process.env.NODIO_RELAY_POLL_INTERVAL_MS || 1000),
9
10
  offlineAfterMisses: Number(process.env.NODIO_OFFLINE_AFTER_MISSES || 3),
10
11
  minReplicas: Number(process.env.NODIO_MIN_REPLICAS || 5),
11
12
  emergencyReplicaFloor: Number(process.env.NODIO_EMERGENCY_REPLICA_FLOOR || 2)
@@ -23,6 +23,31 @@ function normalizeUrl(url) {
23
23
  return String(url || '').replace(/\/+$/, '');
24
24
  }
25
25
 
26
+ async function claimPendingRelayTasks(nodeId, limit = 10) {
27
+ const pendingRelayTasks = await RelayTaskModel.find({
28
+ nodeId,
29
+ status: 'pending'
30
+ })
31
+ .sort({ createdAt: 1 })
32
+ .limit(limit)
33
+ .lean();
34
+
35
+ if (pendingRelayTasks.length > 0) {
36
+ await RelayTaskModel.updateMany(
37
+ { _id: { $in: pendingRelayTasks.map((task) => task._id) } },
38
+ { $set: { status: 'in_progress' }, $inc: { attempts: 1 } }
39
+ );
40
+ }
41
+
42
+ return pendingRelayTasks.map((task) => ({
43
+ taskId: task._id.toString(),
44
+ taskType: task.taskType,
45
+ shardId: task.shardId,
46
+ fileId: task.fileId,
47
+ dataBase64: task.dataBase64
48
+ }));
49
+ }
50
+
26
51
  function buildRoutes(config) {
27
52
  const router = express.Router();
28
53
 
@@ -135,6 +160,7 @@ function buildRoutes(config) {
135
160
  nodeId: node.nodeId,
136
161
  status: node.status,
137
162
  heartbeatIntervalMs: config.heartbeatIntervalMs,
163
+ relayPollIntervalMs: config.relayPollIntervalMs,
138
164
  minReplicas: config.minReplicas,
139
165
  emergencyReplicaFloor: config.emergencyReplicaFloor
140
166
  });
@@ -237,33 +263,40 @@ function buildRoutes(config) {
237
263
  });
238
264
  }
239
265
 
240
- const pendingRelayTasks = await RelayTaskModel.find({
241
- nodeId,
242
- status: 'pending'
243
- })
244
- .sort({ createdAt: 1 })
245
- .limit(10)
246
- .lean();
266
+ res.json({
267
+ ok: true,
268
+ now: new Date().toISOString(),
269
+ replicationTasks: tasksWithSourceUrl,
270
+ relayTasks: []
271
+ });
272
+ } catch (error) {
273
+ next(error);
274
+ }
275
+ });
247
276
 
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
- );
277
+ router.post('/nodes/relay-pull', async (req, res, next) => {
278
+ try {
279
+ const { nodeId } = req.body;
280
+ if (!nodeId) {
281
+ return res.status(400).json({ error: 'nodeId is required' });
253
282
  }
254
283
 
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
- }));
284
+ const node = await NodeModel.findOne({ nodeId });
285
+ if (!node) {
286
+ return res.status(404).json({ error: 'node not found' });
287
+ }
288
+
289
+ const relayTasks = await claimPendingRelayTasks(nodeId, 10);
290
+
291
+ if (relayTasks.length > 0 && node.pendingRelayAlert) {
292
+ node.pendingRelayAlert = false;
293
+ node.pendingRelayAlertAt = null;
294
+ await node.save();
295
+ }
262
296
 
263
297
  res.json({
264
298
  ok: true,
265
299
  now: new Date().toISOString(),
266
- replicationTasks: tasksWithSourceUrl,
267
300
  relayTasks
268
301
  });
269
302
  } catch (error) {
@@ -31,7 +31,7 @@ function sleep(ms) {
31
31
  return new Promise((resolve) => setTimeout(resolve, ms));
32
32
  }
33
33
 
34
- async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 90000, pollMs = 500 }) {
34
+ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 45000, pollMs = 200 }) {
35
35
  const opId = uuidv4();
36
36
  await api.post('/relay/shards/store', {
37
37
  opId,
@@ -42,14 +42,7 @@ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, time
42
42
  });
43
43
 
44
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
- }
45
+ await Promise.allSettled(nodeIds.map((nodeId) => api.post(`/nodes/${nodeId}/alert-relay-pending`)));
53
46
 
54
47
  const deadline = Date.now() + timeoutMs;
55
48
  while (Date.now() < deadline) {
@@ -69,7 +62,7 @@ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, time
69
62
  throw new Error(`relay store timed out for shard ${shardId}`);
70
63
  }
71
64
 
72
- async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 90000, pollMs = 500 }) {
65
+ async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 45000, pollMs = 200 }) {
73
66
  const opId = uuidv4();
74
67
  await api.post('/relay/shards/fetch', {
75
68
  opId,
@@ -78,14 +71,7 @@ async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 90000, pollM
78
71
  });
79
72
 
80
73
  // 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
- }
74
+ await Promise.allSettled(nodeIds.map((nodeId) => api.post(`/nodes/${nodeId}/alert-relay-pending`)));
89
75
 
90
76
  const deadline = Date.now() + timeoutMs;
91
77
  while (Date.now() < deadline) {
@@ -120,6 +106,8 @@ async function uploadFile(options) {
120
106
  const serverUrl = options.server;
121
107
  const shardSizeMb = Number(options.shardSizeMb || 1);
122
108
  const replicas = Number(options.replicas || 5);
109
+ const directTimeoutMs = Number(options.directTimeoutMs || 1200);
110
+ const relayFirst = Boolean(options.relayFirst);
123
111
 
124
112
  if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
125
113
  throw new Error('shard-size-mb must be greater than 0');
@@ -127,6 +115,9 @@ async function uploadFile(options) {
127
115
  if (!Number.isInteger(replicas) || replicas < 5) {
128
116
  throw new Error('replicas must be an integer >= 5');
129
117
  }
118
+ if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
119
+ throw new Error('direct-timeout-ms must be greater than 0');
120
+ }
130
121
 
131
122
  const shardSizeBytes = Math.floor(shardSizeMb * 1024 * 1024);
132
123
  const plainBuffer = await fs.readFile(filePath);
@@ -173,20 +164,39 @@ async function uploadFile(options) {
173
164
  throw new Error(`placement failed for ${shardId}: expected ${replicas} replicas`);
174
165
  }
175
166
 
176
- const successfulReplicas = [];
177
- const failedReplicas = [];
178
-
179
- for (const replica of plannedReplicas) {
180
- try {
181
- const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
182
- await axios.put(putUrl, encrypted.cipherText, {
183
- headers: { 'Content-Type': 'application/octet-stream' },
184
- timeout: 30000
185
- });
186
- successfulReplicas.push(replica);
187
- } catch (error) {
188
- failedReplicas.push({ nodeId: replica.nodeId, url: replica.url, error: error.message });
189
- }
167
+ let successfulReplicas = [];
168
+ let failedReplicas = [];
169
+
170
+ if (relayFirst) {
171
+ failedReplicas = plannedReplicas.map((replica) => ({
172
+ nodeId: replica.nodeId,
173
+ url: replica.url,
174
+ error: 'direct write skipped (relay-first)'
175
+ }));
176
+ } else {
177
+ const directWriteResults = await Promise.allSettled(
178
+ plannedReplicas.map(async (replica) => {
179
+ const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
180
+ await axios.put(putUrl, encrypted.cipherText, {
181
+ headers: { 'Content-Type': 'application/octet-stream' },
182
+ timeout: directTimeoutMs
183
+ });
184
+ return replica;
185
+ })
186
+ );
187
+
188
+ successfulReplicas = directWriteResults
189
+ .filter((result) => result.status === 'fulfilled')
190
+ .map((result) => result.value);
191
+
192
+ failedReplicas = directWriteResults
193
+ .map((result, index) => ({ result, replica: plannedReplicas[index] }))
194
+ .filter((entry) => entry.result.status === 'rejected')
195
+ .map((entry) => ({
196
+ nodeId: entry.replica.nodeId,
197
+ url: entry.replica.url,
198
+ error: entry.result.reason?.message || 'direct write failed'
199
+ }));
190
200
  }
191
201
 
192
202
  if (failedReplicas.length > 0) {
@@ -260,6 +270,12 @@ async function downloadFile(options) {
260
270
  const serverUrl = options.server;
261
271
  const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
262
272
  const keyBuffer = parseAesKey(options.keyBase64);
273
+ const directTimeoutMs = Number(options.directTimeoutMs || 1200);
274
+ const relayFirst = Boolean(options.relayFirst);
275
+
276
+ if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
277
+ throw new Error('direct-timeout-ms must be greater than 0');
278
+ }
263
279
 
264
280
  const api = createApiClient(serverUrl);
265
281
  const manifestResponse = await api.get(`/files/${fileId}/manifest`);
@@ -283,21 +299,25 @@ async function downloadFile(options) {
283
299
  }
284
300
 
285
301
  let encryptedBuffer = null;
286
- for (const replica of shard.replicas || []) {
287
- try {
288
- const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
289
- const response = await axios.get(getUrl, {
290
- responseType: 'arraybuffer',
291
- timeout: 30000
292
- });
293
- const candidate = Buffer.from(response.data);
294
- if (sha256Hex(candidate) !== shard.checksum) {
295
- continue;
296
- }
297
- encryptedBuffer = candidate;
298
- break;
299
- } catch {
300
- continue;
302
+ if (!relayFirst) {
303
+ const directFetchResults = await Promise.allSettled(
304
+ (shard.replicas || []).map(async (replica) => {
305
+ const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
306
+ const response = await axios.get(getUrl, {
307
+ responseType: 'arraybuffer',
308
+ timeout: directTimeoutMs
309
+ });
310
+ const candidate = Buffer.from(response.data);
311
+ if (sha256Hex(candidate) !== shard.checksum) {
312
+ throw new Error('checksum mismatch');
313
+ }
314
+ return candidate;
315
+ })
316
+ );
317
+
318
+ const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
319
+ if (successfulDirectFetch) {
320
+ encryptedBuffer = successfulDirectFetch.value;
301
321
  }
302
322
  }
303
323
 
package/src/user/index.js CHANGED
@@ -14,6 +14,8 @@ program
14
14
  .option('--file-id <id>', 'custom file ID (optional)')
15
15
  .option('--shard-size-mb <mb>', 'plaintext shard size in MB', '1')
16
16
  .option('--replicas <count>', 'replicas per shard (minimum 5)', '5')
17
+ .option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
18
+ .option('--relay-first', 'skip direct donor attempts and use relay path immediately')
17
19
  .option('--key-base64 <key>', '32-byte AES key in base64 (optional)')
18
20
  .action(async (options) => {
19
21
  await uploadFile(options);
@@ -26,6 +28,8 @@ program
26
28
  .requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
27
29
  .option('--server <url>', 'central server URL', 'https://api.nodio.me')
28
30
  .option('--output <path>', 'output file path')
31
+ .option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
32
+ .option('--relay-first', 'skip direct donor attempts and use relay path immediately')
29
33
  .action(async (options) => {
30
34
  await downloadFile(options);
31
35
  });