anbaric-cloud-hosting 1.58.3 → 1.60.0

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": "anbaric-cloud-hosting",
3
- "version": "1.58.3",
3
+ "version": "1.60.0",
4
4
  "description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
5
5
  "license": "MIT",
6
6
  "author": "chris@anbaric.ai",
@@ -18,18 +18,18 @@
18
18
  "@aws-sdk/client-s3": "^3.1110.0",
19
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
20
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
21
- "anbaric-data-store": "^1.58.3",
22
- "anbaric-plugins": "^1.58.3",
23
- "anbaric-tsapi": "^1.58.3",
24
- "anbaric-web": "^1.58.3",
21
+ "anbaric-data-store": "^1.60.0",
22
+ "anbaric-plugins": "^1.60.0",
23
+ "anbaric-tsapi": "^1.60.0",
24
+ "anbaric-web": "^1.60.0",
25
25
  "esbuild": "^0.28.2",
26
26
  "pg": "^8.16.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/pg": "^8.15.0",
31
- "anbaric-impl-cloud": "^1.58.3",
32
- "anbaric-state-machine": "^1.58.3",
31
+ "anbaric-impl-cloud": "^1.60.0",
32
+ "anbaric-state-machine": "^1.60.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -15,6 +15,7 @@ type Deployment = {
15
15
  log : Array<string>,
16
16
  process? : ChildProcess,
17
17
  replaces? : Deployment,
18
+ draining? : { inFlight : number, since : number },
18
19
  };
19
20
 
20
21
  type Probe = (host : string, port : number) => Promise<boolean>;
@@ -23,6 +24,11 @@ const LOG_LIMIT = 200;
23
24
  const LIVENESS_TIMEOUT_MS = 30_000;
24
25
  const LIVENESS_PROBE_INTERVAL_MS = 250;
25
26
  const APP_ADMIN_PORT = 8791;
27
+ /* How long a running app is given to finish the steps it has in hand before
28
+ it is replaced or removed regardless. Long enough for real work, short
29
+ enough that a step that will never finish cannot hold a deploy hostage. */
30
+ const DRAIN_TIMEOUT_MS = 5 * 60_000;
31
+ const DRAIN_POLL_INTERVAL_MS = 2_000;
26
32
 
27
33
  /* Liveness is the built-in admin server answering `ping` on the admin port, so
28
34
  an app that serves no HTTP still passes. */
@@ -32,12 +38,15 @@ abstract class BaseBuildLayer implements BuildLayer {
32
38
 
33
39
  protected deployments = new Map<string, Deployment>();
34
40
  protected docGenerator? : DocGenerator;
41
+ consumerUrlsFor : (appName : string) => Array<string> = () => [];
35
42
  private nextAppIndex = 0;
36
43
  private hydration? : Promise<void>;
37
44
 
38
45
  constructor(protected appsDir : string, private consumerPortBase : number = 8800,
39
46
  private probe : Probe = adminProbe,
40
- private livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS) {}
47
+ private livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS,
48
+ private drainTimeoutMs : number = DRAIN_TIMEOUT_MS,
49
+ private drainPollIntervalMs : number = DRAIN_POLL_INTERVAL_MS) {}
41
50
 
