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.
|
|
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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
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.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
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
|
|
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}`);
|