@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,954 @@
1
+ import * as Arr from "effect-app/Array"
2
+ import * as Effect from "effect-app/Effect"
3
+ import * as Layer from "effect-app/Layer"
4
+ import * as Option from "effect-app/Option"
5
+ import * as Cause from "effect/Cause"
6
+ import * as Duration from "effect/Duration"
7
+ import * as Redacted from "effect/Redacted"
8
+ import { PersistenceError } from "effect/unstable/cluster/ClusterError"
9
+ import type * as Envelope from "effect/unstable/cluster/Envelope"
10
+ import * as MessageStorage from "effect/unstable/cluster/MessageStorage"
11
+ import { SaveResultEncoded } from "effect/unstable/cluster/MessageStorage"
12
+ import type * as Reply from "effect/unstable/cluster/Reply"
13
+ import * as RunnerStorage from "effect/unstable/cluster/RunnerStorage"
14
+ import * as ShardId from "effect/unstable/cluster/ShardId"
15
+ import * as ShardingConfig from "effect/unstable/cluster/ShardingConfig"
16
+ import * as Snowflake from "effect/unstable/cluster/Snowflake"
17
+ import { CosmosClient, CosmosClientLayer } from "./cosmos-client.js"
18
+ import { annotateCosmosResponse, annotateDb } from "./otel.js"
19
+
20
+ export interface ClusterCosmosConfig {
21
+ readonly url: Redacted.Redacted<string>
22
+ readonly dbName: string
23
+ readonly prefix?: string
24
+ }
25
+
26
+ type MessageKind = "Request" | "AckChunk" | "Interrupt"
27
+ type CosmosQueryValue = string | number | boolean | null | Array<string | number | boolean | null>
28
+ type CosmosParameter = { readonly name: string; readonly value: CosmosQueryValue }
29
+
30
+ interface MessageDoc {
31
+ readonly id: string
32
+ readonly _partitionKey: string
33
+ readonly type: "message"
34
+ readonly rowid: string
35
+ readonly messageId: string | null
36
+ readonly shardId: string
37
+ readonly entityType: string
38
+ readonly entityId: string
39
+ readonly kind: MessageKind
40
+ readonly tag: string | null
41
+ readonly payload: unknown
42
+ readonly headers: Record<string, string> | null
43
+ readonly traceId?: string | undefined
44
+ readonly spanId?: string | undefined
45
+ readonly sampled?: boolean | undefined
46
+ processed: boolean
47
+ readonly requestId: string
48
+ readonly replyId: string | null
49
+ lastReplyId: string | null
50
+ lastRead: number | null
51
+ readonly deliverAt: number | null
52
+ readonly _etag?: string
53
+ }
54
+
55
+ type ReplyDoc = WithExitReplyDoc | ChunkReplyDoc
56
+
57
+ interface ReplyDocBase {
58
+ readonly id: string
59
+ readonly _partitionKey: string
60
+ readonly type: "reply"
61
+ readonly rowid: string
62
+ readonly requestId: string
63
+ acked: boolean
64
+ }
65
+
66
+ interface WithExitReplyDoc extends ReplyDocBase {
67
+ readonly kind: "WithExit"
68
+ readonly payload: Reply.WithExitEncoded["exit"]
69
+ readonly sequence: null
70
+ }
71
+
72
+ interface ChunkReplyDoc extends ReplyDocBase {
73
+ readonly kind: "Chunk"
74
+ readonly payload: Reply.ChunkEncoded["values"]
75
+ readonly sequence: number
76
+ }
77
+
78
+ interface RunnerDoc {
79
+ readonly id: string
80
+ readonly _partitionKey: "runner"
81
+ readonly type: "runner"
82
+ readonly address: string
83
+ runner: string
84
+ healthy: boolean
85
+ lastHeartbeat: number
86
+ }
87
+
88
+ interface LockDoc {
89
+ readonly id: string
90
+ readonly _partitionKey: "lock"
91
+ readonly type: "lock"
92
+ readonly shardId: string
93
+ address: string
94
+ acquiredAt: number
95
+ readonly _etag?: string
96
+ }
97
+
98
+ const withTracerDisabled = Effect.withTracerEnabled(false)
99
+ const refailPersistence = <A, E, R>(effect: Effect.Effect<A, E, R>) => PersistenceError.refail(effect)
100
+ const cosmosId = (id: string) => encodeURIComponent(id)
101
+ const messagePartition = (shardId: string) => `message::${shardId}`
102
+ const messageDocId = (envelope: Envelope.Encoded, primaryKey: string | null) =>
103
+ cosmosId(primaryKey === null ? envelopeId(envelope) : `primary::${primaryKey}`)
104
+ const replyPartition = (requestId: string) => `reply::${requestId}`
105
+ const runnerDocId = (address: string) => cosmosId(`runner::${address}`)
106
+ const lockDocId = (shardId: string) => cosmosId(`lock::${shardId}`)
107
+ const tenMinutes = Duration.toMillis(Duration.minutes(10))
108
+
109
+ const isCosmosStatus = (u: unknown, code: number): boolean =>
110
+ Cause.isUnknownError(u)
111
+ ? isCosmosStatus(u.cause, code)
112
+ : typeof u === "object" && u !== null && "code" in u && u.code === code
113
+
114
+ const isConflict = (u: unknown) => isCosmosStatus(u, 409)
115
+ const isNotFound = (u: unknown) => isCosmosStatus(u, 404)
116
+ const isPreconditionFailed = (u: unknown) => isCosmosStatus(u, 412)
117
+
118
+ const respBytes = (
119
+ resp: { diagnostics?: { clientSideRequestStatistics?: { totalResponsePayloadLengthInBytes?: number } } }
120
+ ) => resp.diagnostics?.clientSideRequestStatistics?.totalResponsePayloadLengthInBytes ?? 0
121
+
122
+ const annotateItem = (resp: {
123
+ readonly requestCharge?: number
124
+ readonly statusCode?: number
125
+ readonly diagnostics?: {
126
+ readonly clientSideRequestStatistics?: { readonly totalResponsePayloadLengthInBytes?: number }
127
+ }
128
+ }) =>
129
+ annotateCosmosResponse({
130
+ requestCharge: resp.requestCharge,
131
+ statusCode: resp.statusCode,
132
+ responseBytes: respBytes(resp)
133
+ })
134
+
135
+ const annotateFeed = (resp: {
136
+ readonly resources: readonly unknown[]
137
+ readonly requestCharge?: number
138
+ readonly diagnostics?: {
139
+ readonly clientSideRequestStatistics?: { readonly totalResponsePayloadLengthInBytes?: number }
140
+ }
141
+ }) =>
142
+ annotateCosmosResponse({
143
+ requestCharge: resp.requestCharge,
144
+ returnedRows: resp.resources.length,
145
+ responseBytes: respBytes(resp)
146
+ })
147
+
148
+ const envelopeId = (envelope: Envelope.Encoded) => envelope._tag === "Request" ? envelope.requestId : envelope.id
149
+
150
+ const envelopeToDoc = (
151
+ envelope: Envelope.Encoded,
152
+ primaryKey: string | null,
153
+ deliverAt: number | null
154
+ ): MessageDoc => {
155
+ switch (envelope._tag) {
156
+ case "Request":
157
+ return {
158
+ id: messageDocId(envelope, primaryKey),
159
+ _partitionKey: messagePartition(ShardId.toString(envelope.address.shardId)),
160
+ type: "message",
161
+ rowid: envelope.requestId,
162
+ messageId: primaryKey,
163
+ shardId: ShardId.toString(envelope.address.shardId),
164
+ entityType: envelope.address.entityType,
165
+ entityId: envelope.address.entityId,
166
+ kind: "Request",
167
+ tag: envelope.tag,
168
+ payload: envelope.payload,
169
+ headers: envelope.headers,
170
+ traceId: envelope.traceId,
171
+ spanId: envelope.spanId,
172
+ sampled: envelope.sampled,
173
+ processed: false,
174
+ requestId: envelope.requestId,
175
+ replyId: null,
176
+ lastReplyId: null,
177
+ lastRead: null,
178
+ deliverAt
179
+ }
180
+ case "AckChunk":
181
+ return {
182
+ id: cosmosId(envelope.id),
183
+ _partitionKey: messagePartition(ShardId.toString(envelope.address.shardId)),
184
+ type: "message",
185
+ rowid: envelope.id,
186
+ messageId: primaryKey,
187
+ shardId: ShardId.toString(envelope.address.shardId),
188
+ entityType: envelope.address.entityType,
189
+ entityId: envelope.address.entityId,
190
+ kind: "AckChunk",
191
+ tag: null,
192
+ payload: null,
193
+ headers: null,
194
+ processed: false,
195
+ requestId: envelope.requestId,
196
+ replyId: envelope.replyId,
197
+ lastReplyId: null,
198
+ lastRead: null,
199
+ deliverAt
200
+ }
201
+ case "Interrupt":
202
+ return {
203
+ id: cosmosId(envelope.id),
204
+ _partitionKey: messagePartition(ShardId.toString(envelope.address.shardId)),
205
+ type: "message",
206
+ rowid: envelope.id,
207
+ messageId: primaryKey,
208
+ shardId: ShardId.toString(envelope.address.shardId),
209
+ entityType: envelope.address.entityType,
210
+ entityId: envelope.address.entityId,
211
+ kind: "Interrupt",
212
+ tag: null,
213
+ payload: null,
214
+ headers: null,
215
+ processed: false,
216
+ requestId: envelope.requestId,
217
+ replyId: null,
218
+ lastReplyId: null,
219
+ lastRead: null,
220
+ deliverAt
221
+ }
222
+ }
223
+ }
224
+
225
+ const envelopeFromDoc = (
226
+ doc: MessageDoc,
227
+ lastSentReply: Option.Option<Reply.Encoded>
228
+ ): {
229
+ readonly envelope: Envelope.Encoded
230
+ readonly lastSentReply: Option.Option<Reply.Encoded>
231
+ } => {
232
+ switch (doc.kind) {
233
+ case "Request": {
234
+ const envelope: Envelope.PartialRequestEncoded = {
235
+ _tag: "Request",
236
+ requestId: doc.requestId,
237
+ address: {
238
+ shardId: shardIdFromString(doc.shardId),
239
+ entityType: doc.entityType,
240
+ entityId: doc.entityId
241
+ },
242
+ tag: doc.tag ?? "",
243
+ payload: doc.payload,
244
+ headers: doc.headers ?? {},
245
+ ...(doc.traceId !== undefined && { traceId: doc.traceId }),
246
+ ...(doc.spanId !== undefined && { spanId: doc.spanId }),
247
+ ...(doc.sampled !== undefined && { sampled: doc.sampled })
248
+ }
249
+ return {
250
+ envelope,
251
+ lastSentReply
252
+ }
253
+ }
254
+ case "AckChunk":
255
+ return {
256
+ envelope: {
257
+ _tag: "AckChunk",
258
+ id: doc.rowid,
259
+ requestId: doc.requestId,
260
+ replyId: doc.replyId ?? "",
261
+ address: {
262
+ shardId: shardIdFromString(doc.shardId),
263
+ entityType: doc.entityType,
264
+ entityId: doc.entityId
265
+ }
266
+ },
267
+ lastSentReply: Option.none()
268
+ }
269
+ case "Interrupt":
270
+ return {
271
+ envelope: {
272
+ _tag: "Interrupt",
273
+ id: doc.rowid,
274
+ requestId: doc.requestId,
275
+ address: {
276
+ shardId: shardIdFromString(doc.shardId),
277
+ entityType: doc.entityType,
278
+ entityId: doc.entityId
279
+ }
280
+ },
281
+ lastSentReply: Option.none()
282
+ }
283
+ }
284
+ }
285
+
286
+ const replyToDoc = (reply: Reply.Encoded): ReplyDoc =>
287
+ reply._tag === "WithExit"
288
+ ? {
289
+ id: cosmosId(reply.id),
290
+ _partitionKey: replyPartition(reply.requestId),
291
+ type: "reply",
292
+ rowid: reply.id,
293
+ kind: "WithExit",
294
+ requestId: reply.requestId,
295
+ payload: reply.exit,
296
+ sequence: null,
297
+ acked: false
298
+ }
299
+ : {
300
+ id: cosmosId(reply.id),
301
+ _partitionKey: replyPartition(reply.requestId),
302
+ type: "reply",
303
+ rowid: reply.id,
304
+ kind: "Chunk",
305
+ requestId: reply.requestId,
306
+ payload: reply.values,
307
+ sequence: reply.sequence,
308
+ acked: false
309
+ }
310
+
311
+ const replyFromDoc = (doc: ReplyDoc): Reply.Encoded =>
312
+ doc.kind === "WithExit"
313
+ ? {
314
+ _tag: "WithExit",
315
+ id: doc.rowid,
316
+ requestId: doc.requestId,
317
+ exit: doc.payload
318
+ }
319
+ : {
320
+ _tag: "Chunk",
321
+ id: doc.rowid,
322
+ requestId: doc.requestId,
323
+ values: doc.payload,
324
+ sequence: doc.sequence ?? 0
325
+ }
326
+
327
+ const shardIdFromString = (shardId: string): Envelope.Encoded["address"]["shardId"] =>
328
+ ShardId.fromStringEncoded(shardId)
329
+
330
+ const makeMachineId = (address: string) => {
331
+ let hash = 0
332
+ for (let i = 0; i < address.length; i++) {
333
+ hash = Math.imul(31, hash) + address.charCodeAt(i) | 0
334
+ }
335
+ return Math.abs(hash)
336
+ }
337
+
338
+ const createContainer = (prefix: string) =>
339
+ Effect.fnUntraced(function*() {
340
+ const { db } = yield* CosmosClient
341
+ const containerId = `${prefix}cluster`
342
+ yield* Effect
343
+ .tryPromise(() =>
344
+ db.containers.create({
345
+ id: containerId,
346
+ partitionKey: { paths: ["/_partitionKey"], version: 2 }
347
+ })
348
+ )
349
+ .pipe(Effect.catchIf(isConflict, () => Effect.void))
350
+ return db.container(containerId)
351
+ })
352
+
353
+ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
354
+ readonly prefix?: string | undefined
355
+ }) {
356
+ const prefix = options?.prefix ?? "cluster-"
357
+ const container = yield* createContainer(prefix)().pipe(Effect.orDie)
358
+ const containerId = `${prefix}cluster`
359
+ const annotate = (operation: string) =>
360
+ annotateDb({ operation, system: "cosmosdb", collection: containerId, entity: "cluster-message-storage" })
361
+
362
+ const readMessage = (id: string, partitionKey: string) =>
363
+ Effect.tryPromise(() => container.item(id, partitionKey).read<MessageDoc>()).pipe(
364
+ Effect.tap(annotateItem),
365
+ Effect.map((resp) => Option.fromNullishOr(resp.resource)),
366
+ Effect.catchIf(isNotFound, () => Effect.succeed(Option.none()))
367
+ )
368
+
369
+ const queryMessages = (query: string, parameters: ReadonlyArray<CosmosParameter>) =>
370
+ Effect
371
+ .tryPromise(() => container.items.query<MessageDoc>({ query, parameters: Array.from(parameters) }).fetchAll())
372
+ .pipe(
373
+ Effect.tap(annotateFeed),
374
+ Effect.map((resp) => resp.resources)
375
+ )
376
+
377
+ const queryReplies = (query: string, parameters: ReadonlyArray<CosmosParameter>) =>
378
+ Effect
379
+ .tryPromise(() => container.items.query<ReplyDoc>({ query, parameters: Array.from(parameters) }).fetchAll())
380
+ .pipe(
381
+ Effect.tap(annotateFeed),
382
+ Effect.map((resp) => resp.resources)
383
+ )
384
+
385
+ const lastReply = (replyId: string | null) =>
386
+ replyId === null
387
+ ? Effect.succeed(Option.none<Reply.Encoded>())
388
+ : queryReplies("SELECT * FROM c WHERE c.type = 'reply' AND c.rowid = @id", [{ name: "@id", value: replyId }])
389
+ .pipe(
390
+ Effect.map((docs) => Option.map(Option.fromNullishOr(docs[0]), replyFromDoc))
391
+ )
392
+
393
+ const markReplyAcked = (requestId: string, replyId: string) =>
394
+ Effect.tryPromise(() => container.item(cosmosId(replyId), replyPartition(requestId)).read<ReplyDoc>()).pipe(
395
+ Effect.flatMap((resp) => {
396
+ const doc = resp.resource
397
+ if (!doc) return Effect.void
398
+ doc.acked = true
399
+ return Effect
400
+ .tryPromise(() =>
401
+ container.item(cosmosId(replyId), replyPartition(requestId)).replace(doc, {
402
+ accessCondition: { type: "IfMatch", condition: doc._etag ?? "" }
403
+ })
404
+ )
405
+ .pipe(Effect.tap(annotateItem), Effect.asVoid)
406
+ }),
407
+ Effect.catchIf(isNotFound, () => Effect.void),
408
+ Effect.catchIf(isPreconditionFailed, () => Effect.void)
409
+ )
410
+
411
+ const replaceMessage = (doc: MessageDoc) =>
412
+ Effect
413
+ .tryPromise(() =>
414
+ container.item(doc.id, doc._partitionKey).replace(doc, {
415
+ accessCondition: { type: "IfMatch", condition: doc._etag ?? "" }
416
+ })
417
+ )
418
+ .pipe(Effect.tap(annotateItem), Effect.asVoid)
419
+
420
+ return yield* MessageStorage.makeEncoded({
421
+ saveEnvelope: ({ deliverAt, envelope, primaryKey }) =>
422
+ Effect
423
+ .gen(function*() {
424
+ const doc = envelopeToDoc(envelope, primaryKey, deliverAt)
425
+ if (envelope._tag === "AckChunk") {
426
+ yield* markReplyAcked(envelope.requestId, envelope.replyId)
427
+ const pendingAcks = yield* queryMessages(
428
+ "SELECT * FROM c WHERE c.type = 'message' AND c.kind = 'AckChunk' AND c.processed = false AND c.requestId = @requestId",
429
+ [{ name: "@requestId", value: envelope.requestId }]
430
+ )
431
+ yield* Effect.forEach(pendingAcks, (ack) => {
432
+ ack.processed = true
433
+ return replaceMessage(ack)
434
+ }, { discard: true })
435
+ }
436
+ return yield* Effect.tryPromise(() => container.items.create(doc)).pipe(
437
+ Effect.tap(annotateItem),
438
+ Effect.as<MessageStorage.SaveResult.Encoded>(SaveResultEncoded.Success()),
439
+ Effect.catchIf(isConflict, () =>
440
+ readMessage(doc.id, doc._partitionKey).pipe(
441
+ Effect.flatMap((found) =>
442
+ Option.match(found, {
443
+ onNone: () => Effect.succeed<MessageStorage.SaveResult.Encoded>(SaveResultEncoded.Success()),
444
+ onSome: (existing) =>
445
+ lastReply(existing.lastReplyId).pipe(
446
+ Effect.map((lastReceivedReply) =>
447
+ SaveResultEncoded.Duplicate({
448
+ originalId: Snowflake.Snowflake(existing.requestId),
449
+ lastReceivedReply
450
+ })
451
+ )
452
+ )
453
+ })
454
+ )
455
+ ))
456
+ )
457
+ })
458
+ .pipe(annotate("saveEnvelope"), refailPersistence, withTracerDisabled),
459
+
460
+ saveReply: (reply) =>
461
+ Effect
462
+ .gen(function*() {
463
+ const doc = replyToDoc(reply)
464
+ yield* Effect.tryPromise(() => container.items.create(doc)).pipe(
465
+ Effect.tap(annotateItem),
466
+ Effect.catchIf(isConflict, () => Effect.void)
467
+ )
468
+ const messages = yield* queryMessages(
469
+ "SELECT * FROM c WHERE c.type = 'message' AND c.requestId = @requestId",
470
+ [{ name: "@requestId", value: reply.requestId }]
471
+ )
472
+ yield* Effect.forEach(messages, (message) => {
473
+ if (reply._tag === "WithExit") {
474
+ message.processed = true
475
+ } else if (message.id !== reply.requestId && message.kind !== "Request") {
476
+ return Effect.void
477
+ }
478
+ message.lastReplyId = reply.id
479
+ return replaceMessage(message).pipe(Effect.catchIf(isPreconditionFailed, () => Effect.void))
480
+ }, { discard: true })
481
+ })
482
+ .pipe(annotate("saveReply"), refailPersistence, withTracerDisabled),
483
+
484
+ clearReplies: (requestId) =>
485
+ Effect
486
+ .gen(function*() {
487
+ const id = String(requestId)
488
+ const replies = yield* queryReplies(
489
+ "SELECT * FROM c WHERE c.type = 'reply' AND c.requestId = @requestId AND c.kind = 'Chunk'",
490
+ [
491
+ { name: "@requestId", value: id }
492
+ ]
493
+ )
494
+ yield* Effect.forEach(replies, (reply) =>
495
+ Effect
496
+ .tryPromise(() => container.item(reply.id, reply._partitionKey).delete())
497
+ .pipe(
498
+ Effect.tap(annotateItem),
499
+ Effect.catchIf(isNotFound, () => Effect.void)
500
+ ), { discard: true })
501
+ const messages = yield* queryMessages(
502
+ "SELECT * FROM c WHERE c.type = 'message' AND c.requestId = @requestId",
503
+ [{ name: "@requestId", value: id }]
504
+ )
505
+ yield* Effect.forEach(messages, (message) => {
506
+ if (message.kind === "Interrupt") {
507
+ return Effect.tryPromise(() => container.item(message.id, message._partitionKey).delete()).pipe(
508
+ Effect.tap(annotateItem),
509
+ Effect.catchIf(isNotFound, () => Effect.void)
510
+ )
511
+ }
512
+ message.processed = false
513
+ message.lastReplyId = null
514
+ message.lastRead = null
515
+ return replaceMessage(message).pipe(Effect.catchIf(isPreconditionFailed, () => Effect.void))
516
+ }, { discard: true })
517
+ })
518
+ .pipe(annotate("clearReplies"), refailPersistence, withTracerDisabled),
519
+
520
+ requestIdForPrimaryKey: (primaryKey) =>
521
+ queryMessages("SELECT * FROM c WHERE c.type = 'message' AND c.messageId = @primaryKey", [
522
+ { name: "@primaryKey", value: primaryKey }
523
+ ])
524
+ .pipe(
525
+ Effect.map((docs) => Option.map(Option.fromNullishOr(docs[0]?.requestId), Snowflake.Snowflake)),
526
+ annotate("requestIdForPrimaryKey"),
527
+ refailPersistence,
528
+ withTracerDisabled
529
+ ),
530
+
531
+ repliesFor: (requestIds) =>
532
+ queryReplies(
533
+ "SELECT * FROM c WHERE c.type = 'reply' AND ARRAY_CONTAINS(@requestIds, c.requestId) AND (c.kind = 'WithExit' OR (c.kind = 'Chunk' AND c.acked = false)) ORDER BY c.rowid",
534
+ [{ name: "@requestIds", value: Array.from(requestIds) }]
535
+ )
536
+ .pipe(
537
+ Effect.map(Arr.map(replyFromDoc)),
538
+ annotate("repliesFor"),
539
+ refailPersistence,
540
+ withTracerDisabled
541
+ ),
542
+
543
+ repliesForUnfiltered: (requestIds) =>
544
+ queryReplies(
545
+ "SELECT * FROM c WHERE c.type = 'reply' AND ARRAY_CONTAINS(@requestIds, c.requestId) ORDER BY c.rowid",
546
+ [{ name: "@requestIds", value: Array.from(requestIds) }]
547
+ )
548
+ .pipe(
549
+ Effect.map(Arr.map(replyFromDoc)),
550
+ annotate("repliesForUnfiltered"),
551
+ refailPersistence,
552
+ withTracerDisabled
553
+ ),
554
+
555
+ unprocessedMessages: (shardIds, now) =>
556
+ queryMessages(
557
+ "SELECT * FROM c WHERE c.type = 'message' AND ARRAY_CONTAINS(@shardIds, c.shardId) AND c.processed = false AND (NOT IS_DEFINED(c.lastRead) OR IS_NULL(c.lastRead) OR c.lastRead < @lastReadBefore) AND (NOT IS_DEFINED(c.deliverAt) OR IS_NULL(c.deliverAt) OR c.deliverAt <= @now) ORDER BY c.rowid",
558
+ [
559
+ { name: "@shardIds", value: Array.from(shardIds) },
560
+ { name: "@lastReadBefore", value: now - tenMinutes },
561
+ { name: "@now", value: now }
562
+ ]
563
+ )
564
+ .pipe(
565
+ Effect.flatMap((docs) => collectUnprocessed(docs, now, lastReply, replaceMessage, queryReplies)),
566
+ annotate("unprocessedMessages"),
567
+ refailPersistence,
568
+ withTracerDisabled
569
+ ),
570
+
571
+ unprocessedMessagesById: (messageIds, now) =>
572
+ queryMessages(
573
+ "SELECT * FROM c WHERE c.type = 'message' AND (ARRAY_CONTAINS(@messageIds, c.id) OR ARRAY_CONTAINS(@messageIds, c.requestId)) AND c.processed = false AND (NOT IS_DEFINED(c.deliverAt) OR IS_NULL(c.deliverAt) OR c.deliverAt <= @now) ORDER BY c.rowid",
574
+ [
575
+ { name: "@messageIds", value: Array.from(messageIds, String) },
576
+ { name: "@now", value: now }
577
+ ]
578
+ )
579
+ .pipe(
580
+ Effect.flatMap((docs) => collectUnprocessed(docs, now, lastReply, replaceMessage, queryReplies)),
581
+ annotate("unprocessedMessagesById"),
582
+ refailPersistence,
583
+ withTracerDisabled
584
+ ),
585
+
586
+ resetAddress: (address) =>
587
+ queryMessages(
588
+ "SELECT * FROM c WHERE c.type = 'message' AND c.processed = false AND c.shardId = @shardId AND c.entityType = @entityType AND c.entityId = @entityId",
589
+ [
590
+ { name: "@shardId", value: ShardId.toString(address.shardId) },
591
+ { name: "@entityType", value: address.entityType },
592
+ { name: "@entityId", value: address.entityId }
593
+ ]
594
+ )
595
+ .pipe(
596
+ Effect.flatMap((docs) =>
597
+ Effect.forEach(docs, (doc) => {
598
+ doc.lastRead = null
599
+ return replaceMessage(doc).pipe(Effect.catchIf(isPreconditionFailed, () => Effect.void))
600
+ }, { discard: true })
601
+ ),
602
+ annotate("resetAddress"),
603
+ refailPersistence,
604
+ withTracerDisabled
605
+ ),
606
+
607
+ clearAddress: (address) =>
608
+ queryMessages(
609
+ "SELECT * FROM c WHERE c.type = 'message' AND c.entityType = @entityType AND c.entityId = @entityId",
610
+ [
611
+ { name: "@entityType", value: address.entityType },
612
+ { name: "@entityId", value: address.entityId }
613
+ ]
614
+ )
615
+ .pipe(
616
+ Effect.flatMap((messages) =>
617
+ Effect.forEach(messages, (message) =>
618
+ queryReplies("SELECT * FROM c WHERE c.type = 'reply' AND c.requestId = @requestId", [
619
+ {
620
+ name: "@requestId",
621
+ value: message
622
+ .requestId
623
+ }
624
+ ])
625
+ .pipe(
626
+ Effect
627
+ .flatMap((replies) =>
628
+ Effect
629
+ .forEach(replies, (reply) =>
630
+ Effect
631
+ .tryPromise(() =>
632
+ container.item(reply.id, reply._partitionKey).delete()
633
+ )
634
+ .pipe(
635
+ Effect.tap(annotateItem),
636
+ Effect.catchIf(isNotFound, () => Effect.void)
637
+ ), { discard: true })
638
+ ),
639
+ Effect.andThen(
640
+ Effect.tryPromise(() => container.item(message.id, message._partitionKey).delete()).pipe(
641
+ Effect.tap(annotateItem),
642
+ Effect.catchIf(isNotFound, () => Effect.void)
643
+ )
644
+ )
645
+ ), { discard: true })
646
+ ),
647
+ annotate("clearAddress"),
648
+ refailPersistence,
649
+ withTracerDisabled
650
+ ),
651
+
652
+ resetShards: (shardIds) =>
653
+ queryMessages(
654
+ "SELECT * FROM c WHERE c.type = 'message' AND c.processed = false AND ARRAY_CONTAINS(@shardIds, c.shardId)",
655
+ [{ name: "@shardIds", value: Array.from(shardIds) }]
656
+ )
657
+ .pipe(
658
+ Effect.flatMap((docs) =>
659
+ Effect.forEach(docs, (doc) => {
660
+ doc.lastRead = null
661
+ return replaceMessage(doc).pipe(Effect.catchIf(isPreconditionFailed, () => Effect.void))
662
+ }, { discard: true })
663
+ ),
664
+ annotate("resetShards"),
665
+ refailPersistence,
666
+ withTracerDisabled
667
+ ),
668
+
669
+ withTransaction: (effect) => effect
670
+ })
671
+ })
672
+
673
+ const collectUnprocessed = <E>(
674
+ docs: ReadonlyArray<MessageDoc>,
675
+ now: number,
676
+ lastReply: (replyId: string | null) => Effect.Effect<Option.Option<Reply.Encoded>, E>,
677
+ replaceMessage: (doc: MessageDoc) => Effect.Effect<void, E>,
678
+ queryReplies: (
679
+ query: string,
680
+ parameters: ReadonlyArray<CosmosParameter>
681
+ ) => Effect.Effect<Array<ReplyDoc>, E>
682
+ ) =>
683
+ Effect.gen(function*() {
684
+ const messages: Array<{
685
+ readonly envelope: Envelope.Encoded
686
+ readonly lastSentReply: Option.Option<Reply.Encoded>
687
+ }> = []
688
+ for (const doc of docs) {
689
+ const replies = yield* queryReplies(
690
+ "SELECT * FROM c WHERE c.type = 'reply' AND c.requestId = @requestId AND (c.kind = 'WithExit' OR (c.kind = 'Chunk' AND c.acked = false))",
691
+ [{ name: "@requestId", value: doc.requestId }]
692
+ )
693
+ if (Arr.isArrayNonEmpty(replies)) continue
694
+ const sentReply = yield* lastReply(doc.lastReplyId)
695
+ doc.lastRead = now
696
+ yield* replaceMessage(doc).pipe(Effect.catchIf(isPreconditionFailed, () => Effect.void))
697
+ messages.push(envelopeFromDoc(doc, sentReply))
698
+ }
699
+ return messages
700
+ })
701
+
702
+ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
703
+ readonly prefix?: string | undefined
704
+ }) {
705
+ const prefix = options?.prefix ?? "cluster-"
706
+ const container = yield* createContainer(prefix)().pipe(Effect.orDie)
707
+ const config = yield* ShardingConfig.ShardingConfig
708
+ const expires = Duration.toMillis(Duration.fromInputUnsafe(config.shardLockExpiration))
709
+ const containerId = `${prefix}cluster`
710
+ const annotate = (operation: string) =>
711
+ annotateDb({ operation, system: "cosmosdb", collection: containerId, entity: "cluster-runner-storage" })
712
+
713
+ const queryRunners = (query: string, parameters: ReadonlyArray<CosmosParameter>) =>
714
+ Effect
715
+ .tryPromise(() =>
716
+ container
717
+ .items
718
+ .query<RunnerDoc>({ query, parameters: Array.from(parameters) }, { partitionKey: "runner" })
719
+ .fetchAll()
720
+ )
721
+ .pipe(Effect.tap(annotateFeed), Effect.map((resp) => resp.resources))
722
+
723
+ const readLock = (shardId: string) =>
724
+ Effect.tryPromise(() => container.item(lockDocId(shardId), "lock").read<LockDoc>()).pipe(
725
+ Effect.tap(annotateItem),
726
+ Effect.map((resp) => Option.fromNullishOr(resp.resource)),
727
+ Effect.catchIf(isNotFound, () => Effect.succeed(Option.none()))
728
+ )
729
+
730
+ const writeLock = (doc: LockDoc) =>
731
+ Effect
732
+ .tryPromise(() =>
733
+ container.item(doc.id, "lock").replace(doc, {
734
+ accessCondition: { type: "IfMatch", condition: doc._etag ?? "" }
735
+ })
736
+ )
737
+ .pipe(
738
+ Effect.tap(annotateItem),
739
+ Effect.as(true),
740
+ Effect.catchIf(isPreconditionFailed, () => Effect.succeed(false))
741
+ )
742
+
743
+ const createLock = (address: string, shardId: string, now: number) =>
744
+ Effect
745
+ .tryPromise(() =>
746
+ container.items.create<LockDoc>({
747
+ id: lockDocId(shardId),
748
+ _partitionKey: "lock",
749
+ type: "lock",
750
+ shardId,
751
+ address,
752
+ acquiredAt: now
753
+ })
754
+ )
755
+ .pipe(
756
+ Effect.tap(annotateItem),
757
+ Effect.as(true),
758
+ Effect.catchIf(isConflict, () => Effect.succeed(false))
759
+ )
760
+
761
+ const tryAcquire = (address: string, shardId: string, now: number) =>
762
+ readLock(shardId).pipe(
763
+ Effect.flatMap((lock) =>
764
+ Option.match(lock, {
765
+ onNone: () => createLock(address, shardId, now),
766
+ onSome: (doc) => {
767
+ if (doc.address !== address && now - doc.acquiredAt <= expires) {
768
+ return Effect.succeed(false)
769
+ }
770
+ doc.address = address
771
+ doc.acquiredAt = now
772
+ return writeLock(doc)
773
+ }
774
+ })
775
+ ),
776
+ Effect.map((acquired) => acquired ? Option.some(shardId) : Option.none())
777
+ )
778
+
779
+ return RunnerStorage.makeEncoded({
780
+ getRunners: Effect.sync(() => Date.now()).pipe(
781
+ Effect.flatMap((now) =>
782
+ queryRunners("SELECT * FROM c WHERE c.type = 'runner' AND c.lastHeartbeat > @expiresAt", [
783
+ { name: "@expiresAt", value: now - expires }
784
+ ])
785
+ ),
786
+ Effect.map((docs) => docs.map((doc) => [doc.runner, doc.healthy] as const)),
787
+ annotate("getRunners"),
788
+ refailPersistence,
789
+ withTracerDisabled
790
+ ),
791
+
792
+ register: (address, runner, healthy) =>
793
+ Effect.sync(() => Date.now()).pipe(
794
+ Effect.flatMap((now) =>
795
+ Effect
796
+ .tryPromise(() =>
797
+ container.items.upsert<RunnerDoc>({
798
+ id: runnerDocId(address),
799
+ _partitionKey: "runner",
800
+ type: "runner",
801
+ address,
802
+ runner,
803
+ healthy,
804
+ lastHeartbeat: now
805
+ })
806
+ )
807
+ .pipe(Effect.tap(annotateItem))
808
+ ),
809
+ Effect.as(makeMachineId(address)),
810
+ annotate("register"),
811
+ refailPersistence,
812
+ withTracerDisabled
813
+ ),
814
+
815
+ unregister: (address) =>
816
+ Effect.tryPromise(() => container.item(runnerDocId(address), "runner").delete()).pipe(
817
+ Effect.tap(annotateItem),
818
+ Effect.catchIf(isNotFound, () => Effect.void),
819
+ annotate("unregister"),
820
+ refailPersistence,
821
+ withTracerDisabled
822
+ ),
823
+
824
+ setRunnerHealth: (address, healthy) =>
825
+ Effect.tryPromise(() => container.item(runnerDocId(address), "runner").read<RunnerDoc>()).pipe(
826
+ Effect.flatMap((resp) => {
827
+ const doc = resp.resource
828
+ if (!doc) return Effect.void
829
+ doc.healthy = healthy
830
+ return Effect.tryPromise(() => container.item(doc.id, "runner").replace(doc)).pipe(Effect.tap(annotateItem))
831
+ }),
832
+ Effect.asVoid,
833
+ Effect.catchIf(isNotFound, () => Effect.void),
834
+ annotate("setRunnerHealth"),
835
+ refailPersistence,
836
+ withTracerDisabled
837
+ ),
838
+
839
+ acquire: (address, shardIds) =>
840
+ Effect.sync(() => Date.now()).pipe(
841
+ Effect.flatMap((now) => Effect.forEach(shardIds, (shardId) => tryAcquire(address, shardId, now))),
842
+ Effect.map(Arr.getSomes),
843
+ annotate("acquire"),
844
+ refailPersistence,
845
+ withTracerDisabled
846
+ ),
847
+
848
+ refresh: (address, shardIds) =>
849
+ Effect
850
+ .gen(function*() {
851
+ const now = Date.now()
852
+ yield* Effect.tryPromise(() => container.item(runnerDocId(address), "runner").read<RunnerDoc>()).pipe(
853
+ Effect.flatMap((resp) => {
854
+ const doc = resp.resource
855
+ if (!doc) return Effect.void
856
+ doc.lastHeartbeat = now
857
+ return Effect.tryPromise(() => container.item(doc.id, "runner").replace(doc)).pipe(
858
+ Effect.tap(annotateItem)
859
+ )
860
+ }),
861
+ Effect.catchIf(isNotFound, () => Effect.void)
862
+ )
863
+ const refreshed = yield* Effect.forEach(shardIds, (shardId) =>
864
+ readLock(shardId).pipe(
865
+ Effect.flatMap((lock) =>
866
+ Option.match(lock, {
867
+ onNone: () => Effect.succeed(Option.none<string>()),
868
+ onSome: (doc) => {
869
+ if (doc.address !== address) return Effect.succeed(Option.none<string>())
870
+ doc.acquiredAt = now
871
+ return writeLock(doc).pipe(Effect.map((ok) => ok ? Option.some(shardId) : Option.none<string>()))
872
+ }
873
+ })
874
+ )
875
+ ))
876
+ return Arr.getSomes(refreshed)
877
+ })
878
+ .pipe(annotate("refresh"), refailPersistence, withTracerDisabled),
879
+
880
+ release: (address, shardId) =>
881
+ readLock(shardId).pipe(
882
+ Effect.flatMap((lock) =>
883
+ Option.match(lock, {
884
+ onNone: () => Effect.void,
885
+ onSome: (doc) =>
886
+ doc.address === address
887
+ ? Effect.tryPromise(() => container.item(doc.id, "lock").delete()).pipe(
888
+ Effect.tap(annotateItem),
889
+ Effect.catchIf(isNotFound, () => Effect.void),
890
+ Effect.asVoid
891
+ )
892
+ : Effect.void
893
+ })
894
+ ),
895
+ annotate("release"),
896
+ refailPersistence,
897
+ withTracerDisabled
898
+ ),
899
+
900
+ releaseAll: (address) =>
901
+ Effect
902
+ .tryPromise(() =>
903
+ container
904
+ .items
905
+ .query<LockDoc>({
906
+ query: "SELECT * FROM c WHERE c.type = 'lock' AND c.address = @address",
907
+ parameters: [{ name: "@address", value: address }]
908
+ }, { partitionKey: "lock" })
909
+ .fetchAll()
910
+ )
911
+ .pipe(
912
+ Effect.tap(annotateFeed),
913
+ Effect.flatMap((resp) =>
914
+ Effect.forEach(resp.resources, (doc) =>
915
+ Effect.tryPromise(() => container.item(doc.id, "lock").delete()).pipe(
916
+ Effect.tap(annotateItem),
917
+ Effect.catchIf(isNotFound, () => Effect.void)
918
+ ), { discard: true })
919
+ ),
920
+ annotate("releaseAll"),
921
+ refailPersistence,
922
+ withTracerDisabled
923
+ )
924
+ })
925
+ })
926
+
927
+ export const layerMessageStorage = (options?: {
928
+ readonly prefix?: string | undefined
929
+ }): Layer.Layer<MessageStorage.MessageStorage, never, CosmosClient | ShardingConfig.ShardingConfig> =>
930
+ Layer.effect(MessageStorage.MessageStorage, makeMessageStorage(options)).pipe(
931
+ Layer.provide(Snowflake.layerGenerator)
932
+ )
933
+
934
+ export const layerRunnerStorage = (options?: {
935
+ readonly prefix?: string | undefined
936
+ }): Layer.Layer<RunnerStorage.RunnerStorage, never, CosmosClient | ShardingConfig.ShardingConfig> =>
937
+ Layer.effect(RunnerStorage.RunnerStorage, makeRunnerStorage(options))
938
+
939
+ export const layerStorage = (options?: {
940
+ readonly prefix?: string | undefined
941
+ }): Layer.Layer<
942
+ MessageStorage.MessageStorage | RunnerStorage.RunnerStorage,
943
+ never,
944
+ CosmosClient | ShardingConfig.ShardingConfig
945
+ > => Layer.merge(layerMessageStorage(options), layerRunnerStorage(options))
946
+
947
+ export const layerCosmos = (config: ClusterCosmosConfig): Layer.Layer<
948
+ MessageStorage.MessageStorage | RunnerStorage.RunnerStorage,
949
+ never,
950
+ ShardingConfig.ShardingConfig
951
+ > =>
952
+ layerStorage({ prefix: config.prefix }).pipe(
953
+ Layer.provide(CosmosClientLayer(Redacted.value(config.url), config.dbName))
954
+ )