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 +7 -7
- package/src/app-management/BaseBuildLayer.ts +84 -10
- package/src/app-management/BuildLayer.ts +19 -3
- package/src/app-management/FargateBuildLayer.ts +3 -0
- package/src/data-store/PlatformMigrations.ts +7 -0
- package/src/data-store/PostgresJobPersistence.ts +29 -2
- package/src/data-store/StallSweep.ts +46 -0
- package/src/hosting/HostingServer.ts +4 -0
- package/src/hosting/handlers/AppsHandler.ts +3 -3
- package/src/hosting/handlers/JobsHandler.ts +6 -0
- package/src/index.ts +1 -0
- package/src/main.ts +6 -1
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
|
},
|
|
@@ -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<
|
|
107
|
+
async teardown(appName : string) : Promise<DeploymentSummary | undefined> {
|
|
99
108
|
const deployment = this.deployments.get(appName);
|
|
100
|
-
if (!deployment) return
|
|
109
|
+
if (!deployment) return undefined;
|
|
101
110
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
if (!
|
|
84
|
-
return request.reply(200,
|
|
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
|
|
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}`);
|