anbaric-cloud-hosting 1.59.0 → 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.59.0",
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.59.0",
22
- "anbaric-plugins": "^1.59.0",
23
- "anbaric-tsapi": "^1.59.0",
24
- "anbaric-web": "^1.59.0",
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.59.0",
32
- "anbaric-state-machine": "^1.59.0",
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
  },
@@ -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 }
@@ -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}`);