42
51
  /* The deployments map is rebuilt from the durable backend the first time the
43
52
  app registry is read, so apps survive a platform restart. A failed
@@ -95,16 +104,72 @@ abstract class BaseBuildLayer implements BuildLayer {
95
104
  yield* this.streamLogs(deployment, signal);
96
105
  }
97
106
 
98
- async teardown(appName : string) : Promise<boolean> {
107
+ async teardown(appName : string) : Promise<DeploymentSummary | undefined> {
99
108
  const deployment = this.deployments.get(appName);
100
- if (!deployment) return false;
109
+ if (!deployment) return undefined;
101
110
 
102
- // stop() runs while the deployment is still the mapped one, so the
103
- // Fargate guard lets it delete the service; then drop it from the map.
104
- deployment.status = "stopped";
105
- await this.stop(deployment);
106
- this.deployments.delete(appName);
107
- return true;
111
+ // An app with consumers to drain is drained in the background and the
112
+ // caller polls; one with nothing to drain is gone by the time this
113
+ // returns. Either way stop() runs while the deployment is still the
114
+ // mapped one, so the Fargate guard lets it delete the service.
115
+ const finish = async () => {
116
+ deployment.status = "stopped";
117
+ await this.stop(deployment);
118
+ if (this.deployments.get(appName) === deployment) this.deployments.delete(appName);
119
+ };
120
+
121
+ if (deployment.status === "running" && this.consumerUrlsFor(appName).length > 0) {
122
+ void this.drain(deployment, deployment).then(finish);
123
+ return this.summarize(deployment);
124
+ }
125
+
126
+ await finish();
127
+ return this.summarize(deployment);
128
+ }
129
+
130
+ /* Asks the running app to finish what it has in hand before it goes. The
131
+ app's consumers are told to stop accepting (the platform's pushes are
132
+ refused unconfirmed, so nothing is lost - it waits for the replacement)
133
+ and polled until nothing is in flight or the drain timeout passes;
134
+ `shown` is the deployment whose status the caller is watching. */
135
+ protected async drain(shown : Deployment, running : Deployment) : Promise<void> {
136
+ const urls = [...new Set(this.consumerUrlsFor(running.appName))];
137
+ if (urls.length === 0) return;
138
+
139
+ const previous = shown.status;
140
+ shown.status = "draining";
141
+ shown.draining = { inFlight: 0, since: Date.now() };
142
+ this.log(shown, `draining ${running.appName}: asking it to finish what it has in hand`);
143
+
144
+ await Promise.all(urls.map(url => fetch(`${url}/drain`, { method: "POST" }).catch(() => undefined)));
145
+
146
+ const deadline = Date.now() + this.drainTimeoutMs;
147
+ let inFlight = await this.inFlightAt(urls);
148
+ while (inFlight > 0 && Date.now() < deadline) {
149
+ shown.draining.inFlight = inFlight;
150
+ await new Promise(resolve => setTimeout(resolve, this.drainPollIntervalMs));
151
+ inFlight = await this.inFlightAt(urls);
152
+ }
153
+
154
+ this.log(shown, inFlight === 0
155
+ ? "drained: nothing in flight"
156
+ : `drain timed out after ${this.drainTimeoutMs / 1000}s with ${inFlight} step(s) in flight; going ahead`);
157
+ shown.draining = undefined;
158
+ if (shown.status === "draining") shown.status = previous;
159
+ }
160
+
161
+ // The steps in flight across the app's consumers; one that cannot be
162
+ // reached counts as idle, since nothing can be waited for there.
163
+ private async inFlightAt(urls : Array<string>) : Promise<number> {
164
+ const counts = await Promise.all(urls.map(async url => {
165
+ try {
166
+ const health = await (await fetch(`${url}/health`)).json() as { inFlight? : number };
167
+ return Number(health.inFlight ?? 0);
168
+ } catch {
169
+ return 0;
170
+ }
171
+ }));
172
+ return counts.reduce((total, count) => total + count, 0);
108
173
  }
109
174
 
