@effect-app/infra 4.0.0-beta.258 → 4.0.0-beta.259

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.
@@ -0,0 +1,406 @@
1
+ import { assert, describe, expect, it } from "@effect/vitest"
2
+ import { Context, Effect, Exit, Fiber, Latch, Layer, Option, Redacted, Schema } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { ClusterSchema, Entity, EntityAddress, EntityId, EntityType, Envelope, Message, MessageStorage, Reply, Runner, RunnerAddress, RunnerHealth, Runners, RunnerStorage, ShardId, Sharding, ShardingConfig, Snowflake } from "effect/unstable/cluster"
5
+ import { Headers } from "effect/unstable/http"
6
+ import { Rpc, RpcSchema } from "effect/unstable/rpc"
7
+ import { layerCosmos } from "../src/ClusterCosmos.js"
8
+
9
+ const cosmosUrl = process.env["COSMOS_TEST_URL"]
10
+ const cosmosDb = process.env["COSMOS_TEST_DB"] ?? "cluster-test"
11
+ const testRunId = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`
12
+ const runnerPortBase = 10000 + Date.now() % 40000
13
+
14
+ const layerFor = () =>
15
+ layerCosmos({
16
+ url: Redacted.make(cosmosUrl ?? ""),
17
+ dbName: cosmosDb,
18
+ prefix: "test-cluster-"
19
+ })
20
+ .pipe(
21
+ Layer.provideMerge(Snowflake.layerGenerator),
22
+ Layer.provide(ShardingConfig.layerDefaults)
23
+ )
24
+
25
+ describe.skipIf(!cosmosUrl)("ClusterCosmos MessageStorage", () => {
26
+ it.effect("deduplicates keyed requests and returns the last reply", () =>
27
+ Effect
28
+ .gen(function*() {
29
+ const storage = yield* MessageStorage.MessageStorage
30
+ const shardId = testShardId("message-duplicate")
31
+ const primaryKey = `primary/${testRunId}\\with?illegal#chars`
32
+ const request = yield* makeStreamRequest(primaryKey, shardId)
33
+
34
+ const saved = yield* storage.saveRequest(request)
35
+ assert.strictEqual(saved._tag, "Success")
36
+
37
+ const chunk = yield* makeChunkReply(request, 0)
38
+ yield* storage.saveReply(chunk)
39
+
40
+ const duplicateWithChunk = yield* storage.saveRequest(
41
+ yield* makeStreamRequest(primaryKey, shardId)
42
+ )
43
+ assert(duplicateWithChunk._tag === "Duplicate" && Option.isSome(duplicateWithChunk.lastReceivedReply))
44
+ assert.strictEqual(duplicateWithChunk.lastReceivedReply.value._tag, "Chunk")
45
+
46
+ const ackChunk = yield* makeAckChunk(request, chunk)
47
+ yield* storage.saveEnvelope(ackChunk)
48
+ const repliesAfterAck = yield* storage.repliesFor([request])
49
+ assert.strictEqual(repliesAfterAck.length, 0)
50
+
51
+ yield* storage.saveReply(yield* makeStreamReply(request))
52
+ const duplicateWithExit = yield* storage.saveRequest(
53
+ yield* makeStreamRequest(primaryKey, shardId)
54
+ )
55
+ assert(duplicateWithExit._tag === "Duplicate" && Option.isSome(duplicateWithExit.lastReceivedReply))
56
+ assert.strictEqual(duplicateWithExit.lastReceivedReply.value._tag, "WithExit")
57
+ })
58
+ .pipe(Effect.provide(layerFor())))
59
+
60
+ it.effect("marks reads, resets shards, and excludes completed requests", () =>
61
+ Effect
62
+ .gen(function*() {
63
+ const storage = yield* MessageStorage.MessageStorage
64
+ const shardId = testShardId("message-unprocessed")
65
+ const request1 = yield* makeRequest({ payload: { id: 1 }, shardId })
66
+ const request2 = yield* makeRequest({ payload: { id: 2 }, shardId })
67
+ assert.strictEqual((yield* storage.saveRequest(request1))._tag, "Success")
68
+ assert.strictEqual((yield* storage.saveRequest(request2))._tag, "Success")
69
+
70
+ let messages = yield* storage.unprocessedMessages([request1.envelope.address.shardId])
71
+ assert.deepStrictEqual(messages.map((message) => requestPayloadId(message)).sort(), [1, 2])
72
+
73
+ messages = yield* storage.unprocessedMessages([request1.envelope.address.shardId])
74
+ assert.strictEqual(messages.length, 0)
75
+
76
+ yield* storage.resetShards([request1.envelope.address.shardId])
77
+ messages = yield* storage.unprocessedMessages([request1.envelope.address.shardId])
78
+ assert.deepStrictEqual(messages.map((message) => requestPayloadId(message)).sort(), [1, 2])
79
+
80
+ yield* storage.saveReply(yield* makeReply(request1))
81
+ yield* storage.resetShards([request1.envelope.address.shardId])
82
+ messages = yield* storage.unprocessedMessages([request1.envelope.address.shardId])
83
+ assert.deepStrictEqual(messages.map((message) => requestPayloadId(message)), [2])
84
+ })
85
+ .pipe(Effect.provide(layerFor())))
86
+
87
+ it.effect("notifies registered reply handlers", () =>
88
+ Effect
89
+ .gen(function*() {
90
+ const storage = yield* MessageStorage.MessageStorage
91
+ const latch = yield* Latch.make()
92
+ const request = yield* makeRequest({ shardId: testShardId("message-handler") })
93
+ yield* storage.saveRequest(request)
94
+
95
+ const fiber = yield* storage
96
+ .registerReplyHandler(
97
+ new Message.OutgoingRequest({
98
+ ...request,
99
+ respond: () => latch.open
100
+ })
101
+ )
102
+ .pipe(Effect.forkChild)
103
+
104
+ yield* TestClock.adjust(1)
105
+ yield* storage.saveReply(yield* makeReply(request))
106
+ yield* latch.await
107
+ yield* Fiber.await(fiber)
108
+ })
109
+ .pipe(Effect.provide(layerFor())))
110
+ })
111
+
112
+ describe.skipIf(!cosmosUrl)("ClusterCosmos RunnerStorage", () => {
113
+ it.effect("registers runners and tracks health", () =>
114
+ Effect
115
+ .gen(function*() {
116
+ const storage = yield* RunnerStorage.RunnerStorage
117
+ const runnerAddress = testRunnerAddress(1)
118
+ const runner = Runner.make({
119
+ address: runnerAddress,
120
+ groups: ["default"],
121
+ weight: 1
122
+ })
123
+
124
+ const machineId1 = yield* storage.register(runner, true)
125
+ const machineId2 = yield* storage.register(runner, true)
126
+ assert.deepStrictEqual(machineId2, machineId1)
127
+ expect(runnerStatus(yield* storage.getRunners, runnerAddress)).toEqual([runner, true])
128
+
129
+ yield* storage.setRunnerHealth(runnerAddress, false)
130
+ expect(runnerStatus(yield* storage.getRunners, runnerAddress)).toEqual([runner, false])
131
+
132
+ yield* storage.unregister(runnerAddress)
133
+ expect(runnerStatus(yield* storage.getRunners, runnerAddress)).toBeUndefined()
134
+ })
135
+ .pipe(Effect.provide(layerFor())))
136
+
137
+ it.effect("acquires, refreshes, releases, and re-acquires shard locks", () =>
138
+ Effect
139
+ .gen(function*() {
140
+ const storage = yield* RunnerStorage.RunnerStorage
141
+ const runnerAddress1 = testRunnerAddress(2)
142
+ const runnerAddress2 = testRunnerAddress(3)
143
+ const shards = [
144
+ testShardId("runner-locks", 1),
145
+ testShardId("runner-locks", 2),
146
+ testShardId("runner-locks", 3)
147
+ ]
148
+
149
+ let acquired = yield* storage.acquire(runnerAddress1, shards)
150
+ assert.deepStrictEqual(acquired.map((shard) => shard.id), [1, 2, 3])
151
+
152
+ acquired = yield* storage.acquire(runnerAddress2, shards)
153
+ assert.deepStrictEqual(acquired.map((shard) => shard.id), [])
154
+
155
+ const refreshed = yield* storage.refresh(runnerAddress1, shards)
156
+ assert.deepStrictEqual(refreshed.map((shard) => shard.id), [1, 2, 3])
157
+
158
+ yield* storage.release(runnerAddress1, testShardId("runner-locks", 2))
159
+ acquired = yield* storage.acquire(runnerAddress2, shards)
160
+ assert.deepStrictEqual(acquired.map((shard) => shard.id), [2])
161
+
162
+ yield* storage.releaseAll(runnerAddress1)
163
+ acquired = yield* storage.acquire(runnerAddress2, shards)
164
+ assert.deepStrictEqual(acquired.map((shard) => shard.id), [1, 2, 3])
165
+ })
166
+ .pipe(Effect.provide(layerFor())))
167
+ })
168
+
169
+ describe.skipIf(!cosmosUrl)("ClusterCosmos Sharding RPC", () => {
170
+ it.effect("runs persisted entity RPCs through Cosmos-backed cluster storage", () =>
171
+ Effect
172
+ .gen(function*() {
173
+ yield* TestClock.adjust(1)
174
+ const sharding = yield* Sharding.Sharding
175
+ const makeClient = yield* CosmosRpcEntity.client
176
+ const entityId = `entity/${testRunId}\\with?illegal#chars`
177
+ const shardId = sharding.getShardId(EntityId.make(entityId), testShardGroup("rpc"))
178
+ yield* waitForShard(sharding, shardId)
179
+ assert.isTrue(sharding.hasShardId(shardId))
180
+ const client = makeClient(entityId)
181
+
182
+ const user = yield* client.GetCosmosUser({ id: 42 })
183
+ expect(user).toEqual(new CosmosRpcUser({ id: 42, name: "User 42" }))
184
+
185
+ const primaryKey = `rpc/${testRunId}\\with?illegal#chars`
186
+ const first = yield* client.CosmosRequestWithKey({ key: primaryKey })
187
+ const duplicate = yield* client.CosmosRequestWithKey({ key: primaryKey })
188
+
189
+ assert.strictEqual(first, primaryKey)
190
+ assert.strictEqual(duplicate, primaryKey)
191
+ })
192
+ .pipe(Effect.provide(clusterRpcLayer("rpc"))), 20000)
193
+ })
194
+
195
+ const GetUserRpc = Rpc.make("GetUser", {
196
+ payload: { id: Schema.Number }
197
+ })
198
+
199
+ class CosmosRpcUser extends Schema.Class<CosmosRpcUser>("CosmosRpcUser")({
200
+ id: Schema.Number,
201
+ name: Schema.String
202
+ }) {}
203
+
204
+ const CosmosRpcEntity = Entity
205
+ .make("CosmosRpcEntity", [
206
+ Rpc.make("GetCosmosUser", {
207
+ success: CosmosRpcUser,
208
+ payload: { id: Schema.Number }
209
+ }),
210
+ Rpc.make("CosmosRequestWithKey", {
211
+ success: Schema.String,
212
+ payload: { key: Schema.String },
213
+ primaryKey: ({ key }) => key
214
+ })
215
+ ])
216
+ .annotate(ClusterSchema.ShardGroup, () => testShardGroup("rpc"))
217
+ .annotateRpcs(ClusterSchema.Persisted, true)
218
+
219
+ const CosmosRpcEntityLayer = CosmosRpcEntity.toLayer(
220
+ Effect.succeed(
221
+ CosmosRpcEntity.of({
222
+ GetCosmosUser: (envelope) =>
223
+ Effect.succeed(new CosmosRpcUser({ id: envelope.payload.id, name: `User ${envelope.payload.id}` })),
224
+ CosmosRequestWithKey: (envelope) => Effect.succeed(envelope.payload.key)
225
+ })
226
+ )
227
+ )
228
+
229
+ class StreamRpc extends Rpc.make("StreamTest", {
230
+ success: RpcSchema.Stream(Schema.Void, Schema.Never),
231
+ payload: {
232
+ id: Schema.String
233
+ },
234
+ primaryKey: (value) => value.id.toString()
235
+ }) {}
236
+
237
+ const makeRequest = Effect.fnUntraced(function*(options?: {
238
+ readonly payload?: { readonly id: number }
239
+ readonly shardId?: ShardId.ShardId
240
+ }) {
241
+ const snowflake = yield* Snowflake.Generator
242
+ return new Message.OutgoingRequest({
243
+ envelope: Envelope.makeRequest<typeof GetUserRpc>({
244
+ requestId: snowflake.nextUnsafe(),
245
+ address: EntityAddress.make({
246
+ shardId: options?.shardId ?? testShardId("default"),
247
+ entityType: EntityType.make("test"),
248
+ entityId: EntityId.make("1")
249
+ }),
250
+ tag: GetUserRpc._tag,
251
+ payload: options?.payload ?? { id: 123 },
252
+ traceId: "noop",
253
+ spanId: "noop",
254
+ sampled: false,
255
+ headers: Headers.empty
256
+ }),
257
+ annotations: GetUserRpc.annotations,
258
+ context: Context.empty(),
259
+ rpc: GetUserRpc,
260
+ lastReceivedReply: Option.none(),
261
+ respond() {
262
+ return Effect.void
263
+ }
264
+ })
265
+ })
266
+
267
+ const makeStreamRequest = Effect.fnUntraced(function*(id: string, shardId = testShardId("stream")) {
268
+ const snowflake = yield* Snowflake.Generator
269
+ return new Message.OutgoingRequest({
270
+ envelope: Envelope.makeRequest<typeof StreamRpc>({
271
+ requestId: snowflake.nextUnsafe(),
272
+ address: EntityAddress.make({
273
+ shardId,
274
+ entityType: EntityType.make("test"),
275
+ entityId: EntityId.make("1")
276
+ }),
277
+ tag: StreamRpc._tag,
278
+ payload: StreamRpc.payloadSchema.make({ id }),
279
+ traceId: "noop",
280
+ spanId: "noop",
281
+ sampled: false,
282
+ headers: Headers.empty
283
+ }),
284
+ annotations: StreamRpc.annotations,
285
+ context: Context.empty(),
286
+ rpc: StreamRpc,
287
+ lastReceivedReply: Option.none(),
288
+ respond() {
289
+ return Effect.void
290
+ }
291
+ })
292
+ })
293
+
294
+ const makeReply = Effect.fnUntraced(function*(request: Message.OutgoingRequest<typeof GetUserRpc>) {
295
+ const snowflake = yield* Snowflake.Generator
296
+ return new Reply.ReplyWithContext({
297
+ reply: new Reply.WithExit<typeof GetUserRpc>({
298
+ id: snowflake.nextUnsafe(),
299
+ requestId: request.envelope.requestId,
300
+ exit: Exit.void
301
+ }),
302
+ context: request.context,
303
+ rpc: request.rpc
304
+ })
305
+ })
306
+
307
+ const makeStreamReply = Effect.fnUntraced(function*(request: Message.OutgoingRequest<typeof StreamRpc>) {
308
+ const snowflake = yield* Snowflake.Generator
309
+ return new Reply.ReplyWithContext({
310
+ reply: new Reply.WithExit<typeof StreamRpc>({
311
+ id: snowflake.nextUnsafe(),
312
+ requestId: request.envelope.requestId,
313
+ exit: Exit.void
314
+ }),
315
+ context: request.context,
316
+ rpc: request.rpc
317
+ })
318
+ })
319
+
320
+ const makeAckChunk = Effect.fnUntraced(function*(
321
+ request: Message.OutgoingRequest<typeof StreamRpc>,
322
+ chunk: Reply.ReplyWithContext<typeof StreamRpc>
323
+ ) {
324
+ const snowflake = yield* Snowflake.Generator
325
+ return new Message.OutgoingEnvelope({
326
+ envelope: new Envelope.AckChunk({
327
+ id: snowflake.nextUnsafe(),
328
+ address: request.envelope.address,
329
+ requestId: chunk.reply.requestId,
330
+ replyId: chunk.reply.id
331
+ }),
332
+ rpc: request.rpc
333
+ })
334
+ })
335
+
336
+ const makeChunkReply = Effect.fnUntraced(function*(
337
+ request: Message.OutgoingRequest<typeof StreamRpc>,
338
+ sequence: number
339
+ ) {
340
+ const snowflake = yield* Snowflake.Generator
341
+ return new Reply.ReplyWithContext({
342
+ reply: new Reply.Chunk<typeof StreamRpc>({
343
+ id: snowflake.nextUnsafe(),
344
+ requestId: request.envelope.requestId,
345
+ sequence,
346
+ values: [undefined]
347
+ }),
348
+ context: request.context,
349
+ rpc: request.rpc
350
+ })
351
+ })
352
+
353
+ const requestPayloadId = (message: Message.Incoming<never>) => {
354
+ if (message.envelope._tag !== "Request") {
355
+ throw new Error(`Expected Request envelope`)
356
+ }
357
+ const envelope = message.envelope
358
+ assert(typeof envelope.payload === "object" && envelope.payload !== null)
359
+ assert("id" in envelope.payload)
360
+ assert.strictEqual(typeof envelope.payload.id, "number")
361
+ return envelope.payload.id
362
+ }
363
+
364
+ const testShardId = (label: string, id = 1) => ShardId.make(`cluster-cosmos-${testRunId}-${label}`, id)
365
+
366
+ const testShardGroup = (label: string) => `cluster-cosmos-${testRunId}-${label}`
367
+
368
+ const testRunnerAddress = (offset: number) => RunnerAddress.make("localhost", runnerPortBase + offset)
369
+
370
+ const runnerStatus = (
371
+ runners: ReadonlyArray<readonly [Runner.Runner, boolean]>,
372
+ address: RunnerAddress.RunnerAddress
373
+ ) => runners.find(([runner]) => runner.address.host === address.host && runner.address.port === address.port)
374
+
375
+ const clusterRpcLayer = (label: string) => {
376
+ const shardGroup = testShardGroup(label)
377
+ return CosmosRpcEntityLayer.pipe(
378
+ Layer.provideMerge(Sharding.layer),
379
+ Layer.provide(Runners.layerNoop),
380
+ Layer.provide(RunnerHealth.layerNoop),
381
+ Layer.provide(layerCosmos({
382
+ url: Redacted.make(cosmosUrl ?? ""),
383
+ dbName: cosmosDb,
384
+ prefix: "test-cluster-"
385
+ })),
386
+ Layer.provide(ShardingConfig.layer({
387
+ runnerAddress: Option.some(testRunnerAddress(10)),
388
+ shardsPerGroup: 1,
389
+ availableShardGroups: [shardGroup],
390
+ assignedShardGroups: [shardGroup],
391
+ entityTerminationTimeout: 0,
392
+ entityMessagePollInterval: 50,
393
+ entityReplyPollInterval: 50,
394
+ refreshAssignmentsInterval: 0,
395
+ sendRetryInterval: 50
396
+ }))
397
+ )
398
+ }
399
+
400
+ const waitForShard = (sharding: Sharding.Sharding["Service"], shardId: ShardId.ShardId) =>
401
+ Effect.gen(function*() {
402
+ for (let i = 0; i < 30; i++) {
403
+ if (sharding.hasShardId(shardId)) return
404
+ yield* Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, 100)))
405
+ }
406
+ })
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster-cosmos.test.d.ts","sourceRoot":"","sources":["../cluster-cosmos.test.ts"],"names":[],"mappings":""}