bunsane 0.2.9 → 0.3.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/CHANGELOG.md +266 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +390 -66
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +407 -256
- package/core/EntityHookManager.ts +88 -21
- package/core/EntityManager.ts +12 -3
- package/core/Logger.ts +4 -0
- package/core/RequestContext.ts +4 -1
- package/core/SchedulerManager.ts +92 -9
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +54 -17
- package/core/cache/RedisCache.ts +38 -3
- package/core/decorators/EntityHooks.ts +24 -12
- package/core/middleware/RateLimit.ts +105 -0
- package/core/middleware/index.ts +1 -0
- package/core/remote/CircuitBreaker.ts +115 -0
- package/core/remote/OutboxWorker.ts +183 -0
- package/core/remote/RemoteManager.ts +400 -0
- package/core/remote/RpcCaller.ts +310 -0
- package/core/remote/StreamConsumer.ts +535 -0
- package/core/remote/decorators.ts +121 -0
- package/core/remote/health.ts +139 -0
- package/core/remote/index.ts +37 -0
- package/core/remote/metrics.ts +99 -0
- package/core/remote/outboxSchema.ts +41 -0
- package/core/remote/types.ts +151 -0
- package/core/scheduler/DistributedLock.ts +324 -266
- package/gql/builders/ResolverBuilder.ts +4 -4
- package/gql/complexityLimit.ts +95 -0
- package/gql/index.ts +15 -3
- package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +13 -6
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +30 -3
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +21 -8
- package/storage/LocalStorageProvider.ts +12 -3
- package/storage/S3StorageProvider.ts +6 -6
- package/tests/e2e/http.test.ts +6 -2
- package/tests/helpers/MockRedisClient.ts +113 -0
- package/tests/helpers/MockRedisStreamServer.ts +448 -0
- package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
- package/tests/integration/remote/dlq.test.ts +175 -0
- package/tests/integration/remote/event-dispatch.test.ts +114 -0
- package/tests/integration/remote/outbox.test.ts +130 -0
- package/tests/integration/remote/rpc.test.ts +177 -0
- package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
- package/tests/unit/remote/RemoteError.test.ts +55 -0
- package/tests/unit/remote/decorators.test.ts +195 -0
- package/tests/unit/remote/metrics.test.ts +115 -0
- package/tests/unit/remote/mockRedisStreamServer.test.ts +104 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
- package/upload/FileValidator.ts +9 -6
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { RemoteManager } from "../../../core/remote/RemoteManager";
|
|
3
|
+
import { MockRedisStreamServer } from "../../helpers/MockRedisStreamServer";
|
|
4
|
+
import { createMockRedisFactory } from "../../helpers/MockRedisClient";
|
|
5
|
+
|
|
6
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
|
|
8
|
+
describe("Dead Letter Queue", () => {
|
|
9
|
+
let server: MockRedisStreamServer;
|
|
10
|
+
let producer: RemoteManager;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
server = new MockRedisStreamServer();
|
|
14
|
+
producer = new RemoteManager({
|
|
15
|
+
appName: "prod",
|
|
16
|
+
redisFactory: createMockRedisFactory(server),
|
|
17
|
+
blockMs: 30,
|
|
18
|
+
autoClaimIdleMs: 0,
|
|
19
|
+
shutdownDrainMs: 100,
|
|
20
|
+
});
|
|
21
|
+
await producer.start();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await producer.shutdown();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("poison message routed to DLQ after second delivery", async () => {
|
|
29
|
+
// Consumer 1: handler fails → message stays in PEL with deliveryCount=1
|
|
30
|
+
const c1 = new RemoteManager({
|
|
31
|
+
appName: "cons",
|
|
32
|
+
redisFactory: createMockRedisFactory(server),
|
|
33
|
+
blockMs: 30,
|
|
34
|
+
autoClaimIdleMs: 0,
|
|
35
|
+
dlqMaxDeliveries: 2,
|
|
36
|
+
shutdownDrainMs: 100,
|
|
37
|
+
});
|
|
38
|
+
await c1.start();
|
|
39
|
+
c1.on(
|
|
40
|
+
"poison",
|
|
41
|
+
async () => {
|
|
42
|
+
throw new Error("always fails");
|
|
43
|
+
},
|
|
44
|
+
"h1"
|
|
45
|
+
);
|
|
46
|
+
await producer.emit("cons", "poison", { bad: true });
|
|
47
|
+
await wait(200);
|
|
48
|
+
expect(server.getPelSize("remote:cons", "cons")).toBe(1);
|
|
49
|
+
await c1.shutdown();
|
|
50
|
+
|
|
51
|
+
// Consumer 2: autoClaimIdleMs > 0 triggers XAUTOCLAIM on startup →
|
|
52
|
+
// claims the orphan, deliveryCount becomes 2 → DLQ check fires.
|
|
53
|
+
const c2 = new RemoteManager({
|
|
54
|
+
appName: "cons",
|
|
55
|
+
redisFactory: createMockRedisFactory(server),
|
|
56
|
+
blockMs: 30,
|
|
57
|
+
autoClaimIdleMs: 1,
|
|
58
|
+
dlqMaxDeliveries: 2,
|
|
59
|
+
shutdownDrainMs: 100,
|
|
60
|
+
enableLogging: true,
|
|
61
|
+
});
|
|
62
|
+
await c2.start();
|
|
63
|
+
c2.on(
|
|
64
|
+
"poison",
|
|
65
|
+
async () => {
|
|
66
|
+
throw new Error("still fails");
|
|
67
|
+
},
|
|
68
|
+
"h1"
|
|
69
|
+
);
|
|
70
|
+
await wait(300);
|
|
71
|
+
|
|
72
|
+
expect(server.getStreamLength("remote:cons:dlq")).toBe(1);
|
|
73
|
+
const snap = c2.getMetrics();
|
|
74
|
+
expect(snap.events.dlq).toBe(1);
|
|
75
|
+
|
|
76
|
+
await c2.shutdown();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("dlqMaxDeliveries=0 disables DLQ routing", async () => {
|
|
80
|
+
const c1 = new RemoteManager({
|
|
81
|
+
appName: "cons2",
|
|
82
|
+
redisFactory: createMockRedisFactory(server),
|
|
83
|
+
blockMs: 30,
|
|
84
|
+
autoClaimIdleMs: 0,
|
|
85
|
+
dlqMaxDeliveries: 0,
|
|
86
|
+
shutdownDrainMs: 100,
|
|
87
|
+
});
|
|
88
|
+
await c1.start();
|
|
89
|
+
c1.on(
|
|
90
|
+
"fail",
|
|
91
|
+
async () => {
|
|
92
|
+
throw new Error("x");
|
|
93
|
+
},
|
|
94
|
+
"h1"
|
|
95
|
+
);
|
|
96
|
+
await producer.emit("cons2", "fail", {});
|
|
97
|
+
await wait(200);
|
|
98
|
+
await c1.shutdown();
|
|
99
|
+
|
|
100
|
+
const c2 = new RemoteManager({
|
|
101
|
+
appName: "cons2",
|
|
102
|
+
redisFactory: createMockRedisFactory(server),
|
|
103
|
+
blockMs: 30,
|
|
104
|
+
autoClaimIdleMs: 1,
|
|
105
|
+
dlqMaxDeliveries: 0,
|
|
106
|
+
shutdownDrainMs: 100,
|
|
107
|
+
});
|
|
108
|
+
await c2.start();
|
|
109
|
+
c2.on(
|
|
110
|
+
"fail",
|
|
111
|
+
async () => {
|
|
112
|
+
throw new Error("x");
|
|
113
|
+
},
|
|
114
|
+
"h1"
|
|
115
|
+
);
|
|
116
|
+
await wait(300);
|
|
117
|
+
|
|
118
|
+
expect(server.getStreamLength("remote:cons2:dlq")).toBe(0);
|
|
119
|
+
|
|
120
|
+
await c2.shutdown();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("DLQ entry carries original_id + delivery_count metadata", async () => {
|
|
124
|
+
const c1 = new RemoteManager({
|
|
125
|
+
appName: "cons3",
|
|
126
|
+
redisFactory: createMockRedisFactory(server),
|
|
127
|
+
blockMs: 30,
|
|
128
|
+
autoClaimIdleMs: 0,
|
|
129
|
+
dlqMaxDeliveries: 2,
|
|
130
|
+
shutdownDrainMs: 100,
|
|
131
|
+
});
|
|
132
|
+
await c1.start();
|
|
133
|
+
c1.on(
|
|
134
|
+
"p",
|
|
135
|
+
async () => {
|
|
136
|
+
throw new Error("x");
|
|
137
|
+
},
|
|
138
|
+
"h1"
|
|
139
|
+
);
|
|
140
|
+
await producer.emit("cons3", "p", {});
|
|
141
|
+
await wait(200);
|
|
142
|
+
await c1.shutdown();
|
|
143
|
+
|
|
144
|
+
const c2 = new RemoteManager({
|
|
145
|
+
appName: "cons3",
|
|
146
|
+
redisFactory: createMockRedisFactory(server),
|
|
147
|
+
blockMs: 30,
|
|
148
|
+
autoClaimIdleMs: 1,
|
|
149
|
+
dlqMaxDeliveries: 2,
|
|
150
|
+
shutdownDrainMs: 100,
|
|
151
|
+
});
|
|
152
|
+
await c2.start();
|
|
153
|
+
c2.on(
|
|
154
|
+
"p",
|
|
155
|
+
async () => {
|
|
156
|
+
throw new Error("x");
|
|
157
|
+
},
|
|
158
|
+
"h1"
|
|
159
|
+
);
|
|
160
|
+
await wait(300);
|
|
161
|
+
|
|
162
|
+
const dlqEntries = server.xrange("remote:cons3:dlq", "-", "+");
|
|
163
|
+
expect(dlqEntries.length).toBe(1);
|
|
164
|
+
const [, fields] = dlqEntries[0]!;
|
|
165
|
+
// fields = [k1, v1, k2, v2, ...]
|
|
166
|
+
const flat = fields as string[];
|
|
167
|
+
const idx = (k: string) => flat.indexOf(k);
|
|
168
|
+
expect(idx("original_id")).toBeGreaterThanOrEqual(0);
|
|
169
|
+
expect(idx("delivery_count")).toBeGreaterThanOrEqual(0);
|
|
170
|
+
expect(idx("moved_at")).toBeGreaterThanOrEqual(0);
|
|
171
|
+
expect(idx("data")).toBeGreaterThanOrEqual(0);
|
|
172
|
+
|
|
173
|
+
await c2.shutdown();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { RemoteManager } from "../../../core/remote/RemoteManager";
|
|
3
|
+
import { MockRedisStreamServer } from "../../helpers/MockRedisStreamServer";
|
|
4
|
+
import { createMockRedisFactory } from "../../helpers/MockRedisClient";
|
|
5
|
+
|
|
6
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
|
|
8
|
+
describe("Event round-trip over mock Redis", () => {
|
|
9
|
+
let server: MockRedisStreamServer;
|
|
10
|
+
let appA: RemoteManager;
|
|
11
|
+
let appB: RemoteManager;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
server = new MockRedisStreamServer();
|
|
15
|
+
appA = new RemoteManager({
|
|
16
|
+
appName: "app-a",
|
|
17
|
+
redisFactory: createMockRedisFactory(server),
|
|
18
|
+
blockMs: 50,
|
|
19
|
+
autoClaimIdleMs: 0, // skip orphan reclaim
|
|
20
|
+
dlqMaxDeliveries: 0, // no DLQ for basic tests
|
|
21
|
+
});
|
|
22
|
+
appB = new RemoteManager({
|
|
23
|
+
appName: "app-b",
|
|
24
|
+
redisFactory: createMockRedisFactory(server),
|
|
25
|
+
blockMs: 50,
|
|
26
|
+
autoClaimIdleMs: 0,
|
|
27
|
+
dlqMaxDeliveries: 0,
|
|
28
|
+
});
|
|
29
|
+
await appA.start();
|
|
30
|
+
await appB.start();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await appA.shutdown();
|
|
35
|
+
await appB.shutdown();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("emit from A is received by B's handler", async () => {
|
|
39
|
+
const received: any[] = [];
|
|
40
|
+
appB.on(
|
|
41
|
+
"order.created",
|
|
42
|
+
async (data, ctx) => {
|
|
43
|
+
received.push({ data, sourceApp: ctx.sourceApp });
|
|
44
|
+
},
|
|
45
|
+
"h1"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await appA.emit("app-b", "order.created", { orderId: "abc" });
|
|
49
|
+
await wait(150);
|
|
50
|
+
|
|
51
|
+
expect(received).toHaveLength(1);
|
|
52
|
+
expect(received[0].data).toEqual({ orderId: "abc" });
|
|
53
|
+
expect(received[0].sourceApp).toBe("app-a");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("handler receives ctx.attempt=1 on first delivery", async () => {
|
|
57
|
+
const attempts: number[] = [];
|
|
58
|
+
appB.on(
|
|
59
|
+
"x",
|
|
60
|
+
async (_data, ctx) => {
|
|
61
|
+
attempts.push(ctx.attempt);
|
|
62
|
+
},
|
|
63
|
+
"h1"
|
|
64
|
+
);
|
|
65
|
+
await appA.emit("app-b", "x", {});
|
|
66
|
+
await wait(150);
|
|
67
|
+
expect(attempts).toEqual([1]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("no-handler event is ACKed silently", async () => {
|
|
71
|
+
await appA.emit("app-b", "unhandled.event", {});
|
|
72
|
+
await wait(150);
|
|
73
|
+
// PEL should be empty after ACK
|
|
74
|
+
expect(server.getPelSize("remote:app-b", "app-b")).toBe(0);
|
|
75
|
+
const snap = appB.getMetrics();
|
|
76
|
+
expect(snap.events.noHandler).toBeGreaterThan(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("multiple handlers all fire for one event", async () => {
|
|
80
|
+
const log: string[] = [];
|
|
81
|
+
appB.on("e", async () => { log.push("h1"); }, "h1");
|
|
82
|
+
appB.on("e", async () => { log.push("h2"); }, "h2");
|
|
83
|
+
await appA.emit("app-b", "e", {});
|
|
84
|
+
await wait(150);
|
|
85
|
+
expect(log.sort()).toEqual(["h1", "h2"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("handler failure leaves message in PEL (no ACK)", async () => {
|
|
89
|
+
appB.on(
|
|
90
|
+
"fail",
|
|
91
|
+
async () => {
|
|
92
|
+
throw new Error("handler boom");
|
|
93
|
+
},
|
|
94
|
+
"h1"
|
|
95
|
+
);
|
|
96
|
+
await appA.emit("app-b", "fail", {});
|
|
97
|
+
await wait(150);
|
|
98
|
+
expect(server.getPelSize("remote:app-b", "app-b")).toBe(1);
|
|
99
|
+
const snap = appB.getMetrics();
|
|
100
|
+
expect(snap.events.handlerFailed).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("metrics reflect emit + receive counters", async () => {
|
|
104
|
+
appB.on("m", async () => {}, "h1");
|
|
105
|
+
await appA.emit("app-b", "m", {});
|
|
106
|
+
await appA.emit("app-b", "m", {});
|
|
107
|
+
await wait(200);
|
|
108
|
+
|
|
109
|
+
const aSnap = appA.getMetrics();
|
|
110
|
+
const bSnap = appB.getMetrics();
|
|
111
|
+
expect(aSnap.emit.direct).toBe(2);
|
|
112
|
+
expect(bSnap.events.handled).toBe(2);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { RemoteManager } from "../../../core/remote/RemoteManager";
|
|
3
|
+
import { MockRedisStreamServer } from "../../helpers/MockRedisStreamServer";
|
|
4
|
+
import { createMockRedisFactory } from "../../helpers/MockRedisClient";
|
|
5
|
+
import db from "../../../database";
|
|
6
|
+
|
|
7
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
|
|
9
|
+
describe("Transactional Outbox", () => {
|
|
10
|
+
let server: MockRedisStreamServer;
|
|
11
|
+
let app: RemoteManager;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
// Clear any residual outbox rows from earlier tests
|
|
15
|
+
try {
|
|
16
|
+
await db`DELETE FROM remote_outbox`;
|
|
17
|
+
} catch {
|
|
18
|
+
/* table may not exist yet */
|
|
19
|
+
}
|
|
20
|
+
server = new MockRedisStreamServer();
|
|
21
|
+
app = new RemoteManager({
|
|
22
|
+
appName: "app",
|
|
23
|
+
redisFactory: createMockRedisFactory(server),
|
|
24
|
+
blockMs: 30,
|
|
25
|
+
autoClaimIdleMs: 0,
|
|
26
|
+
dlqMaxDeliveries: 0,
|
|
27
|
+
shutdownDrainMs: 100,
|
|
28
|
+
enableOutbox: true,
|
|
29
|
+
outboxPollIntervalMs: 50,
|
|
30
|
+
outboxBatchSize: 10,
|
|
31
|
+
});
|
|
32
|
+
await app.start();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await app.shutdown();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("emit({ trx }) inserts outbox row within transaction", async () => {
|
|
40
|
+
await (db as any).begin(async (trx: any) => {
|
|
41
|
+
const id = await app.emit(
|
|
42
|
+
"downstream",
|
|
43
|
+
"order.created",
|
|
44
|
+
{ orderId: "abc" },
|
|
45
|
+
{ trx }
|
|
46
|
+
);
|
|
47
|
+
expect(id).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const rows = await db`
|
|
51
|
+
SELECT target, event, data, published_at
|
|
52
|
+
FROM remote_outbox
|
|
53
|
+
`;
|
|
54
|
+
expect(rows.length).toBe(1);
|
|
55
|
+
expect(rows[0]!.target).toBe("downstream");
|
|
56
|
+
expect(rows[0]!.event).toBe("order.created");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("worker publishes committed rows to Redis + marks published_at", async () => {
|
|
60
|
+
await (db as any).begin(async (trx: any) => {
|
|
61
|
+
await app.emit(
|
|
62
|
+
"downstream",
|
|
63
|
+
"published.event",
|
|
64
|
+
{ n: 1 },
|
|
65
|
+
{ trx }
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
await wait(200); // allow at least one poll tick
|
|
69
|
+
|
|
70
|
+
// Row should now be marked published
|
|
71
|
+
const rows = await db`
|
|
72
|
+
SELECT published_at FROM remote_outbox
|
|
73
|
+
WHERE event = 'published.event'
|
|
74
|
+
`;
|
|
75
|
+
expect(rows[0]!.published_at).not.toBeNull();
|
|
76
|
+
|
|
77
|
+
// Stream should have the message
|
|
78
|
+
expect(server.getStreamLength("remote:downstream")).toBeGreaterThanOrEqual(
|
|
79
|
+
1
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("rolled-back transaction suppresses publish", async () => {
|
|
84
|
+
try {
|
|
85
|
+
await (db as any).begin(async (trx: any) => {
|
|
86
|
+
await app.emit(
|
|
87
|
+
"downstream",
|
|
88
|
+
"rolled.back",
|
|
89
|
+
{ n: 2 },
|
|
90
|
+
{ trx }
|
|
91
|
+
);
|
|
92
|
+
throw new Error("force rollback");
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
/* expected */
|
|
96
|
+
}
|
|
97
|
+
await wait(200);
|
|
98
|
+
|
|
99
|
+
const rows = await db`
|
|
100
|
+
SELECT COUNT(*)::int AS c FROM remote_outbox
|
|
101
|
+
WHERE event = 'rolled.back'
|
|
102
|
+
`;
|
|
103
|
+
expect(rows[0]!.c).toBe(0);
|
|
104
|
+
expect(server.getStreamLength("remote:downstream")).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("direct emit + outbox emit hit same target stream", async () => {
|
|
108
|
+
await app.emit("other", "direct", { n: "a" });
|
|
109
|
+
await (db as any).begin(async (trx: any) => {
|
|
110
|
+
await app.emit("other", "outbox", { n: "b" }, { trx });
|
|
111
|
+
});
|
|
112
|
+
await wait(200);
|
|
113
|
+
|
|
114
|
+
const entries = server.xrange("remote:other", "-", "+");
|
|
115
|
+
expect(entries.length).toBe(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("metrics count emit.direct vs emit.outbox separately", async () => {
|
|
119
|
+
await app.emit("dst", "direct", {});
|
|
120
|
+
await (db as any).begin(async (trx: any) => {
|
|
121
|
+
await app.emit("dst", "outbox", {}, { trx });
|
|
122
|
+
});
|
|
123
|
+
await wait(150);
|
|
124
|
+
|
|
125
|
+
const snap = app.getMetrics();
|
|
126
|
+
expect(snap.emit.direct).toBe(1);
|
|
127
|
+
expect(snap.emit.outbox).toBe(1);
|
|
128
|
+
expect(snap.outbox.published).toBeGreaterThanOrEqual(1);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { RemoteManager } from "../../../core/remote/RemoteManager";
|
|
3
|
+
import { RemoteError } from "../../../core/remote/types";
|
|
4
|
+
import { MockRedisStreamServer } from "../../helpers/MockRedisStreamServer";
|
|
5
|
+
import { createMockRedisFactory } from "../../helpers/MockRedisClient";
|
|
6
|
+
|
|
7
|
+
describe("RPC round-trip", () => {
|
|
8
|
+
let server: MockRedisStreamServer;
|
|
9
|
+
let client: RemoteManager;
|
|
10
|
+
let server_app: RemoteManager;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
server = new MockRedisStreamServer();
|
|
14
|
+
client = new RemoteManager({
|
|
15
|
+
appName: "client-app",
|
|
16
|
+
redisFactory: createMockRedisFactory(server),
|
|
17
|
+
blockMs: 50,
|
|
18
|
+
autoClaimIdleMs: 0,
|
|
19
|
+
dlqMaxDeliveries: 0,
|
|
20
|
+
shutdownDrainMs: 100,
|
|
21
|
+
defaultCallTimeout: 1000,
|
|
22
|
+
});
|
|
23
|
+
server_app = new RemoteManager({
|
|
24
|
+
appName: "server-app",
|
|
25
|
+
redisFactory: createMockRedisFactory(server),
|
|
26
|
+
blockMs: 50,
|
|
27
|
+
autoClaimIdleMs: 0,
|
|
28
|
+
dlqMaxDeliveries: 0,
|
|
29
|
+
shutdownDrainMs: 100,
|
|
30
|
+
});
|
|
31
|
+
await client.start();
|
|
32
|
+
await server_app.start();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await client.shutdown();
|
|
37
|
+
await server_app.shutdown();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("call() returns handler result", async () => {
|
|
41
|
+
server_app.onRpc(
|
|
42
|
+
"order.get",
|
|
43
|
+
async (data: any) => ({ id: data.id, status: "ok" }),
|
|
44
|
+
"h1"
|
|
45
|
+
);
|
|
46
|
+
const result = await client.call<{ id: string; status: string }>(
|
|
47
|
+
"server-app",
|
|
48
|
+
"order.get",
|
|
49
|
+
{ id: "abc" }
|
|
50
|
+
);
|
|
51
|
+
expect(result).toEqual({ id: "abc", status: "ok" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("call() rejects with TIMEOUT when no handler registered", async () => {
|
|
55
|
+
// server_app has no handler — still returns NOT_FOUND, not TIMEOUT
|
|
56
|
+
try {
|
|
57
|
+
await client.call(
|
|
58
|
+
"server-app",
|
|
59
|
+
"nonexistent.method",
|
|
60
|
+
{},
|
|
61
|
+
{ timeout: 500 }
|
|
62
|
+
);
|
|
63
|
+
throw new Error("expected throw");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
expect(err).toBeInstanceOf(RemoteError);
|
|
66
|
+
expect((err as RemoteError).code).toBe("NOT_FOUND");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("call() rejects with TIMEOUT when target app down", async () => {
|
|
71
|
+
// Kill server_app — no consumer for the request stream
|
|
72
|
+
await server_app.shutdown();
|
|
73
|
+
try {
|
|
74
|
+
await client.call(
|
|
75
|
+
"server-app",
|
|
76
|
+
"anything",
|
|
77
|
+
{},
|
|
78
|
+
{ timeout: 200 }
|
|
79
|
+
);
|
|
80
|
+
throw new Error("expected throw");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
expect(err).toBeInstanceOf(RemoteError);
|
|
83
|
+
expect((err as RemoteError).code).toBe("TIMEOUT");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("call() to broadcast target * rejects INVALID_TARGET", async () => {
|
|
88
|
+
try {
|
|
89
|
+
await client.call("*", "anything", {});
|
|
90
|
+
throw new Error("expected throw");
|
|
91
|
+
} catch (err) {
|
|
92
|
+
expect(err).toBeInstanceOf(RemoteError);
|
|
93
|
+
expect((err as RemoteError).code).toBe("INVALID_TARGET");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("handler exception propagates as HANDLER_ERROR", async () => {
|
|
98
|
+
server_app.onRpc(
|
|
99
|
+
"fail",
|
|
100
|
+
async () => {
|
|
101
|
+
throw new Error("something bad");
|
|
102
|
+
},
|
|
103
|
+
"h1"
|
|
104
|
+
);
|
|
105
|
+
try {
|
|
106
|
+
await client.call("server-app", "fail", {});
|
|
107
|
+
throw new Error("expected throw");
|
|
108
|
+
} catch (err) {
|
|
109
|
+
expect(err).toBeInstanceOf(RemoteError);
|
|
110
|
+
expect((err as RemoteError).code).toBe("HANDLER_ERROR");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("handler custom RemoteError code flows through", async () => {
|
|
115
|
+
server_app.onRpc(
|
|
116
|
+
"forbidden",
|
|
117
|
+
async () => {
|
|
118
|
+
throw new RemoteError("no", {
|
|
119
|
+
code: "FORBIDDEN",
|
|
120
|
+
extensions: { reason: "test" },
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
"h1"
|
|
124
|
+
);
|
|
125
|
+
try {
|
|
126
|
+
await client.call("server-app", "forbidden", {});
|
|
127
|
+
throw new Error("expected throw");
|
|
128
|
+
} catch (err) {
|
|
129
|
+
expect(err).toBeInstanceOf(RemoteError);
|
|
130
|
+
const re = err as RemoteError;
|
|
131
|
+
expect(re.code).toBe("FORBIDDEN");
|
|
132
|
+
expect(re.extensions).toEqual({ reason: "test" });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("ctx carries correlationId + deadline", async () => {
|
|
137
|
+
let captured: any = null;
|
|
138
|
+
server_app.onRpc(
|
|
139
|
+
"ctx-check",
|
|
140
|
+
async (_data, ctx) => {
|
|
141
|
+
captured = {
|
|
142
|
+
correlationId: ctx.correlationId,
|
|
143
|
+
deadline: ctx.deadline,
|
|
144
|
+
sourceApp: ctx.sourceApp,
|
|
145
|
+
};
|
|
146
|
+
return null;
|
|
147
|
+
},
|
|
148
|
+
"h1"
|
|
149
|
+
);
|
|
150
|
+
await client.call("server-app", "ctx-check", {}, { timeout: 2000 });
|
|
151
|
+
expect(captured.correlationId).toMatch(/^[0-9a-f-]{36}$/);
|
|
152
|
+
expect(captured.deadline).toBeInstanceOf(Date);
|
|
153
|
+
expect(captured.sourceApp).toBe("client-app");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("metrics track successful + failed RPCs", async () => {
|
|
157
|
+
server_app.onRpc("ok", async () => "x", "h1");
|
|
158
|
+
server_app.onRpc(
|
|
159
|
+
"bad",
|
|
160
|
+
async () => {
|
|
161
|
+
throw new Error("x");
|
|
162
|
+
},
|
|
163
|
+
"h2"
|
|
164
|
+
);
|
|
165
|
+
await client.call("server-app", "ok", {});
|
|
166
|
+
await client.call("server-app", "bad", {}).catch(() => {});
|
|
167
|
+
|
|
168
|
+
const clientSnap = client.getMetrics();
|
|
169
|
+
expect(clientSnap.rpc.called).toBe(2);
|
|
170
|
+
expect(clientSnap.rpc.succeeded).toBe(1);
|
|
171
|
+
expect(clientSnap.rpc.failed).toBe(1);
|
|
172
|
+
|
|
173
|
+
const serverSnap = server_app.getMetrics();
|
|
174
|
+
expect(serverSnap.rpc.handlerExecuted).toBe(1);
|
|
175
|
+
expect(serverSnap.rpc.handlerFailed).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
});
|