nodio-cli 1.0.9 → 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.9",
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,7 +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);
123
- const directTimeoutMs = Number(options.directTimeoutMs || 4000);
109
+ const directTimeoutMs = Number(options.directTimeoutMs || 1200);
110
+ const relayFirst = Boolean(options.relayFirst);
124
111
 
125
112
  if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
126
113
  throw new Error('shard-size-mb must be greater than 0');
@@ -177,29 +164,40 @@ async function uploadFile(options) {
177
164
  throw new Error(`placement failed for ${shardId}: expected ${replicas} replicas`);
178
165
  }
179
166
 
180
- const directWriteResults = await Promise.allSettled(
181
- plannedReplicas.map(async (replica) => {
182
- const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
183
- await axios.put(putUrl, encrypted.cipherText, {
184
- headers: { 'Content-Type': 'application/octet-stream' },
185
- timeout: directTimeoutMs
186
- });
187
- return replica;
188
- })
189
- );
190
-
191
- const successfulReplicas = directWriteResults
192
- .filter((result) => result.status === 'fulfilled')
193
- .map((result) => result.value);
194
-
195
- const failedReplicas = directWriteResults
196
- .map((result, index) => ({ result, replica: plannedReplicas[index] }))
197
- .filter((entry) => entry.result.status === 'rejected')
198
- .map((entry) => ({
199
- nodeId: entry.replica.nodeId,
200
- url: entry.replica.url,
201
- error: entry.result.reason?.message || 'direct write failed'
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)'
202
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
+ }));
200
+ }
203
201
 
204
202
  if (failedReplicas.length > 0) {
205
203
  try {
@@ -272,7 +270,8 @@ async function downloadFile(options) {
272
270
  const serverUrl = options.server;
273
271
  const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
274
272
  const keyBuffer = parseAesKey(options.keyBase64);
275
- const directTimeoutMs = Number(options.directTimeoutMs || 4000);
273
+ const directTimeoutMs = Number(options.directTimeoutMs || 1200);
274
+ const relayFirst = Boolean(options.relayFirst);
276
275
 
277
276
  if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
278
277
  throw new Error('direct-timeout-ms must be greater than 0');
@@ -300,24 +299,26 @@ async function downloadFile(options) {
300
299
  }
301
300
 
302
301
  let encryptedBuffer = null;
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;
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;
321
+ }
321
322
  }
322
323
 
323
324
  if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
package/src/user/index.js CHANGED
@@ -14,7 +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', '4000')
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')
18
19
  .option('--key-base64 <key>', '32-byte AES key in base64 (optional)')
19
20
  .action(async (options) => {
20
21
  await uploadFile(options);
@@ -27,7 +28,8 @@ program
27
28
  .requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
28
29
  .option('--server <url>', 'central server URL', 'https://api.nodio.me')
29
30
  .option('--output <path>', 'output file path')
30
- .option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '4000')
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')
31
33
  .action(async (options) => {
32
34
  await downloadFile(options);
33
35
  });