@effect-app/infra 4.0.0-beta.263 → 4.0.0-beta.265
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 +32 -0
- package/dist/ClusterCosmos.d.ts +1 -1
- package/dist/ClusterCosmos.d.ts.map +1 -1
- package/dist/ClusterCosmos.js +158 -126
- package/dist/WorkflowEngineCosmos.d.ts.map +1 -1
- package/dist/WorkflowEngineCosmos.js +58 -37
- package/dist/WorkflowEngineSqlite.js +11 -11
- package/package.json +5 -5
- package/run.sh +7 -1
- package/src/ClusterCosmos.ts +300 -196
- package/src/WorkflowEngineCosmos.ts +70 -40
- package/src/WorkflowEngineSqlite.ts +10 -10
- package/test/cluster-cosmos.test.ts +25 -3
- package/test/cluster-sqlite.test.ts +2 -4
- package/test/dist/cluster-azure-sql.test.d.ts.map +1 -0
- package/test/dist/cluster-storage-parity-sqlite.test.d.ts.map +1 -0
- package/test/workflow-engine-cosmos.test.ts +13 -22
- package/test/workflow-engine-sqlite.test.ts +2 -4
package/src/ClusterCosmos.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OperationInput } from "@azure/cosmos"
|
|
1
|
+
import type { OperationInput, PatchRequestBody } from "@azure/cosmos"
|
|
2
2
|
import * as Arr from "effect-app/Array"
|
|
3
3
|
import * as Effect from "effect-app/Effect"
|
|
4
4
|
import * as Layer from "effect-app/Layer"
|
|
@@ -84,6 +84,17 @@ interface RunnerDoc {
|
|
|
84
84
|
runner: string
|
|
85
85
|
healthy: boolean
|
|
86
86
|
lastHeartbeat: number
|
|
87
|
+
/** Unique, stable machine id (mirrors SQL's auto-increment `machine_id`). */
|
|
88
|
+
readonly machineId: number
|
|
89
|
+
readonly _etag?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface MachineIdCounterDoc {
|
|
93
|
+
readonly id: string
|
|
94
|
+
readonly _partitionKey: "runner"
|
|
95
|
+
readonly type: "machine-id-counter"
|
|
96
|
+
value: number
|
|
97
|
+
readonly _etag?: string
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
interface LockDoc {
|
|
@@ -331,13 +342,7 @@ const replyFromDoc = (doc: ReplyDoc): Reply.Encoded =>
|
|
|
331
342
|
const shardIdFromString = (shardId: string): Envelope.Encoded["address"]["shardId"] =>
|
|
332
343
|
ShardId.fromStringEncoded(shardId)
|
|
333
344
|
|
|
334
|
-
const
|
|
335
|
-
let hash = 0
|
|
336
|
-
for (let i = 0; i < address.length; i++) {
|
|
337
|
-
hash = Math.imul(31, hash) + address.charCodeAt(i) | 0
|
|
338
|
-
}
|
|
339
|
-
return Math.abs(hash)
|
|
340
|
-
}
|
|
345
|
+
const machineIdCounterDocId = cosmosId("machine-id-counter")
|
|
341
346
|
|
|
342
347
|
const createContainer = (prefix: string) =>
|
|
343
348
|
Effect.fnUntraced(function*() {
|
|
@@ -395,29 +400,26 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
395
400
|
)
|
|
396
401
|
|
|
397
402
|
const markReplyAcked = (requestId: string, replyId: string) =>
|
|
398
|
-
Effect
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}),
|
|
411
|
-
Effect.catchIf(isNotFound, () => Effect.void),
|
|
412
|
-
Effect.catchIf(isPreconditionFailed, () => Effect.void)
|
|
413
|
-
)
|
|
403
|
+
Effect
|
|
404
|
+
.tryPromise(() =>
|
|
405
|
+
container.item(cosmosId(replyId), replyPartition(requestId)).patch<ReplyDoc>([
|
|
406
|
+
{ op: "set", path: "/acked", value: true }
|
|
407
|
+
])
|
|
408
|
+
)
|
|
409
|
+
.pipe(
|
|
410
|
+
Effect.tap(annotateItem),
|
|
411
|
+
Effect.asVoid,
|
|
412
|
+
Effect.catchIf(isNotFound, () => Effect.void),
|
|
413
|
+
Effect.catchIf(isPreconditionFailed, () => Effect.void)
|
|
414
|
+
)
|
|
414
415
|
|
|
415
|
-
const
|
|
416
|
+
const claimMessageRead = (doc: MessageDoc, now: number) =>
|
|
416
417
|
Effect
|
|
417
418
|
.tryPromise(() =>
|
|
418
|
-
container.item(doc.id, doc._partitionKey).
|
|
419
|
-
|
|
420
|
-
|
|
419
|
+
container.item(doc.id, doc._partitionKey).patch<MessageDoc>(
|
|
420
|
+
[{ op: "set", path: "/lastRead", value: now }],
|
|
421
|
+
{ accessCondition: { type: "IfMatch", condition: doc._etag ?? "" } }
|
|
422
|
+
)
|
|
421
423
|
)
|
|
422
424
|
.pipe(
|
|
423
425
|
Effect.tap(annotateItem),
|
|
@@ -425,28 +427,27 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
425
427
|
Effect.catchIf(isPreconditionFailed, () => Effect.succeed(false))
|
|
426
428
|
)
|
|
427
429
|
|
|
428
|
-
const
|
|
430
|
+
const batchDocs = <A extends { readonly id: string; readonly _partitionKey: string }>(
|
|
431
|
+
docs: ReadonlyArray<A>,
|
|
432
|
+
operation: (doc: A) => OperationInput,
|
|
433
|
+
fallback: (doc: A) => Effect.Effect<void, unknown, never>
|
|
434
|
+
): Effect.Effect<void, unknown, never> =>
|
|
429
435
|
Effect.forEach(
|
|
430
436
|
Arr.groupByT(docs, (doc) => doc._partitionKey),
|
|
431
437
|
([partitionKey, partitionDocs]) =>
|
|
432
438
|
Effect.forEach(
|
|
433
439
|
Arr.chunksOf(partitionDocs, maxCosmosBatchOperations),
|
|
434
440
|
(chunk) => {
|
|
435
|
-
const operations: Array<OperationInput> = chunk.map(
|
|
436
|
-
operationType: "Patch" as const,
|
|
437
|
-
id: doc.id,
|
|
438
|
-
resourceBody: [{ op: "set" as const, path: "/processed", value: true }]
|
|
439
|
-
}))
|
|
441
|
+
const operations: Array<OperationInput> = chunk.map(operation)
|
|
440
442
|
return Effect
|
|
441
443
|
.tryPromise(() => container.items.batch(operations, partitionKey))
|
|
442
444
|
.pipe(
|
|
443
445
|
Effect.tap(annotateItem),
|
|
444
446
|
Effect.flatMap((resp) => {
|
|
445
447
|
const failed = resp.result?.find((result) => !isSuccessfulStatus(result.statusCode))
|
|
446
|
-
return failed === undefined
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
})
|
|
448
|
+
return failed === undefined ? Effect.void : Effect.fail(failed.statusCode)
|
|
449
|
+
}),
|
|
450
|
+
Effect.catchIf(() => true, () => Effect.forEach(chunk, fallback, { discard: true }))
|
|
450
451
|
)
|
|
451
452
|
},
|
|
452
453
|
{ discard: true }
|
|
@@ -454,27 +455,50 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
454
455
|
{ discard: true }
|
|
455
456
|
)
|
|
456
457
|
|
|
457
|
-
const
|
|
458
|
-
doc:
|
|
459
|
-
|
|
460
|
-
): Effect.Effect<void, unknown> =>
|
|
461
|
-
Effect.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
458
|
+
const patchDoc = <A extends { readonly id: string; readonly _partitionKey: string }>(
|
|
459
|
+
doc: A,
|
|
460
|
+
resourceBody: PatchRequestBody
|
|
461
|
+
): Effect.Effect<void, unknown, never> =>
|
|
462
|
+
Effect.tryPromise(() => container.item(doc.id, doc._partitionKey).patch(resourceBody)).pipe(
|
|
463
|
+
Effect.tap(annotateItem),
|
|
464
|
+
Effect.asVoid,
|
|
465
|
+
Effect.catchIf(isNotFound, () => Effect.void)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
const patchDocs = <A extends { readonly id: string; readonly _partitionKey: string }>(
|
|
469
|
+
docs: ReadonlyArray<A>,
|
|
470
|
+
resourceBody: (doc: A) => PatchRequestBody
|
|
471
|
+
): Effect.Effect<void, unknown, never> =>
|
|
472
|
+
batchDocs(
|
|
473
|
+
docs,
|
|
474
|
+
(doc) => ({
|
|
475
|
+
operationType: "Patch" as const,
|
|
476
|
+
id: doc.id,
|
|
477
|
+
resourceBody: resourceBody(doc)
|
|
478
|
+
}),
|
|
479
|
+
(doc) => patchDoc(doc, resourceBody(doc))
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
const deleteDoc = <A extends { readonly id: string; readonly _partitionKey: string }>(
|
|
483
|
+
doc: A
|
|
484
|
+
): Effect.Effect<void, unknown, never> =>
|
|
485
|
+
Effect.tryPromise(() => container.item(doc.id, doc._partitionKey).delete()).pipe(
|
|
486
|
+
Effect.tap(annotateItem),
|
|
487
|
+
Effect.asVoid,
|
|
488
|
+
Effect.catchIf(isNotFound, () => Effect.void)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
const deleteDocs = <A extends { readonly id: string; readonly _partitionKey: string }>(
|
|
492
|
+
docs: ReadonlyArray<A>
|
|
493
|
+
): Effect.Effect<void, unknown, never> =>
|
|
494
|
+
batchDocs(
|
|
495
|
+
docs,
|
|
496
|
+
(doc) => ({
|
|
497
|
+
operationType: "Delete" as const,
|
|
498
|
+
id: doc.id
|
|
499
|
+
}),
|
|
500
|
+
deleteDoc
|
|
501
|
+
)
|
|
478
502
|
|
|
479
503
|
return yield* MessageStorage.makeEncoded({
|
|
480
504
|
saveEnvelope: ({ deliverAt, envelope, primaryKey }) =>
|
|
@@ -487,7 +511,7 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
487
511
|
"SELECT * FROM c WHERE c.type = 'message' AND c.kind = 'AckChunk' AND c.processed = false AND c.requestId = @requestId",
|
|
488
512
|
[{ name: "@requestId", value: envelope.requestId }]
|
|
489
513
|
)
|
|
490
|
-
yield*
|
|
514
|
+
yield* patchDocs(pendingAcks, () => [{ op: "set", path: "/processed", value: true }])
|
|
491
515
|
}
|
|
492
516
|
return yield* Effect.tryPromise(() => container.items.create(doc)).pipe(
|
|
493
517
|
Effect.tap(annotateItem),
|
|
@@ -525,17 +549,16 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
525
549
|
"SELECT * FROM c WHERE c.type = 'message' AND c.requestId = @requestId",
|
|
526
550
|
[{ name: "@requestId", value: reply.requestId }]
|
|
527
551
|
)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}, { discard: true })
|
|
552
|
+
const updatedMessages = reply._tag === "WithExit"
|
|
553
|
+
? messages
|
|
554
|
+
: messages.filter((message) => message.id === reply.requestId || message.kind === "Request")
|
|
555
|
+
yield* patchDocs(updatedMessages, () =>
|
|
556
|
+
reply._tag === "WithExit"
|
|
557
|
+
? [
|
|
558
|
+
{ op: "set", path: "/processed", value: true },
|
|
559
|
+
{ op: "set", path: "/lastReplyId", value: reply.id }
|
|
560
|
+
]
|
|
561
|
+
: [{ op: "set", path: "/lastReplyId", value: reply.id }])
|
|
539
562
|
})
|
|
540
563
|
.pipe(annotate("saveReply"), refailPersistence, withTracerDisabled),
|
|
541
564
|
|
|
@@ -549,29 +572,20 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
549
572
|
{ name: "@requestId", value: id }
|
|
550
573
|
]
|
|
551
574
|
)
|
|
552
|
-
yield*
|
|
553
|
-
Effect
|
|
554
|
-
.tryPromise(() => container.item(reply.id, reply._partitionKey).delete())
|
|
555
|
-
.pipe(
|
|
556
|
-
Effect.tap(annotateItem),
|
|
557
|
-
Effect.catchIf(isNotFound, () => Effect.void)
|
|
558
|
-
), { discard: true })
|
|
575
|
+
yield* deleteDocs(replies)
|
|
559
576
|
const messages = yield* queryMessages(
|
|
560
577
|
"SELECT * FROM c WHERE c.type = 'message' AND c.requestId = @requestId",
|
|
561
578
|
[{ name: "@requestId", value: id }]
|
|
562
579
|
)
|
|
563
|
-
yield*
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
message.lastRead = null
|
|
573
|
-
return replaceMessage(message).pipe(Effect.asVoid)
|
|
574
|
-
}, { discard: true })
|
|
580
|
+
yield* deleteDocs(messages.filter((message) => message.kind === "Interrupt"))
|
|
581
|
+
yield* patchDocs(
|
|
582
|
+
messages.filter((message) => message.kind !== "Interrupt"),
|
|
583
|
+
() => [
|
|
584
|
+
{ op: "set", path: "/processed", value: false },
|
|
585
|
+
{ op: "set", path: "/lastReplyId", value: null },
|
|
586
|
+
{ op: "set", path: "/lastRead", value: null }
|
|
587
|
+
]
|
|
588
|
+
)
|
|
575
589
|
})
|
|
576
590
|
.pipe(annotate("clearReplies"), refailPersistence, withTracerDisabled),
|
|
577
591
|
|
|
@@ -620,7 +634,7 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
620
634
|
]
|
|
621
635
|
)
|
|
622
636
|
.pipe(
|
|
623
|
-
Effect.flatMap((docs) => collectUnprocessed(docs, now,
|
|
637
|
+
Effect.flatMap((docs) => collectUnprocessed(docs, now, claimMessageRead, queryReplies)),
|
|
624
638
|
annotate("unprocessedMessages"),
|
|
625
639
|
refailPersistence,
|
|
626
640
|
withTracerDisabled
|
|
@@ -635,7 +649,7 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
635
649
|
]
|
|
636
650
|
)
|
|
637
651
|
.pipe(
|
|
638
|
-
Effect.flatMap((docs) =>
|
|
652
|
+
Effect.flatMap((docs) => collectUnprocessedById(docs, queryReplies)),
|
|
639
653
|
annotate("unprocessedMessagesById"),
|
|
640
654
|
refailPersistence,
|
|
641
655
|
withTracerDisabled
|
|
@@ -651,12 +665,7 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
651
665
|
]
|
|
652
666
|
)
|
|
653
667
|
.pipe(
|
|
654
|
-
Effect.flatMap((docs) =>
|
|
655
|
-
Effect.forEach(docs, (doc) => {
|
|
656
|
-
doc.lastRead = null
|
|
657
|
-
return replaceMessage(doc).pipe(Effect.asVoid)
|
|
658
|
-
}, { discard: true })
|
|
659
|
-
),
|
|
668
|
+
Effect.flatMap((docs) => patchDocs(docs, () => [{ op: "set", path: "/lastRead", value: null }])),
|
|
660
669
|
annotate("resetAddress"),
|
|
661
670
|
refailPersistence,
|
|
662
671
|
withTracerDisabled
|
|
@@ -671,37 +680,18 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
671
680
|
]
|
|
672
681
|
)
|
|
673
682
|
.pipe(
|
|
674
|
-
Effect.flatMap((messages) =>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
.
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
Effect
|
|
687
|
-
.forEach(replies, (reply) =>
|
|
688
|
-
Effect
|
|
689
|
-
.tryPromise(() =>
|
|
690
|
-
container.item(reply.id, reply._partitionKey).delete()
|
|
691
|
-
)
|
|
692
|
-
.pipe(
|
|
693
|
-
Effect.tap(annotateItem),
|
|
694
|
-
Effect.catchIf(isNotFound, () => Effect.void)
|
|
695
|
-
), { discard: true })
|
|
696
|
-
),
|
|
697
|
-
Effect.andThen(
|
|
698
|
-
Effect.tryPromise(() => container.item(message.id, message._partitionKey).delete()).pipe(
|
|
699
|
-
Effect.tap(annotateItem),
|
|
700
|
-
Effect.catchIf(isNotFound, () => Effect.void)
|
|
701
|
-
)
|
|
702
|
-
)
|
|
703
|
-
), { discard: true })
|
|
704
|
-
),
|
|
683
|
+
Effect.flatMap((messages) => {
|
|
684
|
+
if (!Arr.isArrayNonEmpty(messages)) return Effect.void
|
|
685
|
+
const requestIds = Array.from(new Set(messages.map((message) => message.requestId)))
|
|
686
|
+
return queryReplies(
|
|
687
|
+
"SELECT * FROM c WHERE c.type = 'reply' AND ARRAY_CONTAINS(@requestIds, c.requestId)",
|
|
688
|
+
[{ name: "@requestIds", value: requestIds }]
|
|
689
|
+
)
|
|
690
|
+
.pipe(
|
|
691
|
+
Effect.flatMap((replies) => deleteDocs(replies)),
|
|
692
|
+
Effect.andThen(deleteDocs(messages))
|
|
693
|
+
)
|
|
694
|
+
}),
|
|
705
695
|
annotate("clearAddress"),
|
|
706
696
|
refailPersistence,
|
|
707
697
|
withTracerDisabled
|
|
@@ -713,12 +703,7 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
713
703
|
[{ name: "@shardIds", value: Array.from(shardIds) }]
|
|
714
704
|
)
|
|
715
705
|
.pipe(
|
|
716
|
-
Effect.flatMap((docs) =>
|
|
717
|
-
Effect.forEach(docs, (doc) => {
|
|
718
|
-
doc.lastRead = null
|
|
719
|
-
return replaceMessage(doc).pipe(Effect.asVoid)
|
|
720
|
-
}, { discard: true })
|
|
721
|
-
),
|
|
706
|
+
Effect.flatMap((docs) => patchDocs(docs, () => [{ op: "set", path: "/lastRead", value: null }])),
|
|
722
707
|
annotate("resetShards"),
|
|
723
708
|
refailPersistence,
|
|
724
709
|
withTracerDisabled
|
|
@@ -731,8 +716,7 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: {
|
|
|
731
716
|
const collectUnprocessed = <E>(
|
|
732
717
|
docs: ReadonlyArray<MessageDoc>,
|
|
733
718
|
now: number,
|
|
734
|
-
|
|
735
|
-
replaceMessage: (doc: MessageDoc) => Effect.Effect<boolean, E>,
|
|
719
|
+
claimMessageRead: (doc: MessageDoc, now: number) => Effect.Effect<boolean, E>,
|
|
736
720
|
queryReplies: (
|
|
737
721
|
query: string,
|
|
738
722
|
parameters: ReadonlyArray<CosmosParameter>
|
|
@@ -743,22 +727,77 @@ const collectUnprocessed = <E>(
|
|
|
743
727
|
readonly envelope: Envelope.Encoded
|
|
744
728
|
readonly lastSentReply: Option.Option<Reply.Encoded>
|
|
745
729
|
}> = []
|
|
730
|
+
const activeRequestIds = yield* activeReplyRequestIds(docs, queryReplies)
|
|
731
|
+
const lastReplies = yield* lastRepliesById(docs, activeRequestIds, queryReplies)
|
|
746
732
|
for (const doc of docs) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
)
|
|
751
|
-
|
|
752
|
-
const sentReply = yield* lastReply(doc.lastReplyId)
|
|
753
|
-
doc.lastRead = now
|
|
754
|
-
const replaced = yield* replaceMessage(doc)
|
|
755
|
-
if (replaced) {
|
|
756
|
-
messages.push(envelopeFromDoc(doc, sentReply))
|
|
733
|
+
if (activeRequestIds.has(doc.requestId)) continue
|
|
734
|
+
const sentReply = Option.fromNullishOr(doc.lastReplyId === null ? undefined : lastReplies.get(doc.lastReplyId))
|
|
735
|
+
const claimed = yield* claimMessageRead(doc, now)
|
|
736
|
+
if (claimed) {
|
|
737
|
+
messages.push(envelopeFromDoc({ ...doc, lastRead: now }, sentReply))
|
|
757
738
|
}
|
|
758
739
|
}
|
|
759
740
|
return messages
|
|
760
741
|
})
|
|
761
742
|
|
|
743
|
+
const collectUnprocessedById = <E>(
|
|
744
|
+
docs: ReadonlyArray<MessageDoc>,
|
|
745
|
+
queryReplies: (
|
|
746
|
+
query: string,
|
|
747
|
+
parameters: ReadonlyArray<CosmosParameter>
|
|
748
|
+
) => Effect.Effect<Array<ReplyDoc>, E>
|
|
749
|
+
) =>
|
|
750
|
+
Effect.gen(function*() {
|
|
751
|
+
const messages: Array<{
|
|
752
|
+
readonly envelope: Envelope.Encoded
|
|
753
|
+
readonly lastSentReply: Option.Option<Reply.Encoded>
|
|
754
|
+
}> = []
|
|
755
|
+
const activeRequestIds = yield* activeReplyRequestIds(docs, queryReplies)
|
|
756
|
+
const lastReplies = yield* lastRepliesById(docs, activeRequestIds, queryReplies)
|
|
757
|
+
for (const doc of docs) {
|
|
758
|
+
if (activeRequestIds.has(doc.requestId)) continue
|
|
759
|
+
const sentReply = Option.fromNullishOr(doc.lastReplyId === null ? undefined : lastReplies.get(doc.lastReplyId))
|
|
760
|
+
messages.push(envelopeFromDoc(doc, sentReply))
|
|
761
|
+
}
|
|
762
|
+
return messages
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
const activeReplyRequestIds = <E>(
|
|
766
|
+
docs: ReadonlyArray<MessageDoc>,
|
|
767
|
+
queryReplies: (
|
|
768
|
+
query: string,
|
|
769
|
+
parameters: ReadonlyArray<CosmosParameter>
|
|
770
|
+
) => Effect.Effect<Array<ReplyDoc>, E>
|
|
771
|
+
) => {
|
|
772
|
+
const requestIds = Array.from(new Set(docs.map((doc) => doc.requestId)))
|
|
773
|
+
if (!Arr.isArrayNonEmpty(requestIds)) return Effect.succeed(new Set<string>())
|
|
774
|
+
return queryReplies(
|
|
775
|
+
"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))",
|
|
776
|
+
[{ name: "@requestIds", value: requestIds }]
|
|
777
|
+
)
|
|
778
|
+
.pipe(Effect.map((replies) => new Set(replies.map((reply) => reply.requestId))))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const lastRepliesById = <E>(
|
|
782
|
+
docs: ReadonlyArray<MessageDoc>,
|
|
783
|
+
activeRequestIds: ReadonlySet<string>,
|
|
784
|
+
queryReplies: (
|
|
785
|
+
query: string,
|
|
786
|
+
parameters: ReadonlyArray<CosmosParameter>
|
|
787
|
+
) => Effect.Effect<Array<ReplyDoc>, E>
|
|
788
|
+
) => {
|
|
789
|
+
const replyIds = Array.from(
|
|
790
|
+
new Set(docs
|
|
791
|
+
.flatMap((doc) => activeRequestIds.has(doc.requestId) || doc.lastReplyId === null ? [] : [doc.lastReplyId]))
|
|
792
|
+
)
|
|
793
|
+
if (!Arr.isArrayNonEmpty(replyIds)) return Effect.succeed(new Map<string, Reply.Encoded>())
|
|
794
|
+
return queryReplies(
|
|
795
|
+
"SELECT * FROM c WHERE c.type = 'reply' AND ARRAY_CONTAINS(@replyIds, c.rowid)",
|
|
796
|
+
[{ name: "@replyIds", value: replyIds }]
|
|
797
|
+
)
|
|
798
|
+
.pipe(Effect.map((replies) => new Map(replies.map((reply) => [reply.rowid, replyFromDoc(reply)]))))
|
|
799
|
+
}
|
|
800
|
+
|
|
762
801
|
export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
763
802
|
readonly prefix?: string | undefined
|
|
764
803
|
}) {
|
|
@@ -780,6 +819,52 @@ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
|
780
819
|
)
|
|
781
820
|
.pipe(Effect.tap(annotateFeed), Effect.map((resp) => resp.resources))
|
|
782
821
|
|
|
822
|
+
const deleteRunner = (doc: RunnerDoc) =>
|
|
823
|
+
Effect
|
|
824
|
+
.tryPromise(() =>
|
|
825
|
+
container.item(doc.id, "runner").delete({
|
|
826
|
+
accessCondition: { type: "IfMatch", condition: doc._etag ?? "" }
|
|
827
|
+
})
|
|
828
|
+
)
|
|
829
|
+
.pipe(
|
|
830
|
+
Effect.tap(annotateItem),
|
|
831
|
+
Effect.asVoid,
|
|
832
|
+
Effect.catchIf(isNotFound, () => Effect.void),
|
|
833
|
+
Effect.catchIf(isPreconditionFailed, () => Effect.void)
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
const readRunner = (address: string) =>
|
|
837
|
+
Effect.tryPromise(() => container.item(runnerDocId(address), "runner").read<RunnerDoc>()).pipe(
|
|
838
|
+
Effect.tap(annotateItem),
|
|
839
|
+
Effect.map((resp) => Option.fromNullishOr(resp.resource)),
|
|
840
|
+
Effect.catchIf(isNotFound, () => Effect.succeed(Option.none()))
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
// Allocate a unique, monotonically increasing machine id via an atomic
|
|
844
|
+
// server-side `incr` on a single counter doc — mirrors SQL's auto-increment
|
|
845
|
+
// `machine_id` PK. A per-address string hash (the previous approach) could
|
|
846
|
+
// collide mod 1024 across distinct runners, yielding duplicate Snowflake ids.
|
|
847
|
+
// The counter doc is seeded at init below, so it always exists here.
|
|
848
|
+
const allocateMachineId = Effect
|
|
849
|
+
.tryPromise(() =>
|
|
850
|
+
container.item(machineIdCounterDocId, "runner").patch<MachineIdCounterDoc>([
|
|
851
|
+
{ op: "incr", path: "/value", value: 1 }
|
|
852
|
+
])
|
|
853
|
+
)
|
|
854
|
+
.pipe(Effect.tap(annotateItem), Effect.map((resp) => resp.resource!.value))
|
|
855
|
+
|
|
856
|
+
// Seed the counter once (create-if-missing); a lost create race is fine.
|
|
857
|
+
yield* Effect
|
|
858
|
+
.tryPromise(() =>
|
|
859
|
+
container.items.create<MachineIdCounterDoc>({
|
|
860
|
+
id: machineIdCounterDocId,
|
|
861
|
+
_partitionKey: "runner",
|
|
862
|
+
type: "machine-id-counter",
|
|
863
|
+
value: 0
|
|
864
|
+
})
|
|
865
|
+
)
|
|
866
|
+
.pipe(Effect.catchIf(isConflict, () => Effect.void), Effect.orDie)
|
|
867
|
+
|
|
783
868
|
const readLock = (shardId: string) =>
|
|
784
869
|
Effect.tryPromise(() => container.item(lockDocId(shardId), "lock").read<LockDoc>()).pipe(
|
|
785
870
|
Effect.tap(annotateItem),
|
|
@@ -800,6 +885,20 @@ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
|
800
885
|
Effect.catchIf(isPreconditionFailed, () => Effect.succeed(false))
|
|
801
886
|
)
|
|
802
887
|
|
|
888
|
+
const deleteLock = (doc: LockDoc) =>
|
|
889
|
+
Effect
|
|
890
|
+
.tryPromise(() =>
|
|
891
|
+
container.item(doc.id, "lock").delete({
|
|
892
|
+
accessCondition: { type: "IfMatch", condition: doc._etag ?? "" }
|
|
893
|
+
})
|
|
894
|
+
)
|
|
895
|
+
.pipe(
|
|
896
|
+
Effect.tap(annotateItem),
|
|
897
|
+
Effect.asVoid,
|
|
898
|
+
Effect.catchIf(isNotFound, () => Effect.void),
|
|
899
|
+
Effect.catchIf(isPreconditionFailed, () => Effect.void)
|
|
900
|
+
)
|
|
901
|
+
|
|
803
902
|
const createLock = (address: string, shardId: string, now: number) =>
|
|
804
903
|
Effect
|
|
805
904
|
.tryPromise(() =>
|
|
@@ -850,9 +949,17 @@ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
|
850
949
|
),
|
|
851
950
|
|
|
852
951
|
register: (address, runner, healthy) =>
|
|
853
|
-
Effect
|
|
854
|
-
|
|
855
|
-
|
|
952
|
+
Effect
|
|
953
|
+
.gen(function*() {
|
|
954
|
+
const now = Date.now()
|
|
955
|
+
// Reuse a previously-allocated machine id for this address (stable per
|
|
956
|
+
// address, like SQL's RETURNING on conflict); allocate a fresh unique
|
|
957
|
+
// one on first registration.
|
|
958
|
+
const existing = yield* readRunner(address)
|
|
959
|
+
const machineId = Option.isSome(existing) && existing.value.machineId !== undefined
|
|
960
|
+
? existing.value.machineId
|
|
961
|
+
: yield* allocateMachineId
|
|
962
|
+
yield* Effect
|
|
856
963
|
.tryPromise(() =>
|
|
857
964
|
container.items.upsert<RunnerDoc>({
|
|
858
965
|
id: runnerDocId(address),
|
|
@@ -861,40 +968,47 @@ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
|
861
968
|
address,
|
|
862
969
|
runner,
|
|
863
970
|
healthy,
|
|
864
|
-
lastHeartbeat: now
|
|
971
|
+
lastHeartbeat: now,
|
|
972
|
+
machineId
|
|
865
973
|
})
|
|
866
974
|
)
|
|
867
975
|
.pipe(Effect.tap(annotateItem))
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
annotate("register"),
|
|
871
|
-
refailPersistence,
|
|
872
|
-
withTracerDisabled
|
|
873
|
-
),
|
|
976
|
+
return machineId
|
|
977
|
+
})
|
|
978
|
+
.pipe(annotate("register"), refailPersistence, withTracerDisabled),
|
|
874
979
|
|
|
875
980
|
unregister: (address) =>
|
|
876
|
-
Effect.
|
|
877
|
-
Effect.
|
|
878
|
-
|
|
981
|
+
Effect.sync(() => Date.now()).pipe(
|
|
982
|
+
Effect.flatMap((now) =>
|
|
983
|
+
queryRunners(
|
|
984
|
+
"SELECT * FROM c WHERE c.type = 'runner' AND (c.address = @address OR c.lastHeartbeat < @expiresAt)",
|
|
985
|
+
[
|
|
986
|
+
{ name: "@address", value: address },
|
|
987
|
+
{ name: "@expiresAt", value: now - expires }
|
|
988
|
+
]
|
|
989
|
+
)
|
|
990
|
+
),
|
|
991
|
+
Effect.flatMap((docs) => Effect.forEach(docs, deleteRunner, { discard: true })),
|
|
879
992
|
annotate("unregister"),
|
|
880
993
|
refailPersistence,
|
|
881
994
|
withTracerDisabled
|
|
882
995
|
),
|
|
883
996
|
|
|
884
997
|
setRunnerHealth: (address, healthy) =>
|
|
885
|
-
Effect
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
998
|
+
Effect
|
|
999
|
+
.tryPromise(() =>
|
|
1000
|
+
container.item(runnerDocId(address), "runner").patch<RunnerDoc>([
|
|
1001
|
+
{ op: "set", path: "/healthy", value: healthy }
|
|
1002
|
+
])
|
|
1003
|
+
)
|
|
1004
|
+
.pipe(
|
|
1005
|
+
Effect.tap(annotateItem),
|
|
1006
|
+
Effect.asVoid,
|
|
1007
|
+
Effect.catchIf(isNotFound, () => Effect.void),
|
|
1008
|
+
annotate("setRunnerHealth"),
|
|
1009
|
+
refailPersistence,
|
|
1010
|
+
withTracerDisabled
|
|
1011
|
+
),
|
|
898
1012
|
|
|
899
1013
|
acquire: (address, shardIds) =>
|
|
900
1014
|
Effect.sync(() => Date.now()).pipe(
|
|
@@ -909,17 +1023,17 @@ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
|
909
1023
|
Effect
|
|
910
1024
|
.gen(function*() {
|
|
911
1025
|
const now = Date.now()
|
|
912
|
-
yield* Effect
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
)
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1026
|
+
yield* Effect
|
|
1027
|
+
.tryPromise(() =>
|
|
1028
|
+
container.item(runnerDocId(address), "runner").patch<RunnerDoc>([
|
|
1029
|
+
{ op: "set", path: "/lastHeartbeat", value: now }
|
|
1030
|
+
])
|
|
1031
|
+
)
|
|
1032
|
+
.pipe(
|
|
1033
|
+
Effect.tap(annotateItem),
|
|
1034
|
+
Effect.asVoid,
|
|
1035
|
+
Effect.catchIf(isNotFound, () => Effect.void)
|
|
1036
|
+
)
|
|
923
1037
|
const refreshed = yield* Effect.forEach(shardIds, (shardId) =>
|
|
924
1038
|
readLock(shardId).pipe(
|
|
925
1039
|
Effect.flatMap((lock) =>
|
|
@@ -944,11 +1058,7 @@ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
|
944
1058
|
onNone: () => Effect.void,
|
|
945
1059
|
onSome: (doc) =>
|
|
946
1060
|
doc.address === address
|
|
947
|
-
?
|
|
948
|
-
Effect.tap(annotateItem),
|
|
949
|
-
Effect.catchIf(isNotFound, () => Effect.void),
|
|
950
|
-
Effect.asVoid
|
|
951
|
-
)
|
|
1061
|
+
? deleteLock(doc)
|
|
952
1062
|
: Effect.void
|
|
953
1063
|
})
|
|
954
1064
|
),
|
|
@@ -970,13 +1080,7 @@ export const makeRunnerStorage = Effect.fnUntraced(function*(options?: {
|
|
|
970
1080
|
)
|
|
971
1081
|
.pipe(
|
|
972
1082
|
Effect.tap(annotateFeed),
|
|
973
|
-
Effect.flatMap((resp) =>
|
|
974
|
-
Effect.forEach(resp.resources, (doc) =>
|
|
975
|
-
Effect.tryPromise(() => container.item(doc.id, "lock").delete()).pipe(
|
|
976
|
-
Effect.tap(annotateItem),
|
|
977
|
-
Effect.catchIf(isNotFound, () => Effect.void)
|
|
978
|
-
), { discard: true })
|
|
979
|
-
),
|
|
1083
|
+
Effect.flatMap((resp) => Effect.forEach(resp.resources, deleteLock, { discard: true })),
|
|
980
1084
|
annotate("releaseAll"),
|
|
981
1085
|
refailPersistence,
|
|
982
1086
|
withTracerDisabled
|