@nosana/node 1.1.47-rc → 1.1.48-rc
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/dist/package.json +1 -1
- package/dist/src/NodeManager/index.js +1 -9
- package/dist/src/NodeManager/monitoring/reportError.d.ts +13 -0
- package/dist/src/NodeManager/monitoring/reportError.js +20 -0
- package/dist/src/NodeManager/node/api/ApiHandler.d.ts +15 -2
- package/dist/src/NodeManager/node/api/ApiHandler.js +86 -14
- package/dist/src/NodeManager/node/task/TaskManager.js +17 -1
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.d.ts +2 -0
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.js +4 -0
- package/dist/src/NodeManager/node/task/operations/runTaskManagerOperation.js +10 -6
- package/dist/src/NodeManager/node/task/operations/stopTaskManagerOperation.js +1 -1
- package/dist/src/NodeManager/provider/Provider.js +14 -2
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +9 -1
- package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +5 -2
- package/dist/src/NodeManager/repository/NodeRepository.d.ts +16 -1
- package/dist/src/NodeManager/repository/NodeRepository.js +135 -36
- package/dist/src/NodeManager/utils/tolerateBrokenStdio.d.ts +13 -0
- package/dist/src/NodeManager/utils/tolerateBrokenStdio.js +23 -0
- package/dist/src/index.js +3 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/package.json
CHANGED
|
@@ -7,12 +7,11 @@ import { logStreaming } from './monitoring/streaming/LogStreamer.js';
|
|
|
7
7
|
import { consoleLogging } from './monitoring/log/console/ConsoleLogger.js';
|
|
8
8
|
import { validateCLIVersion } from '../version/index.js';
|
|
9
9
|
import { checkForMaintenance } from './checkForMaintenance.js';
|
|
10
|
-
import { getSDK } from './sdk/index.js';
|
|
11
10
|
import { ping } from './monitoring/ping/PingHandler.js';
|
|
12
11
|
import { LogMonitoringRegistry } from './monitoring/LogMonitoringRegistry.js';
|
|
13
12
|
import { checkWSLStatus } from './utils/wslCheck.js';
|
|
14
|
-
import { HostManager } from './node/market/hostManager.js';
|
|
15
13
|
import { IN_JOB_LOOP_ENV } from '../exitCodes.js';
|
|
14
|
+
import { reportError } from './monitoring/reportError.js';
|
|
16
15
|
export default class NodeManager {
|
|
17
16
|
constructor(options) {
|
|
18
17
|
this.exiting = false;
|
|
@@ -283,10 +282,3 @@ export default class NodeManager {
|
|
|
283
282
|
});
|
|
284
283
|
}
|
|
285
284
|
}
|
|
286
|
-
const reportError = async (data) => {
|
|
287
|
-
const nosana = getSDK();
|
|
288
|
-
return HostManager.reportError({
|
|
289
|
-
address: nosana.solana.provider.wallet.publicKey.toString(),
|
|
290
|
-
...data,
|
|
291
|
-
});
|
|
292
|
-
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type NodeErrorReport = {
|
|
2
|
+
error_type: string;
|
|
3
|
+
error_name: string;
|
|
4
|
+
error_message: string;
|
|
5
|
+
error_stack: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Send an error to the host, so it is visible off the node.
|
|
9
|
+
*
|
|
10
|
+
* Never throws or rejects: callers are already handling a failure, some from
|
|
11
|
+
* signal handlers where a rejection would be the only trace left.
|
|
12
|
+
*/
|
|
13
|
+
export declare function reportError(report: NodeErrorReport): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getSDK } from '../sdk/index.js';
|
|
2
|
+
import { HostManager } from '../node/market/hostManager.js';
|
|
3
|
+
/**
|
|
4
|
+
* Send an error to the host, so it is visible off the node.
|
|
5
|
+
*
|
|
6
|
+
* Never throws or rejects: callers are already handling a failure, some from
|
|
7
|
+
* signal handlers where a rejection would be the only trace left.
|
|
8
|
+
*/
|
|
9
|
+
export async function reportError(report) {
|
|
10
|
+
try {
|
|
11
|
+
const nosana = getSDK();
|
|
12
|
+
await HostManager.reportError({
|
|
13
|
+
address: nosana.solana.provider.wallet.publicKey.toString(),
|
|
14
|
+
...report,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Best effort: an unreachable host must not mask the original failure.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -11,15 +11,28 @@ export declare class ApiHandler {
|
|
|
11
11
|
private server;
|
|
12
12
|
private wss;
|
|
13
13
|
private eventEmitter;
|
|
14
|
-
private
|
|
14
|
+
private apiCheckTimer;
|
|
15
|
+
private failedProxyRestarts;
|
|
15
16
|
constructor(sdk: SDK, repository: NodeRepository, provider: Provider, port: number);
|
|
16
17
|
start(): Promise<string>;
|
|
17
18
|
preventMultipleApiStarts(): Promise<void>;
|
|
18
|
-
testApiServerOnce(server: string): Promise<boolean>;
|
|
19
|
+
testApiServerOnce(server: string, timeoutMs?: number): Promise<boolean>;
|
|
19
20
|
private restartApiProxy;
|
|
20
21
|
private stopApiProxy;
|
|
21
22
|
private startWebSocketServer;
|
|
22
23
|
private startApiCheck;
|
|
24
|
+
/**
|
|
25
|
+
* Check the API, scheduling the next check once this one is done: a rebuild
|
|
26
|
+
* takes as long as an image pull, and two cannot both create the proxy.
|
|
27
|
+
*/
|
|
28
|
+
private scheduleApiCheck;
|
|
29
|
+
/**
|
|
30
|
+
* End the process once the proxy counts as lost, so a fresh one can build it:
|
|
31
|
+
* nothing else in this process rebuilds it. A job in flight outranks that,
|
|
32
|
+
* since resuming a flow runs its operations again, and an unreachable registry
|
|
33
|
+
* is not something a new process fixes.
|
|
34
|
+
*/
|
|
35
|
+
private endNodeIfProxyIsLost;
|
|
23
36
|
private registerRoutes;
|
|
24
37
|
private listen;
|
|
25
38
|
private stopApiCheck;
|
|
@@ -9,6 +9,16 @@ import { applyLoggingProxyToClass } from '../../monitoring/proxy/loggingProxy.js
|
|
|
9
9
|
import { verifyJobOwnerSignatureMiddleware, verifyWSJobOwnerSignatureMiddleware, verifyWSNodeOrJobOwnerSignatureMiddleware, } from './middlewares/index.js';
|
|
10
10
|
import { getNodeInfoRoute, getJobResultsRoute, getServiceUrlRoute, postJobDefinitionRoute, postServiceStopRoute, wssLogRoute, wssStatusRoute, getCurrentGroupStatusHandler, getGroupStatusHandler, getOperationsStatusHandler, getOperationStatusHandler, restartGroupOperationHandler, restartOperationHandler, stopGroupOperationHandler, stopOperationHandler, wssTaskManagerLogRoute, getJobDefinitionRoute, getJobInfoRoute, getJobStatsRoute, getJobStatsStreamRoute, } from './routes/index.js';
|
|
11
11
|
import { NodeAlreadyActiveError } from '../../errors/NodeAlreadyActiveError.js';
|
|
12
|
+
import { reportError } from '../../monitoring/reportError.js';
|
|
13
|
+
import { EXIT_CODES } from '../../../exitCodes.js';
|
|
14
|
+
import { TaskManagerRegistry } from '../task/TaskManagerRegistry.js';
|
|
15
|
+
/** Rebuilds to fail before the process is replaced. */
|
|
16
|
+
const MAX_PROXY_RESTART_FAILURES = 3;
|
|
17
|
+
const API_CHECK_TIMEOUT_MS = 10 * 1000;
|
|
18
|
+
/** Longer than the check: a slow answer means a node is there, not absent. */
|
|
19
|
+
const API_TEST_TIMEOUT_MS = 60 * 1000;
|
|
20
|
+
const API_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
21
|
+
const API_CHECK_MIN_GAP_MS = 30 * 1000;
|
|
12
22
|
export class ApiHandler {
|
|
13
23
|
constructor(sdk, repository, provider, port) {
|
|
14
24
|
this.sdk = sdk;
|
|
@@ -18,7 +28,8 @@ export class ApiHandler {
|
|
|
18
28
|
this.server = null;
|
|
19
29
|
this.wss = null; // WebSocket server
|
|
20
30
|
this.eventEmitter = ApiEventEmitter.getInstance();
|
|
21
|
-
this.
|
|
31
|
+
this.apiCheckTimer = null;
|
|
32
|
+
this.failedProxyRestarts = 0;
|
|
22
33
|
this.address = this.sdk.solana.provider.wallet.publicKey;
|
|
23
34
|
this.api = express();
|
|
24
35
|
this.api.use(cors());
|
|
@@ -41,9 +52,13 @@ export class ApiHandler {
|
|
|
41
52
|
throw new NodeAlreadyActiveError(this.address.toString());
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
|
-
async testApiServerOnce(server) {
|
|
55
|
+
async testApiServerOnce(server, timeoutMs = API_TEST_TIMEOUT_MS) {
|
|
45
56
|
try {
|
|
46
|
-
|
|
57
|
+
// A tunnel that is down accepts the connection and answers nothing, so
|
|
58
|
+
// without a deadline this waits minutes for the runtime's own.
|
|
59
|
+
const response = await fetch(`${server}`, {
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
61
|
+
});
|
|
47
62
|
if (!response.ok)
|
|
48
63
|
return false;
|
|
49
64
|
const responseText = await response.json();
|
|
@@ -66,7 +81,6 @@ export class ApiHandler {
|
|
|
66
81
|
}
|
|
67
82
|
async stopApiProxy() {
|
|
68
83
|
await this.provider.stopReverseProxyApi(this.address.toString());
|
|
69
|
-
this.stopApiCheck();
|
|
70
84
|
}
|
|
71
85
|
async startWebSocketServer() {
|
|
72
86
|
this.wss = new WebSocket.Server({ noServer: true });
|
|
@@ -111,15 +125,72 @@ export class ApiHandler {
|
|
|
111
125
|
});
|
|
112
126
|
}
|
|
113
127
|
startApiCheck(server) {
|
|
114
|
-
if (!this.
|
|
115
|
-
this.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
if (!this.apiCheckTimer) {
|
|
129
|
+
this.scheduleApiCheck(server, API_CHECK_INTERVAL_MS);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Check the API, scheduling the next check once this one is done: a rebuild
|
|
134
|
+
* takes as long as an image pull, and two cannot both create the proxy.
|
|
135
|
+
*/
|
|
136
|
+
scheduleApiCheck(server, delayMs) {
|
|
137
|
+
this.apiCheckTimer = setTimeout(async () => {
|
|
138
|
+
const startedAt = Date.now();
|
|
139
|
+
try {
|
|
140
|
+
const isAlive = await this.testApiServerOnce(server, API_CHECK_TIMEOUT_MS);
|
|
141
|
+
if (isAlive) {
|
|
142
|
+
this.failedProxyRestarts = 0;
|
|
143
|
+
return;
|
|
120
144
|
}
|
|
121
|
-
|
|
145
|
+
// Stopped while this check waited, so the proxy is not wanted.
|
|
146
|
+
if (!this.apiCheckTimer)
|
|
147
|
+
return;
|
|
148
|
+
console.log('API proxy is offline, restarting..');
|
|
149
|
+
await this.restartApiProxy();
|
|
150
|
+
this.failedProxyRestarts = 0;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// Nothing awaits this check, so a throw would leave unhandled.
|
|
154
|
+
const message = error?.message ?? String(error);
|
|
155
|
+
const description = `Could not restart the API proxy: ${message}. The node is not reachable until it is rebuilt.`;
|
|
156
|
+
console.error(description);
|
|
157
|
+
void reportError({
|
|
158
|
+
error_type: 'apiProxyRestartFailure',
|
|
159
|
+
error_name: error?.name ?? 'Error',
|
|
160
|
+
error_message: description,
|
|
161
|
+
error_stack: error?.stack ?? '',
|
|
162
|
+
});
|
|
163
|
+
this.endNodeIfProxyIsLost(error);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
// Cleared by stopApiCheck, which is how a stopped API stays stopped.
|
|
167
|
+
// The delay is what is left of the interval, so a long rebuild does not
|
|
168
|
+
// push the next check out.
|
|
169
|
+
if (this.apiCheckTimer) {
|
|
170
|
+
this.scheduleApiCheck(server, Math.max(API_CHECK_INTERVAL_MS - (Date.now() - startedAt), API_CHECK_MIN_GAP_MS));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}, delayMs);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* End the process once the proxy counts as lost, so a fresh one can build it:
|
|
177
|
+
* nothing else in this process rebuilds it. A job in flight outranks that,
|
|
178
|
+
* since resuming a flow runs its operations again, and an unreachable registry
|
|
179
|
+
* is not something a new process fixes.
|
|
180
|
+
*/
|
|
181
|
+
endNodeIfProxyIsLost(error) {
|
|
182
|
+
if (error?.eventType === 'image-pull-error') {
|
|
183
|
+
return;
|
|
122
184
|
}
|
|
185
|
+
this.failedProxyRestarts++;
|
|
186
|
+
if (this.failedProxyRestarts < MAX_PROXY_RESTART_FAILURES)
|
|
187
|
+
return;
|
|
188
|
+
if (TaskManagerRegistry.getInstance().size() > 0) {
|
|
189
|
+
console.error('The API proxy could not be rebuilt, waiting for the running job to finish.');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
console.error('The API proxy could not be rebuilt, restarting the node.');
|
|
193
|
+
process.exit(EXIT_CODES.RESTART);
|
|
123
194
|
}
|
|
124
195
|
async registerRoutes() {
|
|
125
196
|
// Attach require objects to routes
|
|
@@ -168,9 +239,9 @@ export class ApiHandler {
|
|
|
168
239
|
});
|
|
169
240
|
}
|
|
170
241
|
stopApiCheck() {
|
|
171
|
-
if (this.
|
|
172
|
-
|
|
173
|
-
this.
|
|
242
|
+
if (this.apiCheckTimer) {
|
|
243
|
+
clearTimeout(this.apiCheckTimer);
|
|
244
|
+
this.apiCheckTimer = null;
|
|
174
245
|
}
|
|
175
246
|
}
|
|
176
247
|
stopServerAndWebSocket() {
|
|
@@ -182,6 +253,7 @@ export class ApiHandler {
|
|
|
182
253
|
}
|
|
183
254
|
}
|
|
184
255
|
async stop() {
|
|
256
|
+
this.stopApiCheck();
|
|
185
257
|
await this.stopApiProxy();
|
|
186
258
|
this.stopServerAndWebSocket();
|
|
187
259
|
}
|
|
@@ -17,6 +17,7 @@ import { setResult, setResults, setHost, setDefaults, setContainerIp, rehydrateE
|
|
|
17
17
|
import { configs } from '../../configs/configs.js';
|
|
18
18
|
import { getSDK } from '../../sdk/index.js';
|
|
19
19
|
import { pkg } from '../../../static/index.js';
|
|
20
|
+
import { reportError } from '../../monitoring/reportError.js';
|
|
20
21
|
export const StopReasons = {
|
|
21
22
|
COMPLETED: 'completed',
|
|
22
23
|
EXPIRED: 'expired',
|
|
@@ -483,7 +484,22 @@ export default class TaskManager {
|
|
|
483
484
|
[StopReasons.QUIT]: { ops: Statuses.FAILED, flow: Statuses.FAILED },
|
|
484
485
|
[StopReasons.UNKNOWN]: { ops: Statuses.FAILED, flow: Statuses.FAILED },
|
|
485
486
|
};
|
|
486
|
-
|
|
487
|
+
const stop = map[reason];
|
|
488
|
+
// A reason outside the known stops, undefined included, reads as unknown.
|
|
489
|
+
// Reported, since it means the caller lost the reason and the flow is about
|
|
490
|
+
// to be recorded as failed on the strength of that.
|
|
491
|
+
if (!stop) {
|
|
492
|
+
const description = `Unknown stop reason '${reason}', reading it as unknown.`;
|
|
493
|
+
console.warn(description);
|
|
494
|
+
void reportError({
|
|
495
|
+
error_type: 'unknownStopReason',
|
|
496
|
+
error_name: 'Error',
|
|
497
|
+
error_message: description,
|
|
498
|
+
error_stack: new Error(description).stack ?? '',
|
|
499
|
+
});
|
|
500
|
+
return map[StopReasons.UNKNOWN][type];
|
|
501
|
+
}
|
|
502
|
+
return stop[type];
|
|
487
503
|
}
|
|
488
504
|
getOpStateIndex(opId) {
|
|
489
505
|
const index = this.operations.findIndex((op) => op.id === opId);
|
|
@@ -44,6 +44,8 @@ export declare class TaskManagerRegistry {
|
|
|
44
44
|
register(jobId: string, manager: TaskManager): void;
|
|
45
45
|
get(jobId: string): TaskManager | undefined;
|
|
46
46
|
remove(jobId: string): void;
|
|
47
|
+
/** Jobs with a task manager, which is to say the jobs this node is running. */
|
|
48
|
+
size(): number;
|
|
47
49
|
has(jobId: string): boolean;
|
|
48
50
|
stop(): Promise<void>;
|
|
49
51
|
}
|
|
@@ -54,6 +54,10 @@ export class TaskManagerRegistry {
|
|
|
54
54
|
remove(jobId) {
|
|
55
55
|
this.registry.delete(jobId);
|
|
56
56
|
}
|
|
57
|
+
/** Jobs with a task manager, which is to say the jobs this node is running. */
|
|
58
|
+
size() {
|
|
59
|
+
return this.registry.size;
|
|
60
|
+
}
|
|
57
61
|
has(jobId) {
|
|
58
62
|
return this.registry.has(jobId);
|
|
59
63
|
}
|
|
@@ -220,17 +220,19 @@ export async function runTaskManagerOperation(flow, op, dependent) {
|
|
|
220
220
|
: exitCode === 0
|
|
221
221
|
? Statuses.SUCCESS
|
|
222
222
|
: Statuses.FAILED;
|
|
223
|
-
|
|
223
|
+
// Empty once the flow is gone, so results are still passed to whatever
|
|
224
|
+
// depends on this operation.
|
|
225
|
+
const logs = this.repository.getOpState(this.job, index)?.logs ?? [];
|
|
224
226
|
const results = op.results
|
|
225
227
|
? createResultsObject(op.results)
|
|
226
228
|
: undefined;
|
|
227
229
|
if (results && op.results) {
|
|
228
|
-
for (const logObj of
|
|
230
|
+
for (const logObj of logs) {
|
|
229
231
|
extractResultFromLog(results, logObj, op.results);
|
|
230
232
|
}
|
|
231
233
|
}
|
|
232
234
|
this.repository.updateOpState(this.job, index, {
|
|
233
|
-
logs
|
|
235
|
+
logs,
|
|
234
236
|
results,
|
|
235
237
|
exitCode,
|
|
236
238
|
endTime: Date.now(),
|
|
@@ -272,12 +274,14 @@ export async function runTaskManagerOperation(flow, op, dependent) {
|
|
|
272
274
|
else {
|
|
273
275
|
emitter.emit('log', 'Operation Completed', 'info');
|
|
274
276
|
}
|
|
275
|
-
|
|
277
|
+
// Empty once the flow is gone, so results are still passed to whatever
|
|
278
|
+
// depends on this operation.
|
|
279
|
+
const logs = this.repository.getOpState(this.job, index)?.logs ?? [];
|
|
276
280
|
const results = op.results
|
|
277
281
|
? createResultsObject(op.results)
|
|
278
282
|
: undefined;
|
|
279
283
|
if (results && op.results) {
|
|
280
|
-
for (const logObj of
|
|
284
|
+
for (const logObj of logs) {
|
|
281
285
|
extractResultFromLog(results, logObj, op.results);
|
|
282
286
|
}
|
|
283
287
|
}
|
|
@@ -298,7 +302,7 @@ export async function runTaskManagerOperation(flow, op, dependent) {
|
|
|
298
302
|
}
|
|
299
303
|
this.repository.updateOpState(this.job, index, {
|
|
300
304
|
results,
|
|
301
|
-
logs
|
|
305
|
+
logs,
|
|
302
306
|
exitCode: 2,
|
|
303
307
|
status,
|
|
304
308
|
endTime: Date.now(),
|
|
@@ -100,7 +100,7 @@ export async function stopTaskManagerOperation(group, opId) {
|
|
|
100
100
|
this.operationStatus.set(opId, OperationProgressStatuses.STOPPED);
|
|
101
101
|
const index = this.getOpStateIndex(opId);
|
|
102
102
|
const opState = this.repository.getOpState(this.job, index);
|
|
103
|
-
const alreadyEnded = !!opState
|
|
103
|
+
const alreadyEnded = !!opState?.endTime;
|
|
104
104
|
if (!alreadyEnded) {
|
|
105
105
|
this.repository.updateOpState(this.job, index, {
|
|
106
106
|
status: this.getStatus(StopReasons.STOPPED, 'ops'),
|
|
@@ -51,10 +51,16 @@ export class Provider {
|
|
|
51
51
|
await this.containerOrchestration.createNetwork(networkName);
|
|
52
52
|
const networks = {};
|
|
53
53
|
networks[networkName] = {};
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
const startup = new AbortController();
|
|
55
|
+
this.proxyStartupAbortController = startup;
|
|
56
|
+
await this.containerOrchestration.pullImage(this.frpcImage, undefined, startup);
|
|
56
57
|
this.resourceManager.images.setImage(this.frpcImage);
|
|
57
58
|
const doesFrpcExist = await this.containerOrchestration.doesContainerExist(frpc_name);
|
|
59
|
+
// Stopped while the image was being pulled, so the proxy is not wanted:
|
|
60
|
+
// creating it now would put back what the caller has taken down.
|
|
61
|
+
if (startup.signal.aborted) {
|
|
62
|
+
throw new Error('API proxy setup was stopped');
|
|
63
|
+
}
|
|
58
64
|
if (!doesFrpcExist) {
|
|
59
65
|
await this.containerOrchestration.runFlowContainer(this.frpcImage, {
|
|
60
66
|
name: 'frpc-api-' + address,
|
|
@@ -113,6 +119,12 @@ export class Provider {
|
|
|
113
119
|
});
|
|
114
120
|
}
|
|
115
121
|
}
|
|
122
|
+
// The runtime carries a create through to the end, so a setup stopped
|
|
123
|
+
// during one takes the container back off rather than leaving it up.
|
|
124
|
+
if (startup.signal.aborted) {
|
|
125
|
+
await this.containerOrchestration.stopAndDeleteContainer(frpc_name);
|
|
126
|
+
throw new Error('API proxy setup was stopped');
|
|
127
|
+
}
|
|
116
128
|
}
|
|
117
129
|
catch (error) {
|
|
118
130
|
throw error;
|
|
@@ -235,7 +235,15 @@ export class DockerContainerOrchestration {
|
|
|
235
235
|
const container = await this.docker.createContainer({
|
|
236
236
|
...createDockerRunOptions(image, args, this.gpu),
|
|
237
237
|
});
|
|
238
|
-
|
|
238
|
+
try {
|
|
239
|
+
await container.start();
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
// A created container holds its name whether it ran or not, and nothing
|
|
243
|
+
// else can take that name until it is gone.
|
|
244
|
+
await this.stopAndDeleteContainer(container.id);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
239
247
|
return container;
|
|
240
248
|
}
|
|
241
249
|
catch (error) {
|
|
@@ -67,8 +67,11 @@ export class PodmanContainerOrchestration extends DockerContainerOrchestration {
|
|
|
67
67
|
return container;
|
|
68
68
|
}
|
|
69
69
|
else {
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const message = (await start.json()).message;
|
|
71
|
+
// A created container holds its name whether it ran or not, and
|
|
72
|
+
// nothing else can take that name until it is gone.
|
|
73
|
+
await this.stopAndDeleteContainer(createResult.Id);
|
|
74
|
+
throw new Error('Cannot start container: ' + message);
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
error = await create.json();
|
|
@@ -4,6 +4,17 @@ import type { NodeDb, ResourceHistory, VolumeResource } from '../db/index.js';
|
|
|
4
4
|
export declare class NodeRepository {
|
|
5
5
|
private db;
|
|
6
6
|
constructor(db: LowSync<NodeDb>);
|
|
7
|
+
private lastPersistError?;
|
|
8
|
+
private lastPersistReportAt;
|
|
9
|
+
/**
|
|
10
|
+
* Write the database to disk, reporting rather than throwing when it cannot.
|
|
11
|
+
*
|
|
12
|
+
* lowdb writes synchronously, so a disk fault throws into container handlers,
|
|
13
|
+
* where it escapes uncaught, and into the awaited flow paths, where it fails
|
|
14
|
+
* the operation. The in-memory update is already applied, so only durability
|
|
15
|
+
* depends on this.
|
|
16
|
+
*/
|
|
17
|
+
private persist;
|
|
7
18
|
getFlow(id: string): Flow;
|
|
8
19
|
getFlows(): {
|
|
9
20
|
[key: string]: Flow;
|
|
@@ -21,7 +32,11 @@ export declare class NodeRepository {
|
|
|
21
32
|
status?: string;
|
|
22
33
|
error: Error | unknown;
|
|
23
34
|
}): void;
|
|
24
|
-
|
|
35
|
+
/** Undefined once the flow is gone: container handlers outlive it. */
|
|
36
|
+
private opStateFor;
|
|
37
|
+
/** The diagnostics, filled in for an op state stored without them. */
|
|
38
|
+
private diagnosticsFor;
|
|
39
|
+
getOpState(id: string, index: number): OpState | undefined;
|
|
25
40
|
updateOpState(id: string, opIndex: number, updatedFields: Partial<OpState>): void;
|
|
26
41
|
updateOpStateExitCode(id: string, opIndex: number, exitCode: number): void;
|
|
27
42
|
updateOpStateLogs(id: string, opIndex: number, log: any): void;
|
|
@@ -1,7 +1,70 @@
|
|
|
1
|
+
import { reportError } from '../monitoring/reportError.js';
|
|
2
|
+
import { isTerminalGone } from '../utils/tolerateBrokenStdio.js';
|
|
1
3
|
const MAX_LOGS = 24999;
|
|
4
|
+
/** How often an unchanged persist failure is repeated, rather than per write. */
|
|
5
|
+
const PERSIST_REPORT_INTERVAL_MS = 5 * 60 * 1000;
|
|
6
|
+
/** A dead terminal must not resurrect the throw a caller is containing. */
|
|
7
|
+
function printToOperator(message) {
|
|
8
|
+
try {
|
|
9
|
+
console.error(message);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (!isTerminalGone(error))
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A persist failure and what the operator has to do about this errno. Until it
|
|
18
|
+
* is done the node runs on in-memory state and loses it on restart.
|
|
19
|
+
*/
|
|
20
|
+
function describePersistFailure(message) {
|
|
21
|
+
let advice = 'Check that the node data directory is writable.';
|
|
22
|
+
if (message.includes('ENOSPC')) {
|
|
23
|
+
advice =
|
|
24
|
+
'The disk is full. Free space on the node — job state cannot be saved until then, ' +
|
|
25
|
+
'and will be lost if the node restarts.';
|
|
26
|
+
}
|
|
27
|
+
else if (message.includes('EROFS')) {
|
|
28
|
+
advice = 'The node data directory is mounted read-only and must be made writable.';
|
|
29
|
+
}
|
|
30
|
+
return `Failed to save node state: ${message}. ${advice}`;
|
|
31
|
+
}
|
|
2
32
|
export class NodeRepository {
|
|
3
33
|
constructor(db) {
|
|
4
34
|
this.db = db;
|
|
35
|
+
this.lastPersistReportAt = 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Write the database to disk, reporting rather than throwing when it cannot.
|
|
39
|
+
*
|
|
40
|
+
* lowdb writes synchronously, so a disk fault throws into container handlers,
|
|
41
|
+
* where it escapes uncaught, and into the awaited flow paths, where it fails
|
|
42
|
+
* the operation. The in-memory update is already applied, so only durability
|
|
43
|
+
* depends on this.
|
|
44
|
+
*/
|
|
45
|
+
persist() {
|
|
46
|
+
try {
|
|
47
|
+
this.db.write();
|
|
48
|
+
this.lastPersistError = undefined;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const message = error?.message ?? String(error);
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const isRepeat = message === this.lastPersistError &&
|
|
54
|
+
now - this.lastPersistReportAt < PERSIST_REPORT_INTERVAL_MS;
|
|
55
|
+
if (isRepeat)
|
|
56
|
+
return;
|
|
57
|
+
this.lastPersistError = message;
|
|
58
|
+
this.lastPersistReportAt = now;
|
|
59
|
+
const description = describePersistFailure(message);
|
|
60
|
+
printToOperator(description);
|
|
61
|
+
void reportError({
|
|
62
|
+
error_type: 'persistFailure',
|
|
63
|
+
error_name: error?.name ?? 'Error',
|
|
64
|
+
error_message: description,
|
|
65
|
+
error_stack: error?.stack ?? '',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
5
68
|
}
|
|
6
69
|
getFlow(id) {
|
|
7
70
|
return this.db.data.flows[id];
|
|
@@ -11,32 +74,35 @@ export class NodeRepository {
|
|
|
11
74
|
}
|
|
12
75
|
setflow(id, flow) {
|
|
13
76
|
this.db.data.flows[id] = flow;
|
|
14
|
-
this.
|
|
77
|
+
this.persist();
|
|
15
78
|
}
|
|
16
79
|
deleteflow(id) {
|
|
17
80
|
delete this.db.data.flows[id];
|
|
18
|
-
this.
|
|
81
|
+
this.persist();
|
|
19
82
|
}
|
|
20
83
|
addOpstate(id, opstate) {
|
|
21
|
-
this.db.data.flows[id]
|
|
22
|
-
|
|
84
|
+
const opStates = this.db.data.flows[id]?.state?.opStates;
|
|
85
|
+
if (!opStates)
|
|
86
|
+
return;
|
|
87
|
+
opStates.push(opstate);
|
|
88
|
+
this.persist();
|
|
23
89
|
}
|
|
24
90
|
getFlowState(id) {
|
|
25
91
|
return this.db.data.flows[id]?.state;
|
|
26
92
|
}
|
|
27
93
|
updateflowState(id, updatedFields) {
|
|
28
|
-
|
|
29
|
-
|
|
94
|
+
const state = this.db.data.flows[id]?.state;
|
|
95
|
+
if (!state)
|
|
96
|
+
return;
|
|
97
|
+
Object.assign(state, updatedFields);
|
|
98
|
+
this.persist();
|
|
30
99
|
}
|
|
31
100
|
updateflowStateSecret(id, updatedFields) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
...updatedFields,
|
|
38
|
-
};
|
|
39
|
-
this.db.write();
|
|
101
|
+
const state = this.db.data.flows[id]?.state;
|
|
102
|
+
if (!state)
|
|
103
|
+
return;
|
|
104
|
+
state.secrets = { ...state.secrets, ...updatedFields };
|
|
105
|
+
this.persist();
|
|
40
106
|
}
|
|
41
107
|
getFlowSecret(id, key) {
|
|
42
108
|
const secrets = this.db.data.flows[id]?.state?.secrets ?? {};
|
|
@@ -52,23 +118,46 @@ export class NodeRepository {
|
|
|
52
118
|
this.db.data.flows[id].state.errors = [];
|
|
53
119
|
}
|
|
54
120
|
this.db.data.flows[id].state.errors.push(error instanceof Error ? error.message : error);
|
|
55
|
-
this.
|
|
121
|
+
this.persist();
|
|
122
|
+
}
|
|
123
|
+
/** Undefined once the flow is gone: container handlers outlive it. */
|
|
124
|
+
opStateFor(id, opIndex) {
|
|
125
|
+
return this.db.data.flows[id]?.state?.opStates?.[opIndex];
|
|
126
|
+
}
|
|
127
|
+
/** The diagnostics, filled in for an op state stored without them. */
|
|
128
|
+
diagnosticsFor(opState) {
|
|
129
|
+
const reason = opState.diagnostics?.reason ?? {
|
|
130
|
+
hostShutDown: false,
|
|
131
|
+
jobStopped: false,
|
|
132
|
+
jobExpired: false,
|
|
133
|
+
};
|
|
134
|
+
opState.diagnostics = { ...opState.diagnostics, reason };
|
|
135
|
+
return opState.diagnostics;
|
|
56
136
|
}
|
|
57
137
|
getOpState(id, index) {
|
|
58
|
-
return this.
|
|
138
|
+
return this.opStateFor(id, index);
|
|
59
139
|
}
|
|
60
140
|
updateOpState(id, opIndex, updatedFields) {
|
|
61
|
-
|
|
62
|
-
|
|
141
|
+
const opState = this.opStateFor(id, opIndex);
|
|
142
|
+
if (!opState)
|
|
143
|
+
return;
|
|
144
|
+
Object.assign(opState, updatedFields);
|
|
145
|
+
this.persist();
|
|
63
146
|
}
|
|
64
147
|
updateOpStateExitCode(id, opIndex, exitCode) {
|
|
65
|
-
this.
|
|
66
|
-
|
|
148
|
+
const opState = this.opStateFor(id, opIndex);
|
|
149
|
+
if (!opState)
|
|
150
|
+
return;
|
|
151
|
+
opState.exitCode = exitCode;
|
|
152
|
+
this.persist();
|
|
67
153
|
}
|
|
68
154
|
updateOpStateLogs(id, opIndex, log) {
|
|
69
|
-
const
|
|
155
|
+
const opState = this.opStateFor(id, opIndex);
|
|
156
|
+
if (!opState)
|
|
157
|
+
return;
|
|
158
|
+
const logs = opState.logs;
|
|
70
159
|
if (!logs) {
|
|
71
|
-
|
|
160
|
+
opState.logs = [log];
|
|
72
161
|
}
|
|
73
162
|
else {
|
|
74
163
|
// Trim if already at max capacity
|
|
@@ -77,24 +166,34 @@ export class NodeRepository {
|
|
|
77
166
|
}
|
|
78
167
|
logs.push(log);
|
|
79
168
|
}
|
|
80
|
-
this.
|
|
169
|
+
this.persist();
|
|
81
170
|
}
|
|
82
171
|
setOpStateDiagnosticsReason(id, opIndex, reason) {
|
|
83
|
-
|
|
84
|
-
|
|
172
|
+
const opState = this.opStateFor(id, opIndex);
|
|
173
|
+
if (!opState)
|
|
174
|
+
return;
|
|
175
|
+
const diagnostics = this.diagnosticsFor(opState);
|
|
176
|
+
diagnostics.reason = { ...diagnostics.reason, ...reason };
|
|
177
|
+
this.persist();
|
|
85
178
|
}
|
|
86
179
|
setOpStateDiagnosticsState(id, opIndex, state) {
|
|
87
|
-
this.
|
|
88
|
-
|
|
180
|
+
const opState = this.opStateFor(id, opIndex);
|
|
181
|
+
if (!opState)
|
|
182
|
+
return;
|
|
183
|
+
this.diagnosticsFor(opState).state = state;
|
|
184
|
+
this.persist();
|
|
89
185
|
}
|
|
90
186
|
updateOpStateError(id, opIndex, error) {
|
|
91
|
-
const opState = this.
|
|
187
|
+
const opState = this.opStateFor(id, opIndex);
|
|
188
|
+
if (!opState)
|
|
189
|
+
return;
|
|
190
|
+
opState.errors ??= [];
|
|
92
191
|
opState.errors.push(error);
|
|
93
|
-
this.
|
|
192
|
+
this.persist();
|
|
94
193
|
}
|
|
95
194
|
updateNodeInfo(updatedFields) {
|
|
96
195
|
Object.assign(this.db.data.info, updatedFields);
|
|
97
|
-
this.
|
|
196
|
+
this.persist();
|
|
98
197
|
}
|
|
99
198
|
getNodeInfo() {
|
|
100
199
|
return this.db.data.info;
|
|
@@ -107,18 +206,18 @@ export class NodeRepository {
|
|
|
107
206
|
}
|
|
108
207
|
createImageResource(image, fields) {
|
|
109
208
|
this.db.data.resources.images[image] = fields;
|
|
110
|
-
this.
|
|
209
|
+
this.persist();
|
|
111
210
|
}
|
|
112
211
|
updateImageResource(image, updatedFields) {
|
|
113
212
|
if (!this.db.data.resources.images[image]) {
|
|
114
213
|
this.createImageResource(image, updatedFields);
|
|
115
214
|
}
|
|
116
215
|
Object.assign(this.db.data.resources.images[image], updatedFields);
|
|
117
|
-
this.
|
|
216
|
+
this.persist();
|
|
118
217
|
}
|
|
119
218
|
deleteImageResource(image) {
|
|
120
219
|
delete this.db.data.resources.images[image];
|
|
121
|
-
this.
|
|
220
|
+
this.persist();
|
|
122
221
|
}
|
|
123
222
|
getVolumesResources() {
|
|
124
223
|
return this.db.data.resources.volumes;
|
|
@@ -128,18 +227,18 @@ export class NodeRepository {
|
|
|
128
227
|
}
|
|
129
228
|
createVolumeResource(volume, fields) {
|
|
130
229
|
this.db.data.resources.volumes[volume] = fields;
|
|
131
|
-
this.
|
|
230
|
+
this.persist();
|
|
132
231
|
}
|
|
133
232
|
updateVolumeResource(volume, updatedFields) {
|
|
134
233
|
if (!this.db.data.resources.volumes[volume]) {
|
|
135
234
|
this.createVolumeResource(volume, updatedFields);
|
|
136
235
|
}
|
|
137
236
|
Object.assign(this.db.data.resources.volumes[volume], updatedFields);
|
|
138
|
-
this.
|
|
237
|
+
this.persist();
|
|
139
238
|
}
|
|
140
239
|
deleteVolumeResource(volume) {
|
|
141
240
|
delete this.db.data.resources.volumes[volume];
|
|
142
|
-
this.
|
|
241
|
+
this.persist();
|
|
143
242
|
}
|
|
144
243
|
displayLog(log) {
|
|
145
244
|
return log;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { Writable } from 'stream';
|
|
3
|
+
/** Whether a write failed because the terminal went away. */
|
|
4
|
+
export declare function isTerminalGone(error: unknown): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Ignore writes that fail because the terminal went away — a detached
|
|
7
|
+
* `docker run -t`, a dropped SSH session.
|
|
8
|
+
*
|
|
9
|
+
* tty writes complete after the call returns, and a stream with no `error`
|
|
10
|
+
* listener rethrows, so the failure lands as an uncaughtException wherever the
|
|
11
|
+
* event loop is. Any other stream error is rethrown.
|
|
12
|
+
*/
|
|
13
|
+
export declare function tolerateBrokenStdio(streams?: Writable[]): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Errnos that mean the far end is gone rather than the stream misused. */
|
|
2
|
+
const TERMINAL_GONE = new Set(['EIO', 'EPIPE', 'ERR_STREAM_DESTROYED']);
|
|
3
|
+
/** Whether a write failed because the terminal went away. */
|
|
4
|
+
export function isTerminalGone(error) {
|
|
5
|
+
return TERMINAL_GONE.has(error?.code ?? '');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Ignore writes that fail because the terminal went away — a detached
|
|
9
|
+
* `docker run -t`, a dropped SSH session.
|
|
10
|
+
*
|
|
11
|
+
* tty writes complete after the call returns, and a stream with no `error`
|
|
12
|
+
* listener rethrows, so the failure lands as an uncaughtException wherever the
|
|
13
|
+
* event loop is. Any other stream error is rethrown.
|
|
14
|
+
*/
|
|
15
|
+
export function tolerateBrokenStdio(streams = [process.stdout, process.stderr]) {
|
|
16
|
+
for (const stream of streams) {
|
|
17
|
+
stream.on('error', (error) => {
|
|
18
|
+
if (isTerminalGone(error))
|
|
19
|
+
return;
|
|
20
|
+
throw error;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
/// <reference path="./global.d.ts" />
|
|
3
3
|
import { pkg } from './static/index.js';
|
|
4
4
|
import { startCLI } from './cli/index.js';
|
|
5
|
+
import { tolerateBrokenStdio } from './NodeManager/utils/tolerateBrokenStdio.js';
|
|
5
6
|
const VERSION = pkg.version;
|
|
7
|
+
// Before anything can write: the first console call happens inside startCLI.
|
|
8
|
+
tolerateBrokenStdio();
|
|
6
9
|
startCLI(VERSION);
|
|
7
10
|
export * from './cli/createNosanaCli.js';
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nosana/node",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.48-rc",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@nosana/node",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.48-rc",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@coral-xyz/anchor": "^0.28.1-beta.1",
|