anbaric-cloud-hosting 1.21.3 → 1.21.4

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.21.3",
3
+ "version": "1.21.4",
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.21.3",
22
- "anbaric-plugins": "^1.21.3",
23
- "anbaric-tsapi": "^1.21.3",
24
- "anbaric-web": "^1.21.3",
21
+ "anbaric-data-store": "^1.21.4",
22
+ "anbaric-plugins": "^1.21.4",
23
+ "anbaric-tsapi": "^1.21.4",
24
+ "anbaric-web": "^1.21.4",
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.21.3",
32
- "anbaric-state-machine": "^1.21.3",
31
+ "anbaric-impl-cloud": "^1.21.4",
32
+ "anbaric-state-machine": "^1.21.4",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -72,6 +72,8 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
72
72
  )
73
73
  `);
74
74
  await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS app_id TEXT");
75
+ await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS retry_at TIMESTAMPTZ");
76
+ await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0");
75
77
  await pool.query(`UPDATE queue
76
78
  SET app_id = split_part(workflow_id, '/', 1),
77
79
  workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
@@ -4,7 +4,7 @@ import {AuditRecordStore} from "../auditing/AuditRecordStore";
4
4
  import {Authenticator} from "../auth/Authenticator";
5
5
  import {CliAuthorizer} from "../auth/CliAuthorizer";
6
6
  import {TokenAuthenticator} from "../auth/TokenAuthenticator";
7
- import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
7
+ import {RemoteQueue} from "../queuing/RemoteQueue";
8
8
  import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
9
9
  import {AppProxyHandler} from "./handlers/AppProxyHandler";
10
10
  import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
@@ -42,7 +42,7 @@ class HostingServer {
42
42
  private publicServer : Server;
43
43
  private internalServer : Server;
44
44
 
45
- constructor(persistence : JobPersistence, queue : ConfirmableQueue,
45
+ constructor(persistence : JobPersistence, queue : RemoteQueue,
46
46
  registry : ConsumerRegistry = new ConsumerRegistry(),
47
47
  buildLayer? : BuildLayer,
48
48
  documentStoreFor? : (appId : string, collection : string) => JsonStore,
@@ -1,10 +1,10 @@
1
- import {ConfirmableQueue} from "../../queuing/ConfirmableQueue";
1
+ import {RemoteQueue} from "../../queuing/RemoteQueue";
2
2
  import {Request} from "../Request";
3
3
  import {RequestHandler} from "../RequestHandler";
4
4
 
5
5
  class QueueHandler implements RequestHandler {
6
6
 
7
- constructor(private queue : ConfirmableQueue) {}
7
+ constructor(private queue : RemoteQueue) {}
8
8
 
9
9
  async handle(request : Request) : Promise<void> {
10
10
  if (request.subresource) return request.notFound();
package/src/index.ts CHANGED
@@ -52,6 +52,6 @@ export * from "./hosting/handlers/StateMachinesHandler";
52
52
  export * from "./hosting/handlers/auth/AuthorizeCliHandler";
53
53
  export * from "./hosting/handlers/auth/KeysHandler";
54
54
  export * from "./hosting/handlers/auth/WhoamiHandler";
55
- export * from "./queuing/ConfirmableQueue";
55
+ export * from "./queuing/RemoteQueue";
56
56
  export * from "./queuing/ConsumerRegistry";
57
57
  export * from "./queuing/Dispatcher";
@@ -1,4 +1,5 @@
1
- import {Dequeue, QueueMessage} from "anbaric-tsapi";
1
+ import {QueueMessage} from "anbaric-tsapi";
2
+ import {RemoteQueue} from "./RemoteQueue";
2
3
  import {ConsumerRegistry} from "./ConsumerRegistry";
3
4
 
4
5
  class Dispatcher {
@@ -6,18 +7,8 @@ class Dispatcher {
6
7
  private ticker? : NodeJS.Timeout;
7
8
  private draining = false;
8
9
 
9
- /* A confirmable queue leases each dequeued message and redelivers any it is
10
- not `confirm`ed (the consumer confirms once it has processed it). So the
11
- dispatcher must NOT put unroutable or failed messages back — the leased
12
- row already redelivers, and re-enqueuing would add a fresh duplicate on
13
- every tick, growing the queue without bound. A plain queue removes on
14
- dequeue, so there those messages must be re-enqueued to be retried. */
15
- private readonly redelivers : boolean;
16
-
17
- constructor(private queue : Dequeue, private registry : ConsumerRegistry,
18
- private dispatchIntervalMs : number = 1000) {
19
- this.redelivers = typeof (this.queue as { confirm? : unknown }).confirm === "function";
20
- }
10
+ constructor(private queue : RemoteQueue, private registry : ConsumerRegistry,
11
+ private dispatchIntervalMs : number = 1000) {}
21
12
 
22
13
  start() : void {
23
14
  if (this.ticker) return;
@@ -30,11 +21,16 @@ class Dispatcher {
30
21
  this.ticker = undefined;
31
22
  }
32
23
 
24
+ /* The queue leases each dequeued message and redelivers any it is not
25
+ `confirm`ed — the consumer confirms once it has processed one. So the
26
+ dispatcher never puts a message back: a failed push just leaves the
27
+ message to redeliver on its next lease. A message with no registered
28
+ listener yet is debounced (backed off), which lets a momentary
29
+ registration race resolve and cancels the message only if it never
30
+ finds a listener. */
33
31
  private async drain() : Promise<void> {
34
-
35
32
  if (this.draining) return;
36
33
  this.draining = true;
37
-
38
34
  try {
39
35
  const messages = await this.queue.dequeueSome();
40
36
  const byConsumerUrl = new Map<string, Array<QueueMessage>>();
@@ -42,7 +38,7 @@ class Dispatcher {
42
38
  for (const message of messages) {
43
39
  const url = this.registry.lookup(message.appId, message.workflowId);
44
40
  if (!url) {
45
- if (!this.redelivers) await this.queue.enqueue(message.jobId, message.appId, message.workflowId);
41
+ await this.queue.debounce(message);
46
42
  continue;
47
43
  }
48
44
  byConsumerUrl.set(url, [...(byConsumerUrl.get(url) ?? []), message]);
@@ -65,11 +61,7 @@ class Dispatcher {
65
61
  });
66
62
  if (!response.ok) throw new Error(`Consumer at ${url} responded with status ${response.status}`);
67
63
  } catch {
68
- if (!this.redelivers) {
69
- for (const message of batch) {
70
- await this.queue.enqueue(message.jobId, message.appId, message.workflowId);
71
- }
72
- }
64
+ // Left in the queue: the unconfirmed lease redelivers it for retry.
73
65
  }
74
66
  }
75
67
 
@@ -1,11 +1,12 @@
1
1
  import {QueueMessage} from "anbaric-tsapi";
2
2
  import {Pool} from "pg";
3
- import {ConfirmableQueue} from "./ConfirmableQueue";
3
+ import {RemoteQueue} from "./RemoteQueue";
4
4
 
5
5
  const DEQUEUE_BATCH_SIZE = 100;
6
6
  const LEASE_SECONDS = 30;
7
+ const MAX_ATTEMPTS = 5;
7
8
 
8
- class PostgresQueue implements ConfirmableQueue {
9
+ class PostgresQueue implements RemoteQueue {
9
10
 
10
11
  constructor(private pool : Pool) {}
11
12
 
@@ -23,6 +24,7 @@ class PostgresQueue implements ConfirmableQueue {
23
24
  WHERE position IN (
24
25
  SELECT position FROM queue
25
26
  WHERE (due IS NULL OR due <= now())
27
+ AND (retry_at IS NULL OR retry_at <= now())
26
28
  AND (leased_until IS NULL OR leased_until < now())
27
29
  ORDER BY (due IS NOT NULL), due, position
28
30
  LIMIT $1
@@ -47,6 +49,30 @@ class PostgresQueue implements ConfirmableQueue {
47
49
  await this.pool.query("DELETE FROM queue WHERE position = $1", [message.position]);
48
50
  }
49
51
 
52
+ async debounce(message : QueueMessage) : Promise<void> {
53
+ if (message.position === undefined) return;
54
+ // First bounce backs off 10s (a consumer that is mid-registration is
55
+ // ready by then); later bounces back off a minute. Clearing the lease
56
+ // makes the row eligible again the moment retry_at passes. After enough
57
+ // bounces the message is a genuine orphan, so it is cancelled.
58
+ const result = await this.pool.query(
59
+ `UPDATE queue
60
+ SET attempts = attempts + 1,
61
+ retry_at = now() + (CASE WHEN attempts = 0 THEN interval '10 seconds' ELSE interval '1 minute' END),
62
+ leased_until = NULL
63
+ WHERE position = $1
64
+ RETURNING attempts`,
65
+ [message.position],
66
+ );
67
+ if (Number(result.rows[0]?.attempts) >= MAX_ATTEMPTS) await this.cancel(message);
68
+ }
69
+
70
+ async cancel(message : QueueMessage) : Promise<void> {
71
+ console.warn(`Cancelling undeliverable queue message for job "${message.jobId}" (workflow "${message.workflowId}")`);
72
+ if (message.position === undefined) return;
73
+ await this.pool.query("DELETE FROM queue WHERE position = $1", [message.position]);
74
+ }
75
+
50
76
  async size() : Promise<number> {
51
77
  const result = await this.pool.query("SELECT count(*)::int AS count FROM queue");
52
78
  return result.rows[0].count;
@@ -0,0 +1,17 @@
1
+ import {Dequeue, QueueMessage} from "anbaric-tsapi";
2
+
3
+ interface RemoteQueue extends Dequeue {
4
+
5
+ confirm(message : QueueMessage) : Promise<void>;
6
+ // Push a message that could not be delivered yet (e.g. its consumer has not
7
+ // registered) into the future with a growing back-off, so a momentary race
8
+ // resolves itself. After enough bounces the message is cancelled.
9
+ debounce(message : QueueMessage) : Promise<void>;
10
+ // Discard a message that can never be delivered. Removes it like confirm,
11
+ // but signals an error.
12
+ cancel(message : QueueMessage) : Promise<void>;
13
+ size() : Promise<number>;
14
+
15
+ }
16
+
17
+ export { RemoteQueue }
@@ -1,10 +0,0 @@
1
- import {Dequeue, QueueMessage} from "anbaric-tsapi";
2
-
3
- interface ConfirmableQueue extends Dequeue {
4
-
5
- confirm(message : QueueMessage) : Promise<void>;
6
- size() : Promise<number>;
7
-
8
- }
9
-
10
- export { ConfirmableQueue }