@nosana/node 1.1.58-rc → 1.1.59-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/.env CHANGED
@@ -1,6 +1,6 @@
1
1
  BACKEND_SOLANA_ADDRESS=7rFPFnxjXHC2sfDy3qrDa9pEb4j49oivMuV7e8sYDPmB
2
- FRPC_CONTAINER_IMAGE=registry.hub.docker.com/nosana/frpc:v1.0.32
3
- FRP_SERVER_IMAGE=registry.hub.docker.com/nosana/frpc:v1.0.32
2
+ FRPC_CONTAINER_IMAGE=registry.hub.docker.com/nosana/frpc:v1.0.44
3
+ FRP_SERVER_IMAGE=registry.hub.docker.com/nosana/frpc:v1.0.44
4
4
  FRP_SERVER_PORT=7000
5
5
  MIN_DISK_SPACE=25
6
6
  SIGN_MESSAGE="Hello Nosana Node!"
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.58-rc",
3
+ "version": "1.1.59-rc",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"
@@ -8,6 +8,12 @@ export declare class ContainerStateManager {
8
8
  private controller;
9
9
  private emitter;
10
10
  private restartPolicy;
11
+ /**
12
+ * Whether a failed liveness probe may restart this container. Like a
13
+ * restart policy it means a stop is not necessarily the op finishing, so
14
+ * the container has to be polled rather than waited on.
15
+ */
16
+ private restartsOnUnhealthy;
11
17
  private state;
12
18
  private exitedCheckCount;
13
19
  private lastLogTimestamp;
@@ -16,10 +22,22 @@ export declare class ContainerStateManager {
16
22
  private currentLogStream;
17
23
  private currentStatsStream;
18
24
  private pollingInterval;
19
- constructor(container: Dockerode.Container, controller: AbortController, emitter: EventEmitter, restartPolicy: RestartPolicy | undefined);
25
+ private restarting;
26
+ constructor(container: Dockerode.Container, controller: AbortController, emitter: EventEmitter, restartPolicy: RestartPolicy | undefined,
27
+ /**
28
+ * Whether a failed liveness probe may restart this container. Like a
29
+ * restart policy it means a stop is not necessarily the op finishing, so
30
+ * the container has to be polled rather than waited on.
31
+ */
32
+ restartsOnUnhealthy?: boolean);
20
33
  getState(): ContainerState;
21
34
  startMonitoring(): Promise<void>;
22
35
  private attachLogStream;
36
+ /**
37
+ * Restart the container in place after a failed liveness probe, keeping the
38
+ * op alive across it. Returns whether the container came back.
39
+ */
40
+ restartContainer(): Promise<boolean>;
23
41
  waitForExit(): Promise<void>;
24
42
  /**
25
43
  * Wait for the followed log stream to finish delivering its final buffered
@@ -12,11 +12,18 @@ function destroyStream(stream) {
12
12
  }
13
13
  }
14
14
  export class ContainerStateManager {
15
- constructor(container, controller, emitter, restartPolicy) {
15
+ constructor(container, controller, emitter, restartPolicy,
16
+ /**
17
+ * Whether a failed liveness probe may restart this container. Like a
18
+ * restart policy it means a stop is not necessarily the op finishing, so
19
+ * the container has to be polled rather than waited on.
20
+ */
21
+ restartsOnUnhealthy = false) {
16
22
  this.container = container;
17
23
  this.controller = controller;
18
24
  this.emitter = emitter;
19
25
  this.restartPolicy = restartPolicy;
26
+ this.restartsOnUnhealthy = restartsOnUnhealthy;
20
27
  this.state = 'starting';
21
28
  this.exitedCheckCount = 0;
22
29
  this.lastLogTimestamp = 0; // Unix timestamp in seconds from actual container logs
@@ -25,6 +32,7 @@ export class ContainerStateManager {
25
32
  this.currentLogStream = null;
26
33
  this.currentStatsStream = null;
27
34
  this.pollingInterval = null;
35
+ this.restarting = false;
28
36
  this.STATS_INTERVAL_MS = 5000;
29
37
  }
30
38
  getState() {
@@ -32,7 +40,7 @@ export class ContainerStateManager {
32
40
  }
33
41
  async startMonitoring() {
34
42
  await Promise.all([this.attachLogStream(), this.attachStatsStream()]);
35
- if (!this.restartPolicy) {
43
+ if (!this.restartPolicy && !this.restartsOnUnhealthy) {
36
44
  this.container
37
45
  .wait({ abortSignal: liveAbortSignal(this.controller.signal) })
38
46
  .finally(() => {
@@ -51,6 +59,12 @@ export class ContainerStateManager {
51
59
  this.stopMonitoring();
52
60
  return;
53
61
  }
62
+ // A restart takes the container through a stop, which is not the op
63
+ // ending; hold off on exit detection until it is back.
64
+ if (this.restarting) {
65
+ this.exitedCheckCount = 0;
66
+ return;
67
+ }
54
68
  try {
55
69
  const inspectInfo = await this.container.inspect();
56
70
  let inspectedState = inspectInfo.State.Status.toLowerCase();
@@ -107,7 +121,10 @@ export class ContainerStateManager {
107
121
  });
108
122
  this.currentLogStream.on('close', () => {
109
123
  this.currentLogStream = null;
110
- if (this.state === 'running' && !this.controller.signal.aborted) {
124
+ // A restart closes the stream while the container is briefly stopped;
125
+ // re-attach so the logs it writes on the way back up aren't lost.
126
+ if ((this.state === 'running' || this.restarting) &&
127
+ !this.controller.signal.aborted) {
111
128
  setTimeout(() => this.attachLogStream(), 100);
112
129
  }
113
130
  });
@@ -126,6 +143,27 @@ export class ContainerStateManager {
126
143
  }
127
144
  }
128
145
  }
146
+ /**
147
+ * Restart the container in place after a failed liveness probe, keeping the
148
+ * op alive across it. Returns whether the container came back.
149
+ */
150
+ async restartContainer() {
151
+ if (this.controller.signal.aborted || this.state === 'exited')
152
+ return false;
153
+ this.restarting = true;
154
+ try {
155
+ await this.container.restart();
156
+ this.state = 'running';
157
+ return true;
158
+ }
159
+ catch (error) {
160
+ return false;
161
+ }
162
+ finally {
163
+ this.exitedCheckCount = 0;
164
+ this.restarting = false;
165
+ }
166
+ }
129
167
  async waitForExit() {
130
168
  return new Promise((resolve) => {
131
169
  const checkInterval = setInterval(() => {
@@ -1,7 +1,16 @@
1
1
  /// <reference types="node" />
2
- import { ExposedPort } from '@nosana/sdk';
2
+ import { ExposedPort, HealthCheck } from '@nosana/sdk';
3
3
  import Dockerode from 'dockerode';
4
4
  import EventEmitter from 'events';
5
+ /**
6
+ * `restart_on_failure` and `max_restarts` ship in the @nosana/sdk release that
7
+ * accompanies this change; widening the SDK type here keeps the node building
8
+ * against an SDK that predates them.
9
+ */
10
+ export type RestartableHealthCheck = HealthCheck & {
11
+ restart_on_failure?: boolean;
12
+ max_restarts?: number;
13
+ };
5
14
  export declare class ExposedPortHealthCheck {
6
15
  private exposedPortMap;
7
16
  private frpcContainer;
@@ -14,15 +23,40 @@ export declare class ExposedPortHealthCheck {
14
23
  private healthStatus;
15
24
  private jobEmitter;
16
25
  private intervals;
26
+ /**
27
+ * Restarts the container in place. Set only when an exposed port declares a
28
+ * `restart_on_failure` check; without it a failure is reported and no more.
29
+ */
30
+ private restartContainer?;
31
+ /** Health-triggered restarts so far, counted across every exposed port. */
32
+ private restartCount;
33
+ private restartInFlight;
34
+ private restartLimitLogged;
35
+ private stopped;
17
36
  private readonly connectTimeoutSeconds;
18
37
  private readonly startupMaxTimeSeconds;
19
38
  private readonly continuousMaxTimeSeconds;
20
39
  private flowId;
21
- constructor(flowId: string, frpcContainer: Dockerode.Container, jobEmitter: EventEmitter, containerName: string, startupIntervalMs?: number, continuousIntervalMs?: number);
40
+ constructor(flowId: string, frpcContainer: Dockerode.Container, jobEmitter: EventEmitter, containerName: string, restartContainer?: () => Promise<boolean>, startupIntervalMs?: number, continuousIntervalMs?: number);
22
41
  addExposedPortsMap(exposedPortMap: Map<string, ExposedPort>): void;
42
+ /**
43
+ * A continuous pass only covers checks up to the first non-continuous one, so
44
+ * a liveness probe that isn't continuous, or that sits behind a startup-only
45
+ * check, never runs. Say so rather than let `restart_on_failure` look active
46
+ * while doing nothing.
47
+ */
48
+ private reportUnreachableRestartChecks;
23
49
  startServiceExposedUrlHealthCheck(): void;
24
50
  private startStartupHealthCheck;
25
51
  private startContinuousHealthCheck;
52
+ /**
53
+ * Restart the container behind a failed liveness probe.
54
+ *
55
+ * The restart is container-wide even though the probe belongs to one port, so
56
+ * every port drops back to startup probing and reports itself online again as
57
+ * it starts answering — the same path a first start takes.
58
+ */
59
+ private restartUnhealthyContainer;
26
60
  stopHealthCheckForId(id: string): void;
27
61
  stopAllHealthChecks(): void;
28
62
  private checkPortHealth;
@@ -1,11 +1,16 @@
1
1
  export class ExposedPortHealthCheck {
2
- constructor(flowId, frpcContainer, jobEmitter, containerName, startupIntervalMs = 5000, continuousIntervalMs = 30000) {
2
+ constructor(flowId, frpcContainer, jobEmitter, containerName, restartContainer, startupIntervalMs = 5000, continuousIntervalMs = 30000) {
3
3
  this.exposedPortMap = new Map();
4
4
  /**
5
5
  * Tracks job previous state to show change in state of job mainly when going from failed to success.
6
6
  */
7
7
  this.healthStatus = new Map();
8
8
  this.intervals = new Map();
9
+ /** Health-triggered restarts so far, counted across every exposed port. */
10
+ this.restartCount = 0;
11
+ this.restartInFlight = false;
12
+ this.restartLimitLogged = false;
13
+ this.stopped = false;
9
14
  // Every curl is bounded: an unbounded one holds the startup check open and
10
15
  // nothing waiting on the service ever starts. The startup budget is generous
11
16
  // because a health check can be a real request against a model that is still
@@ -18,11 +23,34 @@ export class ExposedPortHealthCheck {
18
23
  this.frpcContainer = frpcContainer;
19
24
  this.jobEmitter = jobEmitter;
20
25
  this.containerName = containerName;
26
+ this.restartContainer = restartContainer;
21
27
  this.startupIntervalMs = startupIntervalMs;
22
28
  this.continuousIntervalMs = continuousIntervalMs;
23
29
  }
24
30
  addExposedPortsMap(exposedPortMap) {
25
31
  this.exposedPortMap = exposedPortMap;
32
+ this.reportUnreachableRestartChecks();
33
+ }
34
+ /**
35
+ * A continuous pass only covers checks up to the first non-continuous one, so
36
+ * a liveness probe that isn't continuous, or that sits behind a startup-only
37
+ * check, never runs. Say so rather than let `restart_on_failure` look active
38
+ * while doing nothing.
39
+ */
40
+ reportUnreachableRestartChecks() {
41
+ for (const [id, exposedPort] of this.exposedPortMap) {
42
+ const checks = (exposedPort.health_checks ??
43
+ []);
44
+ const startupOnly = checks.findIndex((check) => !check.continuous);
45
+ checks.forEach((check, index) => {
46
+ if (!check.restart_on_failure)
47
+ return;
48
+ if (check.continuous && (startupOnly === -1 || index < startupOnly)) {
49
+ return;
50
+ }
51
+ this.jobEmitter.emit('log', `restart_on_failure on ${id} is never reached: a liveness check has to be continuous and declared before any check that is not`, 'info');
52
+ });
53
+ }
26
54
  }
27
55
  startServiceExposedUrlHealthCheck() {
28
56
  for (const [id, exposedPort] of this.exposedPortMap) {
@@ -37,10 +65,10 @@ export class ExposedPortHealthCheck {
37
65
  if (checkInFlight)
38
66
  return;
39
67
  checkInFlight = true;
40
- const success = await this.checkPortHealth(exposedPort, true).finally(() => {
68
+ const result = await this.checkPortHealth(exposedPort, true).finally(() => {
41
69
  checkInFlight = false;
42
70
  });
43
- if (success == null) {
71
+ if (result == null) {
44
72
  clearInterval(startupCheckInterval);
45
73
  this.jobEmitter.emit('healthcheck:url:exposed', {
46
74
  id,
@@ -50,8 +78,12 @@ export class ExposedPortHealthCheck {
50
78
  });
51
79
  return;
52
80
  }
53
- if (success) {
81
+ if (result.healthy) {
54
82
  clearInterval(startupCheckInterval);
83
+ // Seed the tracked state, so the *first* continuous failure reads as a
84
+ // change. Left unset it defaults to unhealthy, and a service that goes
85
+ // down and stays down never reports a transition at all.
86
+ this.healthStatus.set(id, true);
55
87
  this.jobEmitter.emit('healthcheck:startup:success', {
56
88
  id,
57
89
  flowId: this.flowId,
@@ -67,15 +99,15 @@ export class ExposedPortHealthCheck {
67
99
  }
68
100
  startContinuousHealthCheck(id, exposedPort) {
69
101
  let continuousCheckInterval = setInterval(async () => {
70
- const healthStatus = await this.checkPortHealth(exposedPort, false);
71
- if (healthStatus === null) {
102
+ const result = await this.checkPortHealth(exposedPort, false);
103
+ if (result === null) {
72
104
  clearInterval(continuousCheckInterval);
73
105
  return; // Skip logging or emitting events if no check was done.
74
106
  }
75
107
  const previousState = this.healthStatus.get(id) || false;
76
- if (healthStatus !== previousState) {
77
- this.healthStatus.set(id, healthStatus);
78
- if (!healthStatus) {
108
+ if (result.healthy !== previousState) {
109
+ this.healthStatus.set(id, result.healthy);
110
+ if (!result.healthy) {
79
111
  this.jobEmitter.emit('healthcheck:continuous:failure', {
80
112
  id,
81
113
  flowId: this.flowId,
@@ -84,9 +116,54 @@ export class ExposedPortHealthCheck {
84
116
  });
85
117
  }
86
118
  }
119
+ // A continuous check only ever runs once the service has passed startup,
120
+ // so reaching here unhealthy is the liveness case: it worked, then stopped.
121
+ if (!result.healthy && result.failedCheck?.restart_on_failure) {
122
+ await this.restartUnhealthyContainer(id, result.failedCheck);
123
+ }
87
124
  }, this.continuousIntervalMs);
88
125
  this.intervals.set(id, continuousCheckInterval);
89
126
  }
127
+ /**
128
+ * Restart the container behind a failed liveness probe.
129
+ *
130
+ * The restart is container-wide even though the probe belongs to one port, so
131
+ * every port drops back to startup probing and reports itself online again as
132
+ * it starts answering — the same path a first start takes.
133
+ */
134
+ async restartUnhealthyContainer(id, failedCheck) {
135
+ if (!this.restartContainer || this.restartInFlight || this.stopped)
136
+ return;
137
+ const maxRestarts = failedCheck.max_restarts;
138
+ if (maxRestarts !== undefined && this.restartCount >= maxRestarts) {
139
+ // The probe keeps running on its interval; say this once rather than
140
+ // every time it comes back unhealthy.
141
+ if (!this.restartLimitLogged) {
142
+ this.restartLimitLogged = true;
143
+ this.jobEmitter.emit('log', `Health check for ${id} failed but its restart limit (${maxRestarts}) is reached, leaving the container alone`, 'info');
144
+ }
145
+ return;
146
+ }
147
+ this.restartInFlight = true;
148
+ this.restartCount++;
149
+ // Every port on this container is about to fail for the same reason, so
150
+ // drop all the probes rather than let them race the restart.
151
+ this.intervals.forEach((interval) => clearInterval(interval));
152
+ this.intervals.clear();
153
+ this.healthStatus.clear();
154
+ this.jobEmitter.emit('log', `Health check for ${id} failed, restarting container (restart ${this.restartCount}${maxRestarts !== undefined ? `/${maxRestarts}` : ''})`, 'info');
155
+ const restarted = await this.restartContainer().catch(() => false);
156
+ this.restartInFlight = false;
157
+ if (!restarted) {
158
+ this.jobEmitter.emit('log', 'Restarting the container after a failed health check did not succeed', 'error');
159
+ return;
160
+ }
161
+ // The op can be torn down while the restart is in flight; re-arming then
162
+ // would leave probes running against a container that is going away.
163
+ if (this.stopped)
164
+ return;
165
+ this.startServiceExposedUrlHealthCheck();
166
+ }
90
167
  stopHealthCheckForId(id) {
91
168
  if (this.intervals.has(id)) {
92
169
  clearInterval(this.intervals.get(id));
@@ -96,6 +173,7 @@ export class ExposedPortHealthCheck {
96
173
  }
97
174
  }
98
175
  stopAllHealthChecks() {
176
+ this.stopped = true;
99
177
  this.intervals.forEach((interval, id) => {
100
178
  clearInterval(interval);
101
179
  });
@@ -117,16 +195,16 @@ export class ExposedPortHealthCheck {
117
195
  if (healthCheck.type === 'http' && typeof exposedPort.port === 'number') {
118
196
  const success = await this.runHttpHealthCheck(exposedPort.port, healthCheck, maxTimeSeconds);
119
197
  if (!success)
120
- return false;
198
+ return { healthy: false, failedCheck: healthCheck };
121
199
  }
122
200
  else if (healthCheck.type === 'websocket' &&
123
201
  typeof exposedPort.port === 'number') {
124
202
  const success = await this.runWebSocketHealthCheck(exposedPort.port, healthCheck, maxTimeSeconds);
125
203
  if (!success)
126
- return false;
204
+ return { healthy: false, failedCheck: healthCheck };
127
205
  }
128
206
  }
129
- return true;
207
+ return { healthy: true };
130
208
  }
131
209
  async runHttpHealthCheck(port, healthCheck, maxTimeSeconds) {
132
210
  const url = `http://${this.containerName}:${port}${healthCheck.path}`;
@@ -1,7 +1,7 @@
1
1
  import { createHash, getExposePorts, isOpExposed, } from '@nosana/sdk';
2
2
  import { configs } from '../configs/configs.js';
3
3
  import { NodeConfigsSingleton } from '../configs/NodeConfigs.js';
4
- import { ExposedPortHealthCheck } from './ExposedPortHealthCheck.js';
4
+ import { ExposedPortHealthCheck, } from './ExposedPortHealthCheck.js';
5
5
  import { s3HelperImage } from '../node/resource/definition/index.js';
6
6
  import { applyLoggingProxyToClass } from '../monitoring/proxy/loggingProxy.js';
7
7
  import { promiseTimeoutWrapper } from '../utils/timeoutPromiseWrapper.js';
@@ -168,6 +168,9 @@ export class Provider {
168
168
  const networks = {};
169
169
  let idMaps = new Map();
170
170
  const ports = getExposePorts(op);
171
+ // A liveness probe restarts the container under the running op, so the
172
+ // state manager has to know a stop may not be the op finishing.
173
+ const restartsOnUnhealthy = ports.some((port) => port.health_checks?.some((check) => check.restart_on_failure));
171
174
  networks[name] = {};
172
175
  await this.containerOrchestration.createNetwork(name, controller);
173
176
  if (isOpExposed(op)) {
@@ -266,10 +269,12 @@ export class Provider {
266
269
  if (teeRuntime !== 'SEV-SNP' && startedInfo.NetworkSettings?.Networks["NOSANA_GATEWAY"].IPAddress) {
267
270
  emitter.emit('setContainerInternalIp', startedInfo.NetworkSettings.Networks["NOSANA_GATEWAY"].IPAddress);
268
271
  }
269
- stateManager = new ContainerStateManager(container, controller, emitter, op.args.restart_policy);
272
+ stateManager = new ContainerStateManager(container, controller, emitter, op.args.restart_policy, restartsOnUnhealthy);
270
273
  await stateManager.startMonitoring();
271
274
  if (isOpExposed(op)) {
272
- exposedPortHealthCheck = new ExposedPortHealthCheck(flow.id, frpcContainer, emitter, name);
275
+ exposedPortHealthCheck = new ExposedPortHealthCheck(flow.id, frpcContainer, emitter, name, restartsOnUnhealthy
276
+ ? () => stateManager.restartContainer()
277
+ : undefined);
273
278
  exposedPortHealthCheck.addExposedPortsMap(idMaps);
274
279
  exposedPortHealthCheck.startServiceExposedUrlHealthCheck();
275
280
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.58-rc",
3
+ "version": "1.1.59-rc",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@nosana/node",
9
- "version": "1.1.58-rc",
9
+ "version": "1.1.59-rc",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@coral-xyz/anchor": "^0.28.1-beta.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.58-rc",
3
+ "version": "1.1.59-rc",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"