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 +7 -7
- package/src/data-store/Schema.ts +2 -0
- package/src/hosting/HostingServer.ts +2 -2
- package/src/hosting/handlers/QueueHandler.ts +2 -2
- package/src/index.ts +1 -1
- package/src/queuing/Dispatcher.ts +13 -21
- package/src/queuing/PostgresQueue.ts +28 -2
- package/src/queuing/RemoteQueue.ts +17 -0
- package/src/queuing/ConfirmableQueue.ts +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.21.
|
|
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.
|
|
22
|
-
"anbaric-plugins": "^1.21.
|
|
23
|
-
"anbaric-tsapi": "^1.21.
|
|
24
|
-
"anbaric-web": "^1.21.
|
|
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.
|
|
32
|
-
"anbaric-state-machine": "^1.21.
|
|
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
|
},
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -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 {
|
|
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 :
|
|
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 {
|
|
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 :
|
|
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/
|
|
55
|
+
export * from "./queuing/RemoteQueue";
|
|
56
56
|
export * from "./queuing/ConsumerRegistry";
|
|
57
57
|
export * from "./queuing/Dispatcher";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 }
|