110
175
  async cleanUp() : Promise<void> {
@@ -157,7 +222,13 @@ abstract class BaseBuildLayer implements BuildLayer {
157
222
  const manifest = JSON.parse(await readFile(join(appDir, "package.json"), "utf8"));
158
223
  await this.linkWorkspacePackages(appDir, manifest);
159
224
 
160
- if (deployment.replaces) await this.stop(deployment.replaces);
225
+ // The version being replaced finishes its work first; the build above
226
+ // ran while it carried on, so the drain is the only pause it sees.
227
+ if (deployment.replaces) {
228
+ if (deployment.replaces.status === "running") await this.drain(deployment, deployment.replaces);
229
+ if (!this.isCurrent(deployment)) return;
230
+ await this.stop(deployment.replaces);
231
+ }
161
232
 
162
233
  if (!this.isCurrent(deployment)) return;
163
234
  await this.start(deployment, appDir, manifest.main);
@@ -245,6 +316,9 @@ abstract class BaseBuildLayer implements BuildLayer {
245
316
  status: deployment.status,
246
317
  appPort: deployment.appPort,
247
318
  appHost: deployment.appHost,
319
+ ...(deployment.draining
320
+ ? { draining: { inFlight: deployment.draining.inFlight, since: new Date(deployment.draining.since).toISOString() } }
321
+ : {}),
248
322
  };
249
323
  }
250
324
 
@@ -1,10 +1,19 @@
1
- type DeploymentStatus = "building" | "running" | "failed" | "stopped";
1
+ /* building: the new version is being built; draining: the running version has
2
+ been told to stop taking on work and is finishing what it has in hand
3
+ before being replaced or removed; then running, failed or stopped. */
4
+ type DeploymentStatus = "building" | "draining" | "running" | "failed" | "stopped";
5
+
6
+ type Draining = {
7
+ inFlight : number,
8
+ since : string,
9
+ };
2
10
 
3
11
  type DeploymentSummary = {
4
12
  appName : string,
5
13
  status : DeploymentStatus,
6
14
  appPort : number,
7
15
  appHost : string,
16
+ draining? : Draining,
8
17
  };
9
18
 
10
19
  interface BuildLayer {
@@ -15,12 +24,19 @@ interface BuildLayer {
15
24
  list() : Array<DeploymentSummary>;
16
25
  ping(appName : string) : Promise<boolean>;
17
26
  logs(appName : string, signal : AbortSignal) : AsyncIterable<string>;
18
- teardown(appName : string) : Promise<boolean>;
27
+ /* Removes the app. One that is running its jobs is first drained, and the
28
+ call returns as soon as that has begun - the summary says "draining" -
29
+ so poll status until the app is gone. Returns undefined for an unknown
30
+ app. */
31
+ teardown(appName : string) : Promise<DeploymentSummary | undefined>;
19
32
  // Re-runs documentation generation for an already-deployed app from its
20
33
  // uploaded source, without redeploying it. Returns the number of docs written.
21
34
  regenerateDocs(appName : string) : Promise<number>;
22
35
  cleanUp() : Promise<void>;
36
+ // Where a running app's queue consumers can be reached, so it can be asked
37
+ // to drain; the hosting server supplies it from its consumer registry.
38
+ consumerUrlsFor : (appName : string) => Array<string>;
23
39
 
24
40
  }
25
41
 
26
- export type { BuildLayer, DeploymentStatus, DeploymentSummary };
42
+ export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining };
@@ -181,6 +181,9 @@ class FargateBuildLayer extends BaseBuildLayer {
181
181
  name: deployment.appName,
182
182
  image: imageUri,
183
183
  essential: true,
184
+ // The most Fargate allows between SIGTERM and SIGKILL, so an
185
+ // app signalled with steps still in hand gets to finish them.
186
+ stopTimeout: 120,
184
187
  portMappings: [
185
188
  { containerPort: deployment.appPort, protocol: "tcp" },
186
189
  { containerPort: deployment.adminPort, protocol: "tcp" },
@@ -230,6 +230,13 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
230
230
  "ALTER TABLE jobs DROP COLUMN properties",
231
231
  ),
232
232
 
233
+ /* A long-running step heartbeats its job; a job whose heartbeat has gone
234
+ quiet is marked Stalled. The index is what the stall sweep reads. */
235
+ statements("004-job-heartbeat",
236
+ "ALTER TABLE jobs ADD COLUMN heartbeat_at TIMESTAMPTZ",
237
+ "CREATE INDEX jobs_heartbeat ON jobs (heartbeat_at) WHERE heartbeat_at IS NOT NULL",
238
+ ),
239
+
233
240
  ];
234
241
 
235
242
  export { PLATFORM_MIGRATIONS };
@@ -13,6 +13,7 @@ type JobRow = {
13
13
  status : string,
14
14
  waiting_for? : string,
15
15
  await_metadata? : SerializedWaitForInput,
16
+ heartbeat_at? : Date,
16
17
  };
17
18
 
18
19
  /* The await a job is parked on is normalised into the awaits table: jobs
@@ -20,7 +21,7 @@ type JobRow = {
20
21
  job's awaitMetadata is rejoined on read. */
21
22
  const JOB_SELECT =
22
23
  `SELECT j.id, j.state, j.workflow_id, j.app_id, j.started_at, j.started_by, j.last_updated,
23
- j.killed, j.status, j.waiting_for, a.metadata AS await_metadata
24
+ j.killed, j.status, j.waiting_for, j.heartbeat_at, a.metadata AS await_metadata
24
25
  FROM jobs j LEFT JOIN awaits a ON a.id = j.waiting_for`;
25
26
 
26
27
  /* Properties live one row each in job_properties, so a read fetches only the
@@ -153,6 +154,31 @@ class PostgresJobPersistence extends JobPersistence {
153
154
  return result.rows.map(row => this.deserializeRow(row, new JobProperties(properties.get(row.id) ?? new Map())));
154
155
  }
155
156
 
157
+ protected async heartbeatInternal(id : string, running : boolean) : Promise<void> {
158
+ // A heartbeat is proof the step is running, so one from a job marked
159
+ // Stalled - a process that came back, or a step slower than the
160
+ // sweep - returns it to active.
161
+ await this.pool.query(running
162
+ ? `UPDATE jobs SET heartbeat_at = now(),
163
+ status = CASE WHEN status = $2 THEN $3 ELSE status END
164
+ WHERE id = $1`
165
+ : "UPDATE jobs SET heartbeat_at = NULL WHERE id = $1",
166
+ running ? [id, Job.Status.STALLED, Job.Status.ACTIVE] : [id]);
167
+ }
168
+
169
+ /* Marks Stalled every active job whose long-running step has not
170
+ heartbeated for `staleMs`. The heartbeat is left as it was, so the
171
+ record shows when the step was last heard from; a save that moves the
172
+ job on sets its status again. Returns how many were marked. */
173
+ async markStalled(staleMs : number = Job.STALL_AFTER_MS) : Promise<number> {
174
+ const result = await this.pool.query(
175
+ `UPDATE jobs SET status = $1, last_updated = now()
176
+ WHERE status = $2 AND killed = false AND heartbeat_at IS NOT NULL AND heartbeat_at < now() - ($3::text || ' milliseconds')::interval`,
177
+ [Job.Status.STALLED, Job.Status.ACTIVE, String(staleMs)],
178
+ );
179
+ return result.rowCount ?? 0;
180
+ }
181
+
156
182
  protected async killInternal(id : string) : Promise<void> {
157
183
  await this.pool.query("UPDATE jobs SET killed = true, last_updated = now() WHERE id = $1", [id]);
158
184
  }
@@ -185,9 +211,10 @@ class PostgresJobPersistence extends JobPersistence {
185
211
  status: row.status,
186
212
  waitingFor: row.waiting_for ?? undefined,
187
213
  awaitMetadata: row.await_metadata ?? undefined,
214
+ heartbeatAt: row.heartbeat_at?.toISOString(),
188
215
  });
189
216
  return new Job(shape.id, properties, shape.state, shape.workflowId, shape.appId, shape.startedBy, shape.startedAt,
190
- shape.lastUpdated, shape.killed, shape.status, shape.awaitMetadata, shape.waitingFor);
217
+ shape.lastUpdated, shape.killed, shape.status, shape.awaitMetadata, shape.waitingFor, shape.heartbeatAt);
191
218
  }
192
219
 
193
220
  }
@@ -0,0 +1,46 @@
1
+ import {PostgresJobPersistence} from "./PostgresJobPersistence";
2
+
3
+ const SWEEP_INTERVAL_MS = 60_000;
4
+
5
+ /* Marks a job Stalled when the long-running step that was heartbeating it has
6
+ gone quiet - the process running it died, most likely mid-deploy or on a
7
+ crash. Marking is all it does: the platform never re-runs a long step on its
8
+ own, because only the app knows what a half-finished one has already done.
9
+ Someone sees the status and decides. */
10
+ class StallSweep {
11
+
12
+ private ticker? : NodeJS.Timeout;
13
+ private sweeping = false;
14
+
15
+ constructor(private persistence : PostgresJobPersistence,
16
+ private intervalMs : number = SWEEP_INTERVAL_MS) {}
17
+
18
+ start() : void {
19
+ if (this.ticker) return;
20
+ this.ticker = setInterval(() => void this.sweep(), this.intervalMs);
21
+ this.ticker.unref();
22
+ }
23
+
24
+ async sweep() : Promise<number> {
25
+ if (this.sweeping) return 0;
26
+ this.sweeping = true;
27
+ try {
28
+ const stalled = await this.persistence.markStalled();
29
+ if (stalled > 0) console.warn(`[jobs] marked ${stalled} job(s) stalled: no heartbeat from a long-running step`);
30
+ return stalled;
31
+ } catch (error) {
32
+ console.error(`[jobs] the stall sweep failed: ${error instanceof Error ? error.message : error}`);
33
+ return 0;
34
+ } finally {
35
+ this.sweeping = false;
36
+ }
37
+ }
38
+
39
+ async cleanUp() : Promise<void> {
40
+ if (this.ticker) clearInterval(this.ticker);
41
+ this.ticker = undefined;
42
+ }
43
+
44
+ }
45
+
46
+ export { StallSweep }
@@ -116,6 +116,10 @@ class HostingServer {
116
116
  }
117
117
  const appProxy = buildLayer ? new AppProxyHandler(buildLayer) : undefined;
118
118
  if (buildLayer && appProxy) {
119
+ // So a deploy can ask the running version to drain: its consumers
120
+ // are wherever they registered themselves.
121
+ buildLayer.consumerUrlsFor = appName =>
122
+ [...new Set(registry.list().filter(consumer => consumer.appId === appName).map(consumer => consumer.url))];
119
123
  publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant));
120
124
  publicRouter.register("app", appProxy);
121
125
  }
@@ -79,9 +79,9 @@ class AppsHandler implements RequestHandler {
79
79
  return request.reply(200, { ...status, live });
80
80
  }
81
81
  case "DELETE": {
82
- const torn = await this.buildLayer.teardown(appName);
83
- if (!torn) return request.reply(404, { error: `No app named "${appName}"` });
84
- return request.reply(200, { appName, status: "stopped" });
82
+ const outcome = await this.buildLayer.teardown(appName);
83
+ if (!outcome) return request.reply(404, { error: `No app named "${appName}"` });
84
+ return request.reply(200, outcome);
85
85
  }
86
86
  }
87
87
  request.notFound();
@@ -22,6 +22,12 @@ class JobsHandler implements RequestHandler {
22
22
  await this.persistence.kill(request.id, SystemActor.actor);
23
23
  return request.reply(204);
24
24
  }
25
+ if (request.id && request.subresource === "heartbeat") {
26
+ if (request.method !== "POST") return request.notFound();
27
+ const body = await request.body().catch(() => ({}));
28
+ await this.persistence.heartbeat(request.id, body?.running !== false);
29
+ return request.reply(204);
30
+ }
25
31
  if (request.subresource) return request.notFound();
26
32
  if (request.id) return this.handleJob(request, request.id);
27
33
  return this.handleCollection(request);
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./app-management/BuildLayer";
2
2
  export * from "./auditing/AuditRecordStore";
3
3
  export * from "./auditing/InMemoryAuditRecordStore";
4
4
  export * from "./data-store/PostgresAuditRecordStore";
5
+ export * from "./data-store/StallSweep";
5
6
  export * from "./auth/Authenticator";
6
7
  export * from "./auth/CliAuthorizer";
7
8
  export * from "./auth/CliKey";
package/src/main.ts CHANGED
@@ -17,6 +17,7 @@ import {SecretsManagerSecretStore} from "./data-store/SecretsManagerSecretStore"
17
17
  import {S3FileStorage} from "./data-store/S3FileStorage";
18
18
  import {PostgresJobRunSchedulePersistence} from "./data-store/PostgresJobRunSchedulePersistence";
19
19
  import {PostgresJobPersistence} from "./data-store/PostgresJobPersistence";
20
+ import {StallSweep} from "./data-store/StallSweep";
20
21
  import {PostgresJsonStore} from "./data-store/PostgresJsonStore";
21
22
  import {PostgresQueue} from "./queuing/PostgresQueue";
22
23
  import {DockerBuildLayer} from "./app-management/DockerBuildLayer";
@@ -119,7 +120,8 @@ const fileStorageFor = (appId : string) : FileStorage =>
119
120
  ? new S3FileStorage(s3, filesBucket, appId ? `${appId}/` : "")
120
121
  : new LocalFileSystemStorage(join(process.env.ANBARIC_FILE_STORAGE_PATH ?? join(tmpdir(), "anbaric", "files"), appId));
121
122
 
122
- const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
123
+ const jobs = new PostgresJobPersistence(pool);
124
+ const server = new HostingServer(jobs, queue, registry, buildLayer,
123
125
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
124
126
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
125
127
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
@@ -131,4 +133,7 @@ const internal = await server.listenInternal(internalPort);
131
133
  const dispatcher = new Dispatcher(queue, registry, Number(process.env.ANBARIC_DISPATCH_INTERVAL_MS ?? 1000));
132
134
  dispatcher.start();
133
135
 
136
+ // Jobs whose long-running step stopped saying it was alive are marked, not touched.
137
+ new StallSweep(jobs).start();
138
+
134
139
  console.log(`anbaric-cloud-hosting listening on port ${port}, internal entry point on ${internal}`);