anbaric-cloud-hosting 1.23.1 → 1.24.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/data-store/PostgresJobRunSchedulePersistence.ts +56 -0
- package/src/data-store/Schema.ts +16 -0
- package/src/hosting/HostingServer.ts +10 -2
- package/src/hosting/handlers/JobRunSchedulesHandler.ts +46 -0
- package/src/hosting/pages/platform-ui.html +1 -1
- package/src/index.ts +2 -0
- package/src/main.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.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.24.0",
|
|
22
|
+
"anbaric-plugins": "^1.24.0",
|
|
23
|
+
"anbaric-tsapi": "^1.24.0",
|
|
24
|
+
"anbaric-web": "^1.24.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.24.0",
|
|
32
|
+
"anbaric-state-machine": "^1.24.0",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {JobRunSchedulePersistence, ScheduledRun} from "anbaric-tsapi";
|
|
2
|
+
import {Pool} from "pg";
|
|
3
|
+
|
|
4
|
+
const CLAIM_BATCH_SIZE = 100;
|
|
5
|
+
|
|
6
|
+
/* The shared store behind the job run scheduler. Claiming is a single
|
|
7
|
+
UPDATE ... RETURNING over rows selected FOR UPDATE SKIP LOCKED, so several
|
|
8
|
+
schedulers can point at one database and each due run is handed to exactly
|
|
9
|
+
one of them - no run started twice, none lost to a scheduler that dies
|
|
10
|
+
mid-claim. */
|
|
11
|
+
class PostgresJobRunSchedulePersistence implements JobRunSchedulePersistence {
|
|
12
|
+
|
|
13
|
+
constructor(private pool : Pool) {}
|
|
14
|
+
|
|
15
|
+
async highWaterMark(appId : string, workflowId : string) : Promise<Date | undefined> {
|
|
16
|
+
const result = await this.pool.query(
|
|
17
|
+
"SELECT max(run_at) AS latest FROM job_run_schedule WHERE app_id = $1 AND workflow_id = $2",
|
|
18
|
+
[appId, workflowId],
|
|
19
|
+
);
|
|
20
|
+
return result.rows[0]?.latest ?? undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async plan(runs : Array<ScheduledRun>) : Promise<void> {
|
|
24
|
+
if (runs.length === 0) return;
|
|
25
|
+
await this.pool.query(
|
|
26
|
+
`INSERT INTO job_run_schedule (app_id, workflow_id, run_at)
|
|
27
|
+
SELECT * FROM unnest($1::text[], $2::text[], $3::timestamptz[])
|
|
28
|
+
ON CONFLICT (app_id, workflow_id, run_at) DO NOTHING`,
|
|
29
|
+
[runs.map(run => run.appId), runs.map(run => run.workflowId), runs.map(run => run.runAt)],
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async claimDue(at : Date) : Promise<Array<ScheduledRun>> {
|
|
34
|
+
const result = await this.pool.query(
|
|
35
|
+
`UPDATE job_run_schedule SET claimed_at = now()
|
|
36
|
+
WHERE id IN (
|
|
37
|
+
SELECT id FROM job_run_schedule
|
|
38
|
+
WHERE claimed_at IS NULL AND run_at <= $1
|
|
39
|
+
ORDER BY run_at
|
|
40
|
+
LIMIT $2
|
|
41
|
+
FOR UPDATE SKIP LOCKED
|
|
42
|
+
)
|
|
43
|
+
RETURNING app_id, workflow_id, run_at`,
|
|
44
|
+
[at, CLAIM_BATCH_SIZE],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return result.rows.map(row => ({
|
|
48
|
+
appId: row.app_id ?? "",
|
|
49
|
+
workflowId: row.workflow_id,
|
|
50
|
+
runAt: row.run_at,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { PostgresJobRunSchedulePersistence }
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -78,6 +78,22 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
|
78
78
|
SET app_id = split_part(workflow_id, '/', 1),
|
|
79
79
|
workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
|
|
80
80
|
WHERE app_id IS NULL AND workflow_id LIKE '%/%'`);
|
|
81
|
+
// Runs a scheduler has planned. Unique on (app_id, workflow_id, run_at) so
|
|
82
|
+
// two schedulers planning the same window converge on one row rather than
|
|
83
|
+
// starting a machine twice.
|
|
84
|
+
await pool.query(`
|
|
85
|
+
CREATE TABLE IF NOT EXISTS job_run_schedule (
|
|
86
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
87
|
+
app_id TEXT NOT NULL DEFAULT '',
|
|
88
|
+
workflow_id TEXT NOT NULL,
|
|
89
|
+
run_at TIMESTAMPTZ NOT NULL,
|
|
90
|
+
claimed_at TIMESTAMPTZ
|
|
91
|
+
)
|
|
92
|
+
`);
|
|
93
|
+
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS job_run_schedule_run
|
|
94
|
+
ON job_run_schedule (app_id, workflow_id, run_at)`);
|
|
95
|
+
await pool.query(`CREATE INDEX IF NOT EXISTS job_run_schedule_due
|
|
96
|
+
ON job_run_schedule (run_at) WHERE claimed_at IS NULL`);
|
|
81
97
|
await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
|
|
82
98
|
await pool.query(`
|
|
83
99
|
CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {JobPersistence, JsonStore, SecretStore} from "anbaric-tsapi";
|
|
1
|
+
import {JobPersistence, JobRunSchedulePersistence, JsonStore, SecretStore} from "anbaric-tsapi";
|
|
2
2
|
import {BuildLayer} from "../app-management/BuildLayer";
|
|
3
3
|
import {AuditRecordStore} from "../auditing/AuditRecordStore";
|
|
4
4
|
import {Authenticator} from "../auth/Authenticator";
|
|
@@ -18,6 +18,7 @@ import {ConsumersHandler} from "./handlers/ConsumersHandler";
|
|
|
18
18
|
import {DocumentsHandler} from "./handlers/DocumentsHandler";
|
|
19
19
|
import {JobsHandler} from "./handlers/JobsHandler";
|
|
20
20
|
import {FaviconHandler} from "./handlers/FaviconHandler";
|
|
21
|
+
import {JobRunSchedulesHandler} from "./handlers/JobRunSchedulesHandler";
|
|
21
22
|
import {LogoutHandler} from "./handlers/LogoutHandler";
|
|
22
23
|
import {PagesHandler} from "./handlers/PagesHandler";
|
|
23
24
|
import {PingHandler} from "./handlers/PingHandler";
|
|
@@ -54,7 +55,8 @@ class HostingServer {
|
|
|
54
55
|
tokenAuthenticator? : TokenAuthenticator,
|
|
55
56
|
tenant? : string,
|
|
56
57
|
auditRecords? : AuditRecordStore,
|
|
57
|
-
plugins : Array<LoadedPlugin> = []
|
|
58
|
+
plugins : Array<LoadedPlugin> = [],
|
|
59
|
+
jobRunSchedules? : JobRunSchedulePersistence) {
|
|
58
60
|
const pages = new PagesHandler();
|
|
59
61
|
const ping = new PingHandler(tenant);
|
|
60
62
|
const jobs = new JobsHandler(persistence);
|
|
@@ -81,6 +83,7 @@ class HostingServer {
|
|
|
81
83
|
if (documents) publicRouter.registerApi("documents", documents);
|
|
82
84
|
if (secrets) publicRouter.registerApi("secrets", secrets);
|
|
83
85
|
if (audits) publicRouter.registerApi("audits", audits);
|
|
86
|
+
if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
|
|
84
87
|
if (cliAuthorizer) {
|
|
85
88
|
publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
|
|
86
89
|
publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
|
|
@@ -103,9 +106,14 @@ class HostingServer {
|
|
|
103
106
|
if (documents) internalRouter.registerApi("documents", documents);
|
|
104
107
|
if (secrets) internalRouter.registerApi("secrets", secrets);
|
|
105
108
|
if (audits) internalRouter.registerApi("audits", audits);
|
|
109
|
+
if (jobRunSchedules) internalRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
|
|
106
110
|
|
|
111
|
+
// The favicon is requested by the browser before anyone has signed in,
|
|
112
|
+
// and by deployed apps' pages, so gating it behind a session would send
|
|
113
|
+
// an icon request to the login flow and leave every page iconless.
|
|
107
114
|
const openRequests = (request : Request) =>
|
|
108
115
|
request.url.pathname === "/ping" ||
|
|
116
|
+
request.url.pathname === "/favicon.ico" ||
|
|
109
117
|
(request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
|
|
110
118
|
|
|
111
119
|
this.publicServer = new Server(publicRouter, [
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {JobRunSchedulePersistence} from "anbaric-tsapi";
|
|
2
|
+
import {Request} from "../Request";
|
|
3
|
+
import {RequestHandler} from "../RequestHandler";
|
|
4
|
+
|
|
5
|
+
class JobRunSchedulesHandler implements RequestHandler {
|
|
6
|
+
|
|
7
|
+
constructor(private persistence : JobRunSchedulePersistence) {}
|
|
8
|
+
|
|
9
|
+
async handle(request : Request) : Promise<void> {
|
|
10
|
+
if (request.subresource) return request.notFound();
|
|
11
|
+
|
|
12
|
+
if (request.method === "GET" && request.id === "high-water-mark") {
|
|
13
|
+
const appId = request.url.searchParams.get("appId") ?? "";
|
|
14
|
+
const workflowId = request.url.searchParams.get("workflowId");
|
|
15
|
+
if (!workflowId) return request.reply(400, { error: "A workflowId is required" });
|
|
16
|
+
|
|
17
|
+
const latest = await this.persistence.highWaterMark(appId, workflowId);
|
|
18
|
+
return request.reply(200, { highWaterMark: latest?.toISOString() ?? null });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (request.method !== "POST" || !request.id) return request.notFound();
|
|
22
|
+
|
|
23
|
+
switch (request.id) {
|
|
24
|
+
case "plan": {
|
|
25
|
+
const { runs } = await request.body();
|
|
26
|
+
await this.persistence.plan((runs ?? []).map((run : any) => ({
|
|
27
|
+
appId: run.appId ?? "",
|
|
28
|
+
workflowId: run.workflowId,
|
|
29
|
+
runAt: new Date(run.runAt),
|
|
30
|
+
})));
|
|
31
|
+
return request.reply(204);
|
|
32
|
+
}
|
|
33
|
+
case "claim-due": {
|
|
34
|
+
const { at } = await request.body();
|
|
35
|
+
const claimed = await this.persistence.claimDue(at ? new Date(at) : new Date());
|
|
36
|
+
return request.reply(200, {
|
|
37
|
+
runs: claimed.map(run => ({ ...run, runAt: run.runAt.toISOString() })),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
request.notFound();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { JobRunSchedulesHandler }
|