anbaric-cloud-hosting 1.23.2 → 1.25.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.23.2",
3
+ "version": "1.25.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.23.2",
22
- "anbaric-plugins": "^1.23.2",
23
- "anbaric-tsapi": "^1.23.2",
24
- "anbaric-web": "^1.23.2",
21
+ "anbaric-data-store": "^1.25.0",
22
+ "anbaric-plugins": "^1.25.0",
23
+ "anbaric-tsapi": "^1.25.0",
24
+ "anbaric-web": "^1.25.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.23.2",
32
- "anbaric-state-machine": "^1.23.2",
31
+ "anbaric-impl-cloud": "^1.25.0",
32
+ "anbaric-state-machine": "^1.25.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -9,6 +9,15 @@ import {AdminServer} from "./AdminServer";
9
9
  and its admin port stops answering `ping`. */
10
10
  const TSX = process.env.ANBARIC_TSX_BIN ?? "/anbaric/node_modules/.bin/tsx";
11
11
 
12
+ /* A host can name a module to load before the app's own entry point, which is
13
+ how it registers implementations an app picks up through the factories -
14
+ anything the app should be able to use without knowing it is there. It runs
15
+ in the app's process, so registering from the launcher would be too early. */
16
+ const preloadArgs = () => {
17
+ const preload = process.env.ANBARIC_APP_PRELOAD;
18
+ return preload ? ["--import", preload] : [];
19
+ };
20
+
12
21
  const main = async () => {
13
22
  const entryPoint = process.argv[2];
14
23
  if (!entryPoint) throw new Error("Expected the app entry point as the first argument");
@@ -17,7 +26,7 @@ const main = async () => {
17
26
  const port = await admin.listen();
18
27
  console.log(`[anbaric-admin] listening on ${port}`);
19
28
 
20
- const app = spawn(TSX, [entryPoint], { stdio: "inherit" });
29
+ const app = spawn(TSX, [...preloadArgs(), entryPoint], { stdio: "inherit" });
21
30
 
22
31
  const shutdown = (signal : NodeJS.Signals) => app.kill(signal);
23
32
  process.on("SIGTERM", shutdown);
@@ -49,9 +49,13 @@ class PostgresJobPersistence extends JobPersistence {
49
49
  await client.query(
50
50
  `INSERT INTO jobs (id, state, properties, workflow_id, app_id, started_at, started_by, last_updated, killed, status, waiting_for)
51
51
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
52
+ -- killed is deliberately not updated here. Only kill() sets it,
53
+ -- and a save carries whatever the job looked like when it was
54
+ -- read: a pass that began before a kill would otherwise write
55
+ -- killed=false straight back and bring the job back to life.
52
56
  ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, properties = EXCLUDED.properties,
53
57
  workflow_id = EXCLUDED.workflow_id, app_id = EXCLUDED.app_id, last_updated = EXCLUDED.last_updated,
54
- killed = EXCLUDED.killed, status = EXCLUDED.status, waiting_for = EXCLUDED.waiting_for`,
58
+ status = EXCLUDED.status, waiting_for = EXCLUDED.waiting_for`,
55
59
  [job.id, job.state, Object.fromEntries(job.properties), job.workflowId, job.appId || null,
56
60
  job.startedAt, job.startedBy, job.lastUpdated, job.killed, job.status, job.waitingFor ?? null],
57
61
  );
@@ -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 }
@@ -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,6 +106,7 @@ 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
 
107
111
  // The favicon is requested by the browser before anyone has signed in,
108
112
  // and by deployed apps' pages, so gating it behind a session would send
@@ -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 }