@podosoft/podokit 1.3.0 → 1.4.1
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/README.md +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import type { ConnectionOptions } from "bullmq";
|
|
3
|
+
import { BullMqJobQueue, externalJobId, parseExternalJobId } from "./bullmq-job.queue";
|
|
4
|
+
import { producerConnection, reconnectDelay, redisConnection } from "./queue";
|
|
5
|
+
|
|
6
|
+
function producerAt(port: number): ConnectionOptions {
|
|
7
|
+
return producerConnection({
|
|
8
|
+
REDIS_HOST: "127.0.0.1",
|
|
9
|
+
REDIS_PORT: String(port),
|
|
10
|
+
REDIS_COMMAND_TIMEOUT_MS: "100",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function closedPort(): number {
|
|
15
|
+
const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
16
|
+
const port = server.port;
|
|
17
|
+
server.stop(true);
|
|
18
|
+
return port;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Answers the connection handshake and INFO like Redis 7, but stops answering a
|
|
23
|
+
* connection, without closing it, once it sends any other command.
|
|
24
|
+
*/
|
|
25
|
+
function stallingRedis(): { port: number; stop: () => void } {
|
|
26
|
+
const info = "# Server\r\nredis_version:7.2.0\r\nloading:0\r\n";
|
|
27
|
+
const stalled = new WeakSet<object>();
|
|
28
|
+
const server = Bun.listen({
|
|
29
|
+
hostname: "127.0.0.1",
|
|
30
|
+
port: 0,
|
|
31
|
+
socket: {
|
|
32
|
+
data(socket, data) {
|
|
33
|
+
// Each command is an array of bulk strings: "*<count>", "$<length>", "<name>", ...
|
|
34
|
+
const lines = data.toString().split("\r\n");
|
|
35
|
+
lines.forEach((line, index) => {
|
|
36
|
+
if (stalled.has(socket) || !line.startsWith("*")) return;
|
|
37
|
+
const command = lines[index + 2]?.toLowerCase();
|
|
38
|
+
if (command === "client") socket.write("+OK\r\n");
|
|
39
|
+
else if (command === "info") socket.write(`$${Buffer.byteLength(info)}\r\n${info}\r\n`);
|
|
40
|
+
else stalled.add(socket);
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return { port: server.port, stop: () => server.stop(true) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function rejection(promise: Promise<unknown>): Promise<unknown> {
|
|
49
|
+
return promise.then(() => undefined, (error: unknown) => error);
|
|
50
|
+
}
|
|
3
51
|
|
|
4
52
|
describe("BullMQ job identifiers", () => {
|
|
5
53
|
it("round-trips queue names without exposing an ambiguous id", () => {
|
|
@@ -8,3 +56,75 @@ describe("BullMQ job identifiers", () => {
|
|
|
8
56
|
expect(parseExternalJobId("invalid")).toBeNull();
|
|
9
57
|
});
|
|
10
58
|
});
|
|
59
|
+
|
|
60
|
+
describe("BullMQ connections", () => {
|
|
61
|
+
it("lets workers wait for replies and gives producers a bounded, supervised connection", () => {
|
|
62
|
+
expect(redisConnection({})).toMatchObject({ maxRetriesPerRequest: null });
|
|
63
|
+
const producer = producerConnection({ REDIS_COMMAND_TIMEOUT_MS: "750" });
|
|
64
|
+
expect(producer).toMatchObject({ enableOfflineQueue: false, commandTimeout: 750, lazyConnect: true });
|
|
65
|
+
expect("retryStrategy" in producer && producer.retryStrategy?.(1)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("backs off reconnects from 250 ms up to 5 seconds", () => {
|
|
69
|
+
expect([1, 2, 3, 4, 5, 6, 30].map(reconnectDelay)).toEqual([250, 500, 1_000, 2_000, 4_000, 5_000, 5_000]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("BullMqJobQueue while Redis is unavailable", () => {
|
|
74
|
+
it("fails enqueues and reads at once before the producer has connected", async () => {
|
|
75
|
+
const queue = new BullMqJobQueue(producerAt(closedPort()));
|
|
76
|
+
try {
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
expect(await rejection(queue.enqueue("demo", { text: "hello" })))
|
|
79
|
+
.toMatchObject({ code: "JOBS_UNAVAILABLE", statusCode: 503 });
|
|
80
|
+
expect(await rejection(queue.get(externalJobId("demo", "1"))))
|
|
81
|
+
.toMatchObject({ code: "JOBS_UNAVAILABLE" });
|
|
82
|
+
expect(Date.now() - started).toBeLessThan(100);
|
|
83
|
+
} finally {
|
|
84
|
+
await queue.close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reports a refused connection to the supervisor and keeps failing enqueues at once", async () => {
|
|
89
|
+
const queue = new BullMqJobQueue(producerAt(closedPort()));
|
|
90
|
+
const lost: unknown[] = [];
|
|
91
|
+
queue.onLost((reason) => { lost.push(reason); });
|
|
92
|
+
try {
|
|
93
|
+
await expect(queue.connect()).rejects.toThrow();
|
|
94
|
+
await expect(queue.ready()).rejects.toMatchObject({ code: "JOBS_UNAVAILABLE" });
|
|
95
|
+
const started = Date.now();
|
|
96
|
+
expect(await rejection(queue.enqueue("demo", {}))).toMatchObject({ code: "JOBS_UNAVAILABLE" });
|
|
97
|
+
expect(Date.now() - started).toBeLessThan(100);
|
|
98
|
+
expect(lost.length).toBeGreaterThan(0);
|
|
99
|
+
} finally {
|
|
100
|
+
await queue.close();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("drops a connection Redis stops answering and fails at once until the supervisor reopens it", async () => {
|
|
105
|
+
const redis = stallingRedis();
|
|
106
|
+
const queue = new BullMqJobQueue(producerAt(redis.port));
|
|
107
|
+
const lost: unknown[] = [];
|
|
108
|
+
queue.onLost((reason) => { lost.push(reason); });
|
|
109
|
+
try {
|
|
110
|
+
await queue.connect();
|
|
111
|
+
let started = Date.now();
|
|
112
|
+
expect(await rejection(queue.enqueue("demo", { text: "hello" })))
|
|
113
|
+
.toMatchObject({ code: "JOBS_UNAVAILABLE", statusCode: 503 });
|
|
114
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
115
|
+
expect(lost.length).toBe(1);
|
|
116
|
+
|
|
117
|
+
started = Date.now();
|
|
118
|
+
expect(await rejection(queue.enqueue("demo", {}))).toMatchObject({ code: "JOBS_UNAVAILABLE" });
|
|
119
|
+
expect(Date.now() - started).toBeLessThan(100);
|
|
120
|
+
|
|
121
|
+
await queue.connect();
|
|
122
|
+
await queue.ready();
|
|
123
|
+
await Bun.sleep(50);
|
|
124
|
+
expect(lost.length).toBe(1);
|
|
125
|
+
} finally {
|
|
126
|
+
await queue.close();
|
|
127
|
+
redis.stop();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { Queue, RedisConnection, Worker, type ConnectionOptions, type RedisClient } from "bullmq";
|
|
2
3
|
import type {
|
|
3
4
|
EnqueueOptions,
|
|
4
5
|
JobHandler,
|
|
@@ -8,10 +9,15 @@ import type {
|
|
|
8
9
|
JobStatus,
|
|
9
10
|
Unsubscribe,
|
|
10
11
|
} from "@podosoft/podokit-runtime";
|
|
11
|
-
import {
|
|
12
|
+
import type { SupervisedConnection } from "../core/services";
|
|
13
|
+
import { producerConnection, redisConnection } from "./queue";
|
|
12
14
|
|
|
13
15
|
const ID_SEPARATOR = "~";
|
|
14
16
|
|
|
17
|
+
// BullMQ would otherwise wait for Redis and ask its version before the first
|
|
18
|
+
// command, which keeps a call pending while Redis is down instead of failing it.
|
|
19
|
+
const NO_STARTUP_ROUND_TRIP = { skipWaitingForReady: true, skipVersionCheck: true } as const;
|
|
20
|
+
|
|
15
21
|
export function externalJobId(name: string, id: string): string {
|
|
16
22
|
return `${encodeURIComponent(name)}${ID_SEPARATOR}${id}`;
|
|
17
23
|
}
|
|
@@ -31,23 +37,57 @@ function statusOf(state: string): JobStatus {
|
|
|
31
37
|
return "waiting";
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
function isReplyTimeout(error: unknown): boolean {
|
|
41
|
+
return error instanceof Error && error.message === "Command timed out";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function jobsUnavailable(): AppException {
|
|
45
|
+
return new AppException("JOBS_UNAVAILABLE", "Background jobs are temporarily unavailable.", 503);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Adds and reads jobs over one producer connection that the service registry
|
|
50
|
+
* supervises, and runs in-process handlers on BullMQ workers, which keep their
|
|
51
|
+
* own blocking connections and reconnect by themselves.
|
|
52
|
+
*/
|
|
53
|
+
export class BullMqJobQueue implements JobQueue, SupervisedConnection {
|
|
35
54
|
private readonly queues = new Map<string, Queue>();
|
|
36
55
|
private readonly workers = new Map<string, Worker>();
|
|
56
|
+
private producer: RedisConnection;
|
|
57
|
+
private lostListener?: (reason: unknown) => void;
|
|
58
|
+
private closed = false;
|
|
59
|
+
|
|
60
|
+
constructor(private readonly connection: ConnectionOptions = producerConnection()) {
|
|
61
|
+
this.producer = this.openProducer();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onLost(listener: (reason: unknown) => void): void {
|
|
65
|
+
this.lostListener = listener;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Opens the producer connection. */
|
|
69
|
+
async connect(): Promise<void> {
|
|
70
|
+
const client = await this.producer.client;
|
|
71
|
+
if (client.status !== "ready") await client.connect();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rejects while Redis is unreachable or slower to answer than `REDIS_COMMAND_TIMEOUT_MS`. */
|
|
75
|
+
async ready(): Promise<void> {
|
|
76
|
+
await this.command((client) => client.info());
|
|
77
|
+
}
|
|
37
78
|
|
|
38
79
|
async enqueue(
|
|
39
80
|
name: string,
|
|
40
81
|
payload: unknown,
|
|
41
82
|
options: EnqueueOptions = {},
|
|
42
83
|
): Promise<JobReference> {
|
|
43
|
-
const
|
|
44
|
-
const job = await queue.add(name, payload, {
|
|
84
|
+
const job = await this.command((client) => this.queue(name, client).add(name, payload, {
|
|
45
85
|
...(options.delayMs === undefined ? {} : { delay: options.delayMs }),
|
|
46
86
|
...(options.attempts === undefined ? {} : { attempts: options.attempts }),
|
|
47
87
|
...(options.deduplicationKey === undefined
|
|
48
88
|
? {}
|
|
49
89
|
: { deduplication: { id: options.deduplicationKey } }),
|
|
50
|
-
});
|
|
90
|
+
}));
|
|
51
91
|
if (job.id === undefined) throw new Error("BullMQ did not assign a job id");
|
|
52
92
|
return { id: externalJobId(name, job.id), name };
|
|
53
93
|
}
|
|
@@ -55,12 +95,13 @@ export class BullMqJobQueue implements JobQueue {
|
|
|
55
95
|
async get(externalId: string): Promise<JobSnapshot | null> {
|
|
56
96
|
const parsed = parseExternalJobId(externalId);
|
|
57
97
|
if (!parsed) return null;
|
|
58
|
-
const job = await this.queue(parsed.name).getJob(parsed.id);
|
|
98
|
+
const job = await this.command((client) => this.queue(parsed.name, client).getJob(parsed.id));
|
|
59
99
|
if (!job) return null;
|
|
100
|
+
const state = await this.command(() => job.getState());
|
|
60
101
|
return {
|
|
61
102
|
id: externalId,
|
|
62
103
|
name: parsed.name,
|
|
63
|
-
status: statusOf(
|
|
104
|
+
status: statusOf(state),
|
|
64
105
|
attempts: job.attemptsMade,
|
|
65
106
|
createdAt: job.timestamp,
|
|
66
107
|
updatedAt: job.finishedOn ?? job.processedOn ?? job.timestamp,
|
|
@@ -89,18 +130,75 @@ export class BullMqJobQueue implements JobQueue {
|
|
|
89
130
|
}
|
|
90
131
|
|
|
91
132
|
async close(): Promise<void> {
|
|
133
|
+
this.closed = true;
|
|
92
134
|
await Promise.all([
|
|
93
135
|
...[...this.workers.values()].map((worker) => worker.close()),
|
|
94
136
|
...[...this.queues.values()].map((queue) => queue.close()),
|
|
95
137
|
]);
|
|
96
138
|
this.workers.clear();
|
|
97
139
|
this.queues.clear();
|
|
140
|
+
await this.producer.close(true);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Runs `send` on the producer connection and fails it with JOBS_UNAVAILABLE
|
|
145
|
+
* while the connection is closed or when Redis does not answer in time.
|
|
146
|
+
*/
|
|
147
|
+
private async command<T>(send: (client: RedisClient) => Promise<T>): Promise<T> {
|
|
148
|
+
const producer = this.producer;
|
|
149
|
+
const client = await producer.client;
|
|
150
|
+
if (client.status !== "ready") throw jobsUnavailable();
|
|
151
|
+
try {
|
|
152
|
+
return await send(client);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const timedOut = isReplyTimeout(error);
|
|
155
|
+
if (timedOut && producer === this.producer) this.replaceProducer(error);
|
|
156
|
+
if (timedOut || producer !== this.producer || client.status !== "ready") throw jobsUnavailable();
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private openProducer(): RedisConnection {
|
|
162
|
+
const producer = new RedisConnection(this.connection, {
|
|
163
|
+
shared: false,
|
|
164
|
+
blocking: false,
|
|
165
|
+
...NO_STARTUP_ROUND_TRIP,
|
|
166
|
+
});
|
|
167
|
+
// Connection errors reach callers as JOBS_UNAVAILABLE and the supervisor's log.
|
|
168
|
+
producer.on("error", () => undefined);
|
|
169
|
+
producer.on("close", () => {
|
|
170
|
+
if (producer === this.producer) this.report(new Error("Connection closed"));
|
|
171
|
+
});
|
|
172
|
+
return producer;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Redis answers in order, so after a reply times out every later command
|
|
177
|
+
* would wait behind it, and a server that stopped answering never completes
|
|
178
|
+
* the close either. The connection is dropped for a new one that the
|
|
179
|
+
* supervisor opens.
|
|
180
|
+
*/
|
|
181
|
+
private replaceProducer(reason: unknown): void {
|
|
182
|
+
const stale = this.producer;
|
|
183
|
+
const queues = [...this.queues.values()];
|
|
184
|
+
this.queues.clear();
|
|
185
|
+
this.producer = this.openProducer();
|
|
186
|
+
void Promise.all(queues.map((queue) => queue.close()))
|
|
187
|
+
.then(() => stale.close(true))
|
|
188
|
+
.catch(() => undefined);
|
|
189
|
+
this.report(reason);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private report(reason: unknown): void {
|
|
193
|
+
if (!this.closed) this.lostListener?.(reason);
|
|
98
194
|
}
|
|
99
195
|
|
|
100
|
-
private queue(name: string): Queue {
|
|
196
|
+
private queue(name: string, client: RedisClient): Queue {
|
|
101
197
|
const current = this.queues.get(name);
|
|
102
198
|
if (current) return current;
|
|
103
|
-
const queue = new Queue(name, { connection:
|
|
199
|
+
const queue = new Queue(name, { connection: client, ...NO_STARTUP_ROUND_TRIP });
|
|
200
|
+
// It reports the producer connection's errors, which `openProducer` already handles.
|
|
201
|
+
queue.on("error", () => undefined);
|
|
104
202
|
this.queues.set(name, queue);
|
|
105
203
|
return queue;
|
|
106
204
|
}
|
|
@@ -2,7 +2,7 @@ import { AppException } from "@podosoft/podokit-contracts";
|
|
|
2
2
|
import { JOBS } from "@podosoft/podokit-runtime";
|
|
3
3
|
import { Elysia, t } from "elysia";
|
|
4
4
|
import { PROVIDERS } from "../config/providers";
|
|
5
|
-
import type
|
|
5
|
+
import { READINESS, type AppPlugin, type PodokitModule } from "../core/services";
|
|
6
6
|
import { BullMqJobQueue } from "./bullmq-job.queue";
|
|
7
7
|
|
|
8
8
|
const jobsPlugin: AppPlugin = ({ services }) => {
|
|
@@ -33,6 +33,8 @@ export const jobsModule: PodokitModule = {
|
|
|
33
33
|
if (PROVIDERS.jobs !== "bullmq") return;
|
|
34
34
|
const queue = new BullMqJobQueue();
|
|
35
35
|
services.register(JOBS, queue, () => queue.close());
|
|
36
|
+
services.resolve(READINESS).register("jobs", () => queue.ready());
|
|
37
|
+
services.supervise("jobs", queue);
|
|
36
38
|
},
|
|
37
39
|
plugin: jobsPlugin,
|
|
38
40
|
};
|
|
@@ -1,7 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ConnectionOptions } from "bullmq";
|
|
2
|
+
import { redisCommandTimeoutMs, redisConnectionOptions } from "../config/redis.connection";
|
|
2
3
|
|
|
3
4
|
export const DEMO_QUEUE = "demo";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/** Wait before reconnect attempt `attempt`: 250 ms, doubling up to 5 seconds. */
|
|
7
|
+
export function reconnectDelay(attempt: number): number {
|
|
8
|
+
return Math.min(250 * 2 ** Math.max(0, attempt - 1), 5_000);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Workers block on Redis, so BullMQ requires their commands to wait for a reply indefinitely. */
|
|
12
|
+
export function redisConnection(env: NodeJS.ProcessEnv = process.env): ConnectionOptions {
|
|
13
|
+
return {
|
|
14
|
+
...redisConnectionOptions(env, { maxRetriesPerRequest: null }),
|
|
15
|
+
retryStrategy: reconnectDelay,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The API's connection for adding and reading jobs. Commands fail at once while
|
|
21
|
+
* it is disconnected and wait at most `REDIS_COMMAND_TIMEOUT_MS` for a reply, so
|
|
22
|
+
* a request that enqueues a job cannot hang on Redis. It does not reconnect by
|
|
23
|
+
* itself: the service registry's supervisor reopens it.
|
|
24
|
+
*/
|
|
25
|
+
export function producerConnection(env: NodeJS.ProcessEnv = process.env): ConnectionOptions {
|
|
26
|
+
return {
|
|
27
|
+
...redisConnectionOptions(env, {
|
|
28
|
+
lazyConnect: true,
|
|
29
|
+
enableOfflineQueue: false,
|
|
30
|
+
connectTimeout: 5_000,
|
|
31
|
+
commandTimeout: redisCommandTimeoutMs(env),
|
|
32
|
+
}),
|
|
33
|
+
retryStrategy: () => null,
|
|
34
|
+
};
|
|
7
35
|
}
|
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
"apps/api/src/config/providers.ts",
|
|
9
9
|
"apps/api/src/config/redis.connection.ts"
|
|
10
10
|
],
|
|
11
|
+
"requiredProjectContents": {
|
|
12
|
+
"apps/api/src/core/services.ts": ["supervise(name: string, connection: SupervisedConnection): void {"],
|
|
13
|
+
"apps/api/src/config/redis.connection.ts": ["export function redisCommandTimeoutMs("]
|
|
14
|
+
},
|
|
11
15
|
"managedGlobs": [
|
|
12
16
|
"apps/api/src/jobs/**",
|
|
13
17
|
"apps/api/src/main-worker.ts"
|
|
14
18
|
],
|
|
15
19
|
"targetApp": "api",
|
|
16
20
|
"dependencies": {
|
|
17
|
-
"bullmq": "^5.
|
|
21
|
+
"bullmq": "^5.57.0"
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
20
24
|
"start:worker": "bun dist/main-worker.js",
|
|
@@ -8,9 +8,7 @@ export const eventsRedisModule: PodokitModule = {
|
|
|
8
8
|
configure: (_env, services): void => {
|
|
9
9
|
if (PROVIDERS.events !== "redis") return;
|
|
10
10
|
const events = new RedisEventBus();
|
|
11
|
-
services.register(EVENTS, events,
|
|
12
|
-
|
|
13
|
-
});
|
|
14
|
-
services.onStart(() => events.ready());
|
|
11
|
+
services.register(EVENTS, events, () => events.close());
|
|
12
|
+
services.supervise("events", events);
|
|
15
13
|
},
|
|
16
14
|
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { RedisClient } from "bun";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { RedisCommandTimeoutError } from "../config/redis.connection";
|
|
4
|
+
import { RedisEventBus } from "./redis-event.bus";
|
|
5
|
+
|
|
6
|
+
type Listener = (message: string, channel: string) => void;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One Redis server shared by fake clients: it can go down, and it drops every
|
|
10
|
+
* connection when it does. While paused it keeps connections open and answers nothing.
|
|
11
|
+
*/
|
|
12
|
+
class FakeRedis {
|
|
13
|
+
up = true;
|
|
14
|
+
paused = false;
|
|
15
|
+
readonly clients: FakeClient[] = [];
|
|
16
|
+
|
|
17
|
+
client(): RedisClient {
|
|
18
|
+
const client = new FakeClient(this);
|
|
19
|
+
this.clients.push(client);
|
|
20
|
+
return client as unknown as RedisClient;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
stop(): void {
|
|
24
|
+
this.up = false;
|
|
25
|
+
for (const client of this.clients) client.drop();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
deliver(channel: string, message: string): number {
|
|
29
|
+
let receivers = 0;
|
|
30
|
+
for (const client of this.clients) {
|
|
31
|
+
if (!client.connected || !client.channels.has(channel)) continue;
|
|
32
|
+
receivers += 1;
|
|
33
|
+
client.listener?.(message, channel);
|
|
34
|
+
}
|
|
35
|
+
return receivers;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class FakeClient {
|
|
40
|
+
connected = false;
|
|
41
|
+
onclose: ((error: Error) => void) | null = null;
|
|
42
|
+
readonly channels = new Set<string>();
|
|
43
|
+
listener?: Listener;
|
|
44
|
+
private readonly pending = new Set<(error: Error) => void>();
|
|
45
|
+
|
|
46
|
+
constructor(private readonly server: FakeRedis) {}
|
|
47
|
+
|
|
48
|
+
/** Never answers while the server is paused; closing the connection fails the command. */
|
|
49
|
+
private answer<T>(value: () => T): Promise<T> {
|
|
50
|
+
if (!this.server.paused) return Promise.resolve(value());
|
|
51
|
+
return new Promise<T>((_resolve, reject) => {
|
|
52
|
+
this.pending.add(reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
connect(): Promise<void> {
|
|
57
|
+
if (!this.server.up) {
|
|
58
|
+
this.onclose?.(new Error("Connection closed"));
|
|
59
|
+
return Promise.reject(new Error("Connection closed"));
|
|
60
|
+
}
|
|
61
|
+
this.connected = true;
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ping(): Promise<string> {
|
|
66
|
+
return this.connected ? this.answer(() => "PONG") : Promise.reject(new Error("Connection closed"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
publish(channel: string, message: string): Promise<number> {
|
|
70
|
+
if (!this.connected) {
|
|
71
|
+
return Promise.reject(new Error("Connection is closed and offline queue is disabled"));
|
|
72
|
+
}
|
|
73
|
+
return this.answer(() => this.server.deliver(channel, message));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
subscribe(channel: string, listener: Listener): Promise<number> {
|
|
77
|
+
if (!this.connected) return Promise.reject(new Error("Connection closed"));
|
|
78
|
+
return this.answer(() => {
|
|
79
|
+
this.channels.add(channel);
|
|
80
|
+
this.listener = listener;
|
|
81
|
+
return this.channels.size;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
drop(): void {
|
|
86
|
+
if (!this.connected) return;
|
|
87
|
+
// Bun forgets the server subscription but keeps its listener.
|
|
88
|
+
this.connected = false;
|
|
89
|
+
this.channels.clear();
|
|
90
|
+
this.onclose?.(new Error("Connection closed"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close(): void {
|
|
94
|
+
if (!this.connected) return;
|
|
95
|
+
this.connected = false;
|
|
96
|
+
for (const reject of this.pending) reject(new Error("Connection closed"));
|
|
97
|
+
this.pending.clear();
|
|
98
|
+
this.onclose?.(new Error("Connection closed"));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bus(server: FakeRedis): RedisEventBus {
|
|
103
|
+
return new RedisEventBus(
|
|
104
|
+
{ EVENTS_REDIS_CHANNEL: "test:events", REDIS_COMMAND_TIMEOUT_MS: "50" },
|
|
105
|
+
() => server.client(),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("RedisEventBus reconnects", () => {
|
|
110
|
+
it("keeps handlers registered without Redis and delivers once the channel is subscribed", async () => {
|
|
111
|
+
const server = new FakeRedis();
|
|
112
|
+
server.up = false;
|
|
113
|
+
const events = bus(server);
|
|
114
|
+
let interruptions = 0;
|
|
115
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
116
|
+
const received: unknown[] = [];
|
|
117
|
+
await events.subscribe("sse", (event) => { received.push(event); });
|
|
118
|
+
|
|
119
|
+
await expect(events.connect()).rejects.toThrow("Connection closed");
|
|
120
|
+
await expect(events.ready()).rejects.toThrow("not subscribed");
|
|
121
|
+
server.up = true;
|
|
122
|
+
await events.connect();
|
|
123
|
+
await events.ready();
|
|
124
|
+
|
|
125
|
+
await events.publish("sse", { id: 1 });
|
|
126
|
+
expect(received).toEqual([{ id: 1 }]);
|
|
127
|
+
// Streams opened before the first subscription may have missed events.
|
|
128
|
+
expect(interruptions).toBe(1);
|
|
129
|
+
events.close();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("subscribes again after Redis drops the connections and reports one interruption", async () => {
|
|
133
|
+
const server = new FakeRedis();
|
|
134
|
+
const events = bus(server);
|
|
135
|
+
const lost: unknown[] = [];
|
|
136
|
+
events.onLost((reason) => { lost.push(reason); });
|
|
137
|
+
let interruptions = 0;
|
|
138
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
139
|
+
const received: unknown[] = [];
|
|
140
|
+
await events.subscribe("config.changed", (event) => { received.push(event); });
|
|
141
|
+
await events.connect();
|
|
142
|
+
expect(interruptions).toBe(0);
|
|
143
|
+
|
|
144
|
+
server.stop();
|
|
145
|
+
expect(lost.length).toBeGreaterThan(0);
|
|
146
|
+
await expect(events.ready()).rejects.toThrow("not subscribed");
|
|
147
|
+
await expect(events.publish("config.changed", { origin: "a" })).rejects.toThrow("offline queue");
|
|
148
|
+
server.up = true;
|
|
149
|
+
await events.connect();
|
|
150
|
+
|
|
151
|
+
expect(interruptions).toBe(1);
|
|
152
|
+
await events.publish("config.changed", { origin: "b" });
|
|
153
|
+
expect(received).toEqual([{ origin: "b" }]);
|
|
154
|
+
events.close();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("reports no interruption when only the publisher reconnects", async () => {
|
|
158
|
+
const server = new FakeRedis();
|
|
159
|
+
const events = bus(server);
|
|
160
|
+
let interruptions = 0;
|
|
161
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
162
|
+
await events.connect();
|
|
163
|
+
|
|
164
|
+
server.clients[0]?.drop();
|
|
165
|
+
await events.connect();
|
|
166
|
+
expect(interruptions).toBe(0);
|
|
167
|
+
await events.ready();
|
|
168
|
+
events.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not report connections it closes itself", async () => {
|
|
172
|
+
const server = new FakeRedis();
|
|
173
|
+
const events = bus(server);
|
|
174
|
+
const lost: unknown[] = [];
|
|
175
|
+
events.onLost((reason) => { lost.push(reason); });
|
|
176
|
+
await events.connect();
|
|
177
|
+
|
|
178
|
+
events.close();
|
|
179
|
+
expect(lost).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("fails a publish Redis does not answer and hands the publisher to the supervisor", async () => {
|
|
183
|
+
const server = new FakeRedis();
|
|
184
|
+
const events = bus(server);
|
|
185
|
+
const lost: unknown[] = [];
|
|
186
|
+
events.onLost((reason) => { lost.push(reason); });
|
|
187
|
+
let interruptions = 0;
|
|
188
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
189
|
+
const received: unknown[] = [];
|
|
190
|
+
await events.subscribe("sse", (event) => { received.push(event); });
|
|
191
|
+
await events.connect();
|
|
192
|
+
|
|
193
|
+
server.paused = true;
|
|
194
|
+
const started = Date.now();
|
|
195
|
+
await expect(events.publish("sse", { id: 1 })).rejects.toBeInstanceOf(RedisCommandTimeoutError);
|
|
196
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
197
|
+
expect(lost.length).toBe(1);
|
|
198
|
+
await expect(events.ready()).rejects.toThrow("Connection closed");
|
|
199
|
+
|
|
200
|
+
server.paused = false;
|
|
201
|
+
await events.connect();
|
|
202
|
+
await events.publish("sse", { id: 2 });
|
|
203
|
+
expect(received).toEqual([{ id: 2 }]);
|
|
204
|
+
expect(interruptions).toBe(0);
|
|
205
|
+
events.close();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("fails a subscription Redis does not confirm and subscribes on the next attempt", async () => {
|
|
209
|
+
const server = new FakeRedis();
|
|
210
|
+
server.paused = true;
|
|
211
|
+
const events = bus(server);
|
|
212
|
+
let interruptions = 0;
|
|
213
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
214
|
+
|
|
215
|
+
await expect(events.connect()).rejects.toBeInstanceOf(RedisCommandTimeoutError);
|
|
216
|
+
await expect(events.ready()).rejects.toThrow("not subscribed");
|
|
217
|
+
|
|
218
|
+
server.paused = false;
|
|
219
|
+
await events.connect();
|
|
220
|
+
await events.ready();
|
|
221
|
+
expect(interruptions).toBe(1);
|
|
222
|
+
events.close();
|
|
223
|
+
});
|
|
224
|
+
});
|