1import { assert, describe, expect, it } from "@effect/vitest"
2import {
3 Cause,
4 Context,
5 DateTime,
6 Duration,
7 Effect,
8 Exit,
9 Fiber,
10 Latch,
11 Layer,
12 Logger,
13 Option,
14 Result,
15 Schema,
16 Scope,
17 Tracer
18} from "effect"
19import {
20 ClusterError,
21 ClusterSchema,
22 ClusterWorkflowEngine,
23 Entity,
24 MessageStorage,
25 RunnerHealth,
26 Runners,
27 RunnerStorage,
28 Sharding,
29 ShardingConfig,
30 Snowflake
31} from "effect/cluster"
32import { CurrentActivationScope } from "effect/cluster/internal/entityActivation"
33import { Rpc } from "effect/rpc"
34import { TestClock } from "effect/testing"
35import { Activity, DurableClock, DurableDeferred, Workflow } from "effect/workflow"
36import {
37 makeUnsafe as makeWorkflowEngineUnsafe,
38 WorkflowEngine,
39 WorkflowInstance
40} from "effect/workflow/WorkflowEngine"
41
42describe.concurrent("ClusterWorkflowEngine", () => {
43 it.effect("does not replay for an unawaited deferred checkpoint", () =>
44 Effect.gen(function*() {
45 const checkpoint = DurableDeferred.make("UnawaitedCheckpoint/Checkpoint")
46 const gate = DurableDeferred.make("UnawaitedCheckpoint/Gate", { success: Schema.String })
47 const workflow = Workflow.make("UnawaitedCheckpoint", {
48 payload: {},
49 success: Schema.String,
50 idempotencyKey: () => "one"
51 })
52 let activations = 0
53 const context = yield* Layer.build(
54 workflow.toLayer(Effect.fnUntraced(function*(_payload, executionId) {
55 activations++
56 const engine = yield* WorkflowEngine
57 if (Option.isNone(yield* engine.deferredResult(checkpoint))) {
58 yield* DurableDeferred.succeed(checkpoint, {
59 token: DurableDeferred.tokenFromExecutionId(checkpoint, { workflow, executionId }),
60 value: undefined
61 })
62 }
63 return yield* DurableDeferred.await(gate)
64 })).pipe(Layer.provideMerge(makeTestWorkflowEngine()))
65 )
66 yield* Effect.gen(function*() {
67 const executionId = yield* workflow.execute({}, { discard: true })
68 yield* pollUntil(workflow, executionId, "Suspended")
69 const beforeRelease = activations
70 yield* DurableDeferred.succeed(gate, {
71 token: DurableDeferred.tokenFromExecutionId(gate, { workflow, executionId }),
72 value: "done"
73 })
74 assert.deepStrictEqual(
75 yield* pollUntil(workflow, executionId, "Complete"),
76 new Workflow.Complete({ exit: Exit.succeed("done") })
77 )
78 assert.deepStrictEqual({ beforeRelease, afterRelease: activations }, { beforeRelease: 1, afterRelease: 2 })
79 }).pipe(Effect.provide(context))
80 }))
81
82 it.effect("retries a deferred wake after a transient run reset failure", () =>
83 Effect.gen(function*() {
84 const gate = DurableDeferred.make("ResetRetry/Gate", { success: Schema.String })
85 const workflow = Workflow.make("ResetRetry", {
86 payload: {},
87 success: Schema.String,
88 idempotencyKey: () => "one"
89 })
90 const shared = yield* Layer.build(
91 MessageStorage.layerMemory.pipe(Layer.provide(ShardingConfig.layerDefaults))
92 )
93 const storage = Context.get(shared, MessageStorage.MessageStorage)
94 const driver = Context.get(shared, MessageStorage.MemoryDriver)
95 let runRequestId: string | undefined
96 let resetAttempts = 0
97 const storageLayer = Layer.succeed(MessageStorage.MessageStorage, {
98 ...storage,
99 clearReplies: (requestId) => {
100 if (String(requestId) !== runRequestId) return storage.clearReplies(requestId)
101 resetAttempts++
102 return resetAttempts === 1
103 ? Effect.fail(new ClusterError.PersistenceError({ cause: "transient reset failure" }))
104 : storage.clearReplies(requestId)
105 }
106 })
107 const context = yield* Layer.build(
108 workflow.toLayer(() => DurableDeferred.await(gate)).pipe(
109 Layer.provideMerge(makeTestWorkflowEngine({ storageLayer }))
110 )
111 )
112 yield* Effect.gen(function*() {
113 const executionId = yield* workflow.execute({}, { discard: true })
114 yield* pollUntil(workflow, executionId, "Suspended")
115 const run = driver.journal.find((message) => message._tag === "Request" && message.tag === "run")
116 assert(run?._tag === "Request")
117 runRequestId = run.requestId
118
119 const completion = yield* DurableDeferred.succeed(gate, {
120 token: DurableDeferred.tokenFromExecutionId(gate, { workflow, executionId }),
121 value: "signal"
122 }).pipe(Effect.forkChild({ startImmediately: true }))
123 yield* advanceUntil(() => resetAttempts >= 2, "deferred wake must retry the failed reset", 200, 20)
124 yield* Fiber.join(completion)
125 assert.deepStrictEqual(
126 yield* workflow.poll(executionId),
127 Option.some(new Workflow.Complete({ exit: Exit.succeed("signal") }))
128 )
129 }).pipe(Effect.provide(context))
130 }), 20_000)
131
132 it.effect("keeps a completed reply when an older concurrent wake resets it", () =>
133 Effect.gen(function*() {
134 const firstReset = yield* Latch.make()
135 const allowStaleReset = yield* Latch.make()
136 const staleResetFinished = yield* Latch.make()
137 const allowPoll = yield* Latch.make()
138 const first = DurableDeferred.make("ConcurrentResume/First", { success: Schema.String })
139 const second = DurableDeferred.make("ConcurrentResume/Second", { success: Schema.String })
140 const workflow = Workflow.make("ConcurrentResume", {
141 payload: {},
142 success: Schema.String,
143 idempotencyKey: () => "one"
144 })
145 const shared = yield* Layer.build(
146 MessageStorage.layerMemory.pipe(Layer.provide(ShardingConfig.layerDefaults))
147 )
148 const storage = Context.get(shared, MessageStorage.MessageStorage)
149 const driver = Context.get(shared, MessageStorage.MemoryDriver)
150 let runRequestId: Snowflake.Snowflake | undefined
151 let runResets = 0
152 const storageLayer = Layer.succeed(MessageStorage.MessageStorage, {
153 ...storage,
154 clearReplies: (id, options) =>
155 id === runRequestId && options?.expectedReplyId !== undefined && ++runResets === 1
156 ? firstReset.open.pipe(
157 Effect.andThen(allowStaleReset.await),
158 Effect.andThen(storage.clearReplies(id, options)),
159 Effect.andThen(staleResetFinished.open),
160 // Do not let the stale wake poll storage and replay before inspecting the reply.
161 Effect.andThen(allowPoll.await)
162 )
163 : storage.clearReplies(id, options)
164 })
165 const context = yield* Layer.build(
166 workflow.toLayer(() =>
167 Effect.all([DurableDeferred.await(first), DurableDeferred.await(second)], { concurrency: "unbounded" }).pipe(
168 Effect.map(([a, b]) => a + "/" + b)
169 )
170 ).pipe(Layer.provideMerge(makeTestWorkflowEngine({ storageLayer })))
171 )
172 yield* Effect.addFinalizer(() => Effect.all([allowStaleReset.open, allowPoll.open], { discard: true }))
173 yield* Effect.gen(function*() {
174 const executionId = yield* workflow.execute({}, { discard: true })
175 yield* pollUntil(workflow, executionId, "Suspended")
176 const run = driver.journal.find((message) => message._tag === "Request" && message.tag === "run")!
177 runRequestId = Snowflake.Snowflake(run.requestId)
178 const complete = (deferred: typeof first, value: string) =>
179 DurableDeferred.succeed(deferred, {
180 token: DurableDeferred.tokenFromExecutionId(deferred, { workflow, executionId }),
181 value
182 })
183 const firstFiber = yield* complete(first, "first").pipe(Effect.forkChild({ startImmediately: true }))
184 yield* advanceUntil(() => firstReset.isOpen(), "first wake must reach reset")
185 yield* complete(second, "second")
186 assert.deepStrictEqual(
187 yield* pollUntil(workflow, executionId, "Complete"),
188 new Workflow.Complete({ exit: Exit.succeed("first/second") })
189 )
190 yield* allowStaleReset.open
191 yield* advanceUntil(() => staleResetFinished.isOpen(), "stale reset must finish")
192 assert.deepStrictEqual(
193 yield* workflow.poll(executionId),
194 Option.some(new Workflow.Complete({ exit: Exit.succeed("first/second") }))
195 )
196 yield* allowPoll.open
197 yield* Fiber.join(firstFiber)
198 }).pipe(Effect.provide(context))
199 }), 30_000)
200
201 for (const entityMailboxCapacity of [2, 3]) {
202 it.effect(
203 `admits a required completion after an unrelated completion with mailbox capacity ${entityMailboxCapacity}`,
204 () =>
205 Effect.gen(function*() {
206 const unrelatedRead = yield* Latch.make()
207 const required = DurableDeferred.make("MailboxProgress/Required", { success: Schema.String })
208 const unrelated = DurableDeferred.make("MailboxProgress/Unrelated", { success: Schema.String })
209 const workflow = Workflow.make("MailboxProgress", {
210 payload: {},
211 success: Schema.String,
212 idempotencyKey: () => "one"
213 })
214 let instance: WorkflowInstance["Service"] | undefined
215 let runRequestId: Snowflake.Snowflake | undefined
216 const shared = yield* Layer.build(
217 MessageStorage.layerMemory.pipe(Layer.provide(ShardingConfig.layerDefaults))
218 )
219 const storage = Context.get(shared, MessageStorage.MessageStorage)
220 const driver = Context.get(shared, MessageStorage.MemoryDriver)
221 const storageLayer = Layer.succeed(MessageStorage.MessageStorage, {
222 ...storage,
223 repliesForUnfiltered: (requestIds) => {
224 const ids = Array.from(requestIds)
225 return storage.repliesForUnfiltered(ids).pipe(
226 Effect.tap(() =>
227 runRequestId !== undefined && ids.includes(runRequestId) ? unrelatedRead.open : Effect.void
228 )
229 )
230 }
231 })
232 const context = yield* Layer.build(
233 workflow.toLayer(() =>
234 Effect.gen(function*() {
235 instance = yield* WorkflowInstance
236 return yield* DurableDeferred.raceAll({
237 name: "mailbox-progress",
238 success: Schema.String,
239 error: Schema.Never,
240 // Keep the run active without allocating an activity RPC/mailbox slot.
241 effects: [DurableDeferred.await(required), Effect.never]
242 })
243 })
244 ).pipe(Layer.provideMerge(makeTestWorkflowEngine({ storageLayer, config: { entityMailboxCapacity } })))
245 )
246 yield* Effect.gen(function*() {
247 const sharding = yield* Sharding.Sharding
248 const executionId = yield* workflow.execute({}, { discard: true })
249 yield* advanceUntil(
250 () => instance?.awaitedDeferreds.has(required.name) === true,
251 "run must await the required deferred"
252 )
253 assert.isFalse(instance!.awaitedDeferreds.has(unrelated.name))
254 const run = driver.journal.find((message) => message._tag === "Request" && message.tag === "run")!
255 runRequestId = Snowflake.Snowflake(run.requestId)
256 yield* DurableDeferred.succeed(unrelated, {
257 token: DurableDeferred.tokenFromExecutionId(unrelated, { workflow, executionId }),
258 value: "unrelated"
259 })
260 // Both implementations read the active run before replying or parking.
261 // Wait for that read before admitting the completion needed for progress.
262 yield* advanceUntil(() => unrelatedRead.isOpen(), "unrelated completion must inspect the active run")
263 yield* DurableDeferred.succeed(required, {
264 token: DurableDeferred.tokenFromExecutionId(required, { workflow, executionId }),
265 value: "signal"
266 })
267 let result = yield* workflow.poll(executionId)
268 for (let i = 0; i < 100 && !(Option.isSome(result) && result.value._tag === "Complete"); i++) {
269 yield* TestClock.adjust(100)
270 yield* sharding.pollStorage
271 result = yield* workflow.poll(executionId)
272 }
273 const pendingDeferreds = Array.from(driver.requests.values()).filter((entry) =>
274 entry.envelope._tag === "Request" && entry.envelope.tag === "deferred" && entry.replies.length === 0
275 ).map((entry) => (entry.envelope as { payload: { name: string } }).payload.name)
276 assert.deepStrictEqual(
277 result,
278 Option.some(new Workflow.Complete({ exit: Exit.succeed("signal") })),
279 `required completion must progress; pending deferreds: ${pendingDeferreds.join(", ")}`
280 )
281 }).pipe(Effect.provide(context))
282 }),
283 20_000
284 )
285 }
286
287 it.effect("retains a handover completion before its deferred reply is persisted", () =>
288 Effect.gen(function*() {
289 const gate = DurableDeferred.make("DeferredHandover/Gate", { success: Schema.String })
290 const workflow = Workflow.make("DeferredHandover", {
291 payload: {},
292 success: Schema.String,
293 idempotencyKey: () => "one"
294 })
295 const shared = yield* Layer.build(
296 MessageStorage.layerMemory.pipe(Layer.provide(ShardingConfig.layerDefaults))
297 )
298 const storage = Context.get(shared, MessageStorage.MessageStorage)
299 const layer = workflow.toLayer(() => DurableDeferred.await(gate)).pipe(
300 Layer.provideMerge(makeTestWorkflowEngine({
301 storageLayer: Layer.succeed(MessageStorage.MessageStorage, {
302 ...storage,
303 saveReply: (reply) =>
304 reply.rpc._tag === "deferred"
305 ? Effect.sleep(100).pipe(Effect.andThen(storage.saveReply(reply)))
306 : storage.saveReply(reply)
307 })
308 }))
309 )
310 // Each owner has a fresh engine over the same durable storage.
311 const withOwner = <A, E>(body: Effect.Effect<A, E, Layer.Success<typeof layer>>) =>
312 Effect.scoped(Effect.gen(function*() {
313 const context = yield* Layer.build(layer)
314 return yield* Effect.provide(body, context)
315 }))
316 const executionId = yield* withOwner(Effect.gen(function*() {
317 const id = yield* workflow.execute({}, { discard: true })
318 yield* pollUntil(workflow, id, "Suspended")
319 return id
320 }))
321 const result = yield* withOwner(Effect.gen(function*() {
322 yield* DurableDeferred.succeed(gate, {
323 token: DurableDeferred.tokenFromExecutionId(gate, { workflow, executionId }),
324 value: "signal"
325 })
326 // Complete before the 5-second storage retry can hide a lost wake-up.
327 const result = yield* pollUntil(workflow, executionId, "Complete")
328 yield* TestClock.adjust(1000)
329 return result
330 }))
331 assert.deepStrictEqual(result, new Workflow.Complete({ exit: Exit.succeed("signal") }))
332 }), 30_000)
333
334 for (const lifecycle of ["defect rebuild", "overlapping activations"] as const) {
335 it.effect(`completes before deferred persistence across ${lifecycle}`, () =>
336 Effect.gen(function*() {
337 const saving = yield* Latch.make()
338 const allowSave = yield* Latch.make()
339 const rebuilt = yield* Latch.make()
340 const allowRun = yield* Latch.make()
341 const allowDefect = yield* Latch.make()
342 const closing = yield* Latch.make()
343 const allowClose = yield* Latch.make()
344 const closed = yield* Latch.make()
345 const allowRedelivery = yield* Latch.make()
346 let persisted = false
347 let builds = 0
348 let firstBuildRuns = 0
349 let deferredDeliveries = 0
350 const activations: Array<Scope.Scope> = []
351 const gate = DurableDeferred.make("BehaviouralLifecycle/Gate", { success: Schema.String })
352 const workflow = Workflow.make("BehaviouralLifecycle", {
353 payload: {},
354 success: Schema.String,
355 idempotencyKey: () => "one"
356 })
357 const shardingLayer = Layer.effect(
358 Sharding.Sharding,
359 Effect.map(Sharding.Sharding, (sharding) => ({
360 ...sharding,
361 registerEntity: (entity, handlers, options) =>
362 sharding.registerEntity(
363 entity,
364 Effect.gen(function*() {
365 if (entity.type !== `Workflow/${workflow._tag}`) return yield* handlers
366 const build = ++builds
367 const activationOption = yield* Effect.serviceOption(CurrentActivationScope)
368 assert(Option.isSome(activationOption), "the entity manager must provide its activation")
369 const activation = activationOption.value
370 activations.push(activation)
371 assert.notStrictEqual(activation, yield* Effect.scope, "handler and activation scopes must differ")
372 // Registered before the production handlers, this marks the end of activation cleanup.
373 if (build === 1) yield* Scope.addFinalizer(activation, closed.open)
374 const built = yield* handlers
375 if (build === 1 && lifecycle === "overlapping activations") {
376 // Pause the retiring activation before its production cleanup. Its replacement
377 // records the completion first, then the old activation finishes closing.
378 yield* Scope.addFinalizer(activation, closing.open.pipe(Effect.andThen(allowClose.await)))
379 }
380 if (build === 2) yield* rebuilt.open
381 const { deferred, run } = built as unknown as {
382 run: (request: Entity.Request<any>) => Effect.Effect<any, any, any>
383 deferred: (request: Entity.Request<any>) => Effect.Effect<any, any, any>
384 }
385 return {
386 ...built,
387 run: (request: Entity.Request<any>) =>
388 Effect.suspend(() => {
389 if (build === 1 && ++firstBuildRuns === 2 && lifecycle === "defect rebuild") {
390 // Defect outside Workflow.intoResult to rebuild the real RPC server.
391 return saving.await.pipe(
392 Effect.andThen(allowDefect.await),
393 Effect.andThen(Effect.die("injected entity defect"))
394 )
395 }
396 return build === 2 ? allowRun.await.pipe(Effect.andThen(run(request))) : run(request)
397 }),
398 deferred: (request: Entity.Request<any>) =>
399 Effect.suspend(() => {
400 // A replayed completion must not repair a cache loss before the assertion.
401 if (++deferredDeliveries > 1) {
402 return allowRedelivery.await.pipe(Effect.andThen(deferred(request)))
403 }
404 return deferred(request)
405 })
406 }
407 }),
408 options
409 )
410 }))
411 ).pipe(Layer.provide(Sharding.layer))
412 const storageLayer = Layer.effect(
413 MessageStorage.MessageStorage,
414 Effect.map(
415 MessageStorage.MessageStorage,
416 (storage) => ({
417 ...storage,
418 saveReply: (reply) =>
419 reply.rpc._tag === "deferred"
420 ? saving.open.pipe(
421 Effect.andThen(allowSave.await),
422 Effect.interruptible,
423 Effect.andThen(storage.saveReply(reply)),
424 Effect.tap(() =>
425 Effect.sync(() => {
426 persisted = true
427 })
428 )
429 )
430 : storage.saveReply(reply)
431 })
432 )
433 ).pipe(Layer.provide(MessageStorage.layerMemory))
434 const context = yield* Layer.build(
435 workflow.toLayer(() => DurableDeferred.await(gate)).pipe(
436 Layer.provideMerge(makeTestWorkflowEngine({ shardingLayer, storageLayer }))
437 )
438 )
439 yield* Effect.addFinalizer(() =>
440 Effect.all([
441 allowSave.open,
442 allowRun.open,
443 allowDefect.open,
444 allowClose.open,
445 allowRedelivery.open
446 ], { discard: true })
447 )
448 yield* Effect.gen(function*() {
449 const executionId = yield* workflow.execute({}, { discard: true })
450 yield* pollUntil(workflow, executionId, "Suspended")
451 if (lifecycle === "overlapping activations") {
452 yield* advanceUntil(() => closing.isOpen(), "old activation must start closing", 5000, 12)
453 assert.strictEqual(builds, 1)
454 }
455 yield* DurableDeferred.succeed(gate, {
456 token: DurableDeferred.tokenFromExecutionId(gate, { workflow, executionId }),
457 value: "signal"
458 })
459 yield* advanceUntil(() => saving.isOpen(), "deferred persistence must start")
460 yield* allowDefect.open
461 yield* advanceUntil(() => rebuilt.isOpen(), "replacement handlers must be built")
462 assert.strictEqual(builds, 2)
463 if (lifecycle === "defect rebuild") {
464 assert.strictEqual(activations[0], activations[1], "rebuild must retain activation identity")
465 assert.isFalse(closed.isOpen(), "a handler rebuild must not close its activation")
466 } else {
467 assert.notStrictEqual(activations[0], activations[1], "replacement must have its own activation")
468 yield* allowClose.open
469 yield* advanceUntil(() => closed.isOpen(), "old activation cleanup must finish")
470 }
471 yield* allowRun.open
472 const result = yield* pollUntil(workflow, executionId, "Complete")
473 assert.deepStrictEqual(result, new Workflow.Complete({ exit: Exit.succeed("signal") }))
474 assert.isFalse(allowSave.isOpen(), "workflow must complete while persistence remains gated")
475 assert.isFalse(persisted, "no deferred reply may be durable before workflow completion")
476 assert.isFalse(allowRedelivery.isOpen(), "redelivery must not repair a lost completion")
477 }).pipe(Effect.provide(context))
478 }), 30_000)
479 }
480
481 it.effect("executes, resumes, deduplicates, and polls a suspended workflow", () =>
482 Effect.gen(function*() {
483 const sharding = yield* Sharding.Sharding
484 const driver = yield* MessageStorage.MemoryDriver
485 const flags = yield* Flags
486
487 const fiber = yield* EmailWorkflow.execute({
488 id: "test-email-1",
489 to: "bob@example.com"
490 }).pipe(Effect.forkChild({ startImmediately: true }))
491
492 // resume after the clock
493 yield* TestClock.adjust("10 seconds")
494 yield* sharding.pollStorage
495 yield* TestClock.adjust(5000)
496 // The storage poll may return before the resumed run finishes unwinding.
497 yield* advanceUntil(() => flags.get("ensuring") === true, "suspended await must run ensuring")
498
499 // --- the workflow is suspended at this point
500
501 // - 1 initial request
502 // - 5 attempts to send email
503 // - 1 sleep activity
504 // - 1 durable clock run
505 // - 1 durable clock deferred set
506 expect(driver.requests.size).toEqual(9)
507 const executionId = driver.journal[0].address.entityId
508
509 // normal finalizer should run even after suspension
510 expect(flags.get("finalizer")).toBeTruthy()
511 // but not compensation
512 expect(flags.get("compensation")).toBeFalsy()
513 // ensuring will run
514 expect(flags.get("ensuring")).toBeTruthy()
515 expect(flags.get("catchCause")).toBeFalsy()
516
517 // --- resume the workflow using DurableDeferred.done
518
519 const token = yield* DurableDeferred.token(EmailTrigger).pipe(
520 Effect.provideService(WorkflowInstance, WorkflowInstance.initial(EmailWorkflow, executionId))
521 )
522 yield* DurableDeferred.done(EmailTrigger, {
523 token,
524 exit: Exit.succeed("done")
525 })
526 yield* sharding.pollStorage
527
528 // - 1 DurableDeferred set
529 expect(driver.requests.size).toEqual(10)
530
531 // allow suspend polling to complete
532 yield* TestClock.adjust(10000)
533 expect(yield* Fiber.join(fiber)).toBeUndefined()
534
535 // --- the workflow is complete
536
537 // ensuring will run
538 expect(flags.get("ensuring")).toBeTruthy()
539 expect(flags.get("catchCause")).toBeFalsy()
540
541 // test deduplication
542 yield* EmailWorkflow.execute({
543 id: "test-email-1",
544 to: "bob@example.com"
545 })
546 expect(driver.requests.size).toEqual(10)
547
548 // test poll
549 expect(yield* EmailWorkflow.poll(executionId)).toEqual(Option.some(new Workflow.Complete({ exit: Exit.void })))
550 }).pipe(Effect.provide(TestWorkflowLayer)))
551
552 it.effect("interrupts a suspended workflow and runs compensation", () =>
553 Effect.gen(function*() {
554 const sharding = yield* Sharding.Sharding
555 const driver = yield* MessageStorage.MemoryDriver
556 yield* TestClock.adjust(1)
557
558 const fiber = yield* EmailWorkflow.execute({
559 id: "test-email-2",
560 to: "bob@example.com"
561 }).pipe(Effect.forkChild)
562
563 yield* TestClock.adjust(1)
564 yield* TestClock.adjust("10 seconds")
565 yield* sharding.pollStorage
566 yield* TestClock.adjust(1)
567
568 const envelope = driver.journal[0]
569 const executionId = envelope.address.entityId
570 // Interrupt after the clock-backed activity completes, so the workflow is
571 // suspended on EmailTrigger rather than racing the clock's completion.
572 yield* advanceUntil(
573 () =>
574 Array.from(driver.requests.values()).some(({ envelope, replies }) =>
575 envelope._tag === "Request" && envelope.tag === "activity" &&
576 envelope.address.entityId === executionId &&
577 (envelope.payload as { name: string }).name === "Sleep" &&
578 replies.some((reply) =>
579 reply._tag === "WithExit" && reply.exit._tag === "Success" &&
580 (reply.exit.value as Workflow.ResultEncoded<any, any>)._tag === "Complete"
581 )
582 ),
583 "sleep activity must complete before interruption",
584 10
585 )
586 yield* pollUntil(EmailWorkflow, executionId, "Suspended")
587 yield* EmailWorkflow.interrupt(executionId)
588
589 // Wait for this execution's signal; concurrent cleanup can change the total request count.
590 yield* advanceUntil(
591 () =>
592 Array.from(driver.requests.values()).some(({ envelope }) =>
593 envelope._tag === "Request" && envelope.tag === "deferred" &&
594 envelope.address.entityId === executionId &&
595 envelope.address.entityType === `Workflow/${EmailWorkflow._tag}` &&
596 (envelope.payload as { name: string }).name === "Workflow/InterruptSignal"
597 ),
598 "interrupt signal request must be persisted"
599 )
600 yield* pollUntil(EmailWorkflow, executionId, "Complete")
601 // - clock cleared
602 expect(driver.requests.size).toEqual(9)
603
604 const result = driver.requests.get(envelope.requestId)!
605 const reply = result.replies[0]!
606 assert(
607 reply._tag === "WithExit" &&
608 reply.exit._tag === "Success"
609 )
610 const value = reply.exit.value as Workflow.ResultEncoded<any, any>
611 assert(value._tag === "Complete" && value.exit._tag === "Failure")
612
613 yield* advanceUntil(() => fiber.pollUnsafe() !== undefined, "execute waiter must observe interruption", 10)
614 const exit = yield* Fiber.await(fiber)
615 assert(Exit.hasInterrupts(exit))
616
617 const flags = yield* Flags
618 assert.isTrue(flags.get("compensation"))
619 }).pipe(
620 Effect.provide(TestWorkflowLayer)
621 ))
622
623 it.effect("Workflow.withCompensation runs compensation when the workflow fails", () =>
624 Effect.gen(function*() {
625 yield* TestClock.adjust(1)
626
627 const fiber = yield* EmailWorkflow.execute({
628 id: "test-email-3",
629 to: "compensation"
630 }).pipe(Effect.forkChild({ startImmediately: true }))
631
632 yield* pollUntil(
633 EmailWorkflow,
634 yield* EmailWorkflow.executionId({ id: "test-email-3", to: "compensation" }),
635 "Complete"
636 )
637
638 const error = yield* Fiber.join(fiber).pipe(
639 Effect.flip
640 )
641 expect(error).toBeInstanceOf(SendEmailError)
642 const flags = yield* Flags
643 assert.isTrue(flags.get("compensation"))
644 }).pipe(
645 Effect.provide(TestWorkflowLayer)
646 ))
647
648 it.effect("Activity.raceAll returns the first activity and interrupts losers", () =>
649 Effect.gen(function*() {
650 const flags = yield* Flags
651 yield* TestClock.adjust(1)
652
653 const fiber = yield* RaceWorkflow.execute({
654 id: "race-1"
655 }).pipe(Effect.forkChild({ startImmediately: true }))
656
657 // Activity timers may be registered after the caller's first clock advance.
658 yield* pollUntil(RaceWorkflow, yield* RaceWorkflow.executionId({ id: "race-1" }), "Complete")
659
660 const result = yield* Fiber.join(fiber)
661 expect(result).toEqual("Activity3")
662
663 expect(flags.get("interrupt1")).toBeTruthy()
664 expect(flags.get("interrupt2")).toBeTruthy()
665 expect(flags.get("interrupt3")).toBeFalsy()
666 }).pipe(Effect.provide(TestWorkflowLayer)))
667
668 it.effect("Activity.raceAll ignores a failure when another activity can succeed", () =>
669 Effect.gen(function*() {
670 const fiber = yield* FailureRaceWorkflow.execute({
671 id: "failure-race"
672 }).pipe(Effect.forkChild({ startImmediately: true }))
673
674 yield* pollUntil(FailureRaceWorkflow, yield* FailureRaceWorkflow.executionId({ id: "failure-race" }), "Complete")
675
676 expect(yield* Fiber.join(fiber)).toEqual("slow")
677 }).pipe(Effect.provide(TestWorkflowLayer)))
678
679 it.effect("Activity.raceAll replays the first durable activity", () =>
680 Effect.gen(function*() {
681 const flags = yield* Flags
682 const sharding = yield* Sharding.Sharding
683 yield* TestClock.adjust(1)
684
685 const fiber = yield* DurableRaceWorkflow.execute({
686 id: "race-2"
687 }).pipe(Effect.forkChild)
688
689 yield* TestClock.adjust(1)
690 yield* TestClock.adjust(1000)
691 yield* sharding.pollStorage
692 yield* TestClock.adjust(5000)
693
694 const token = flags.get("durable-race-token")
695 assert(typeof token === "string")
696 yield* DurableDeferred.done(DurableRaceGate, {
697 token: DurableDeferred.Token.make(token),
698 exit: Exit.void
699 })
700 yield* sharding.pollStorage
701 yield* TestClock.adjust(5000)
702
703 const result = yield* Fiber.join(fiber)
704 expect(result).toEqual("Activity3")
705 }).pipe(Effect.provide(TestWorkflowLayer)))
706
707 it.effect("DurableDeferred.raceAll lets a deferred win while another branch is active", () =>
708 Effect.gen(function*() {
709 const sharding = yield* Sharding.Sharding
710 const executionId = yield* MixedRaceWorkflow.executionId({ id: "mixed-race" })
711 const fiber = yield* MixedRaceWorkflow.execute({ id: "mixed-race" }).pipe(
712 Effect.forkChild({ startImmediately: true })
713 )
714
715 yield* TestClock.adjust(1)
716 const token = DurableDeferred.tokenFromExecutionId(MixedRaceGate, {
717 workflow: MixedRaceWorkflow,
718 executionId
719 })
720 yield* DurableDeferred.succeed(MixedRaceGate, { token, value: "signal" })
721 yield* sharding.pollStorage
722 yield* TestClock.adjust("1 second")
723
724 expect(yield* Fiber.join(fiber)).toEqual("signal")
725 }).pipe(Effect.provide(TestWorkflowLayer)), 20_000)
726
727 it.effect(
728 "DurableDeferred.raceAll lets an active branch win while the deferred stays pending",
729 () =>
730 Effect.gen(function*() {
731 const fiber = yield* MixedRaceWorkflow.execute({ id: "mixed-race-activity" }).pipe(
732 Effect.forkChild({ startImmediately: true })
733 )
734
735 for (let i = 0; i < 4; i++) {
736 yield* TestClock.adjust("1 second")
737 }
738
739 expect(yield* Fiber.join(fiber)).toEqual("activity")
740 }).pipe(Effect.provide(TestWorkflowLayer)),
741 20_000
742 )
743
744 it.effect(
745 "DurableDeferred.raceAll replays the run when a losing deferred completes late",
746 () =>
747 Effect.gen(function*() {
748 const flags = yield* Flags
749 const sharding = yield* Sharding.Sharding
750 const executionId = yield* LosingDeferredWorkflow.executionId({ id: "losing-deferred" })
751 const fiber = yield* LosingDeferredWorkflow.execute({ id: "losing-deferred" }).pipe(
752 Effect.forkChild({ startImmediately: true })
753 )
754
755 yield* TestClock.adjust(1)
756 yield* TestClock.adjust("1 second")
757 while (flags.get("losing-deferred-tail-runs") !== 1) {
758 yield* TestClock.adjust("1 second")
759 }
760
761 const token = DurableDeferred.tokenFromExecutionId(LosingDeferredGate, {
762 workflow: LosingDeferredWorkflow,
763 executionId
764 })
765 yield* DurableDeferred.succeed(LosingDeferredGate, { token, value: "signal" })
766 for (let i = 0; i < 4; i++) {
767 yield* sharding.pollStorage
768 yield* TestClock.adjust("10 seconds")
769 }
770
771 expect(yield* Fiber.join(fiber)).toEqual("activity:tail")
772 // The late completion preempts the tail; the replay re-executes it.
773 expect(flags.get("losing-deferred-tail-runs")).toEqual(2)
774 }).pipe(Effect.provide(TestWorkflowLayer)),
775 20_000
776 )
777
778 it.effect("DurableDeferred.raceAll wakes the active run by replaying it", () =>
779 Effect.gen(function*() {
780 const flags = yield* Flags
781 const sharding = yield* Sharding.Sharding
782 const executionId = yield* InPlaceWakeWorkflow.executionId({ id: "in-place-wake" })
783 const fiber = yield* InPlaceWakeWorkflow.execute({ id: "in-place-wake" }).pipe(
784 Effect.forkChild({ startImmediately: true })
785 )
786
787 yield* TestClock.adjust(1)
788 const token = DurableDeferred.tokenFromExecutionId(InPlaceWakeGate, {
789 workflow: InPlaceWakeWorkflow,
790 executionId
791 })
792 yield* DurableDeferred.succeed(InPlaceWakeGate, { token, value: "signal" })
793 yield* sharding.pollStorage
794 yield* TestClock.adjust("1 second")
795
796 expect(yield* Fiber.join(fiber)).toEqual("signal")
797 // Usually the completion preempts the parked run (2 runs); under load
798 // it can land before the branch parks and is read directly (1 run).
799 assert([1, 2].includes(flags.get("in-place-wake-runs") as number))
800 }).pipe(Effect.provide(TestWorkflowLayer)), 20_000)
801
802 it.effect("DurableDeferred.raceAll wakes a branch wrapped in DurableDeferred.into", () =>
803 Effect.gen(function*() {
804 const sharding = yield* Sharding.Sharding
805 const executionId = yield* IntoWrapWorkflow.executionId({ id: "into-wrap" })
806 const fiber = yield* IntoWrapWorkflow.execute({ id: "into-wrap" }).pipe(
807 Effect.forkChild({ startImmediately: true })
808 )
809
810 yield* TestClock.adjust(1)
811 const token = DurableDeferred.tokenFromExecutionId(IntoWrapGate, {
812 workflow: IntoWrapWorkflow,
813 executionId
814 })
815 yield* DurableDeferred.succeed(IntoWrapGate, { token, value: "signal" })
816 yield* sharding.pollStorage
817 yield* TestClock.adjust("1 second")
818
819 expect(yield* Fiber.join(fiber)).toEqual("signal")
820 }).pipe(Effect.provide(TestWorkflowLayer)), 20_000)
821
822 it.effect(
823 "DurableDeferred.raceAll does not preempt for deferreds awaited inside activity bodies",
824 () =>
825 Effect.gen(function*() {
826 const sharding = yield* Sharding.Sharding
827 const fiber = yield* ClockCaptureWorkflow.execute({ id: "clock-capture" }).pipe(
828 Effect.forkChild({ startImmediately: true })
829 )
830
831 // Only workflow-level awaits preempt; the clock inside the activity
832 // does not, so the other branch wins.
833 yield* TestClock.adjust(1)
834 yield* TestClock.adjust(5000)
835 yield* sharding.pollStorage
836 yield* TestClock.adjust(1000)
837 yield* TestClock.adjust("60 seconds")
838
839 expect(yield* Fiber.join(fiber)).toEqual("slow")
840 }).pipe(Effect.provide(TestWorkflowLayer)),
841 20_000
842 )
843
844 it.effect(
845 "DurableDeferred.raceAll lets a bare durable clock branch win while another branch is active",
846 () =>
847 Effect.gen(function*() {
848 const sharding = yield* Sharding.Sharding
849 const fiber = yield* BareClockWorkflow.execute({ id: "bare-clock" }).pipe(
850 Effect.forkChild({ startImmediately: true })
851 )
852
853 yield* TestClock.adjust(1)
854 for (let i = 0; i < 8; i++) {
855 yield* sharding.pollStorage
856 yield* TestClock.adjust("5 seconds")
857 }
858
859 expect(yield* Fiber.join(fiber)).toEqual("clock")
860 }).pipe(Effect.provide(TestWorkflowLayer)),
861 20_000
862 )
863
864 it.effect("DurableDeferred.raceAll runs a branch's transformations on a deferred wake", () =>
865 Effect.gen(function*() {
866 const sharding = yield* Sharding.Sharding
867 const executionId = yield* MappedGateWorkflow.executionId({ id: "mapped-gate" })
868 const fiber = yield* MappedGateWorkflow.execute({ id: "mapped-gate" }).pipe(
869 Effect.forkChild({ startImmediately: true })
870 )
871
872 yield* TestClock.adjust(1)
873 const token = DurableDeferred.tokenFromExecutionId(MappedGate, {
874 workflow: MappedGateWorkflow,
875 executionId
876 })
877 yield* DurableDeferred.succeed(MappedGate, { token, value: "signal" })
878 yield* sharding.pollStorage
879 yield* TestClock.adjust("1 second")
880
881 expect(yield* Fiber.join(fiber)).toEqual("signal!")
882 }).pipe(Effect.provide(TestWorkflowLayer)), 20_000)
883
884 it.effect(
885 "DurableDeferred.raceAll wakes a branch that ran an activity before its await",
886 () =>
887 Effect.gen(function*() {
888 const sharding = yield* Sharding.Sharding
889 const executionId = yield* PreGateWorkflow.executionId({ id: "pre-gate" })
890 const fiber = yield* PreGateWorkflow.execute({ id: "pre-gate" }).pipe(
891 Effect.forkChild({ startImmediately: true })
892 )
893
894 yield* TestClock.adjust(1)
895 yield* TestClock.adjust(1)
896 const token = DurableDeferred.tokenFromExecutionId(PreGate, {
897 workflow: PreGateWorkflow,
898 executionId
899 })
900 yield* DurableDeferred.succeed(PreGate, { token, value: "signal" })
901 yield* sharding.pollStorage
902 yield* TestClock.adjust("1 second")
903
904 expect(yield* Fiber.join(fiber)).toEqual("act:signal")
905 }).pipe(Effect.provide(TestWorkflowLayer)),
906 20_000
907 )
908
909 it.effect("DurableDeferred.raceAll re-runs a multi-await branch once per completion", () =>
910 Effect.gen(function*() {
911 const flags = yield* Flags
912 const branchControl = yield* TwoStepBranchControl
913 const sharding = yield* Sharding.Sharding
914 const executionId = yield* TwoStepWorkflow.executionId({ id: "two-step" })
915 const fiber = yield* TwoStepWorkflow.execute({ id: "two-step" }).pipe(
916 Effect.forkChild({ startImmediately: true })
917 )
918
919 yield* branchControl.entered.await
920 const tokenA = DurableDeferred.tokenFromExecutionId(TwoStepGateA, {
921 workflow: TwoStepWorkflow,
922 executionId
923 })
924 yield* DurableDeferred.succeed(TwoStepGateA, { token: tokenA, value: "a" })
925 yield* sharding.pollStorage
926 yield* TestClock.adjust("1 second")
927 yield* branchControl.release.open
928 yield* branchControl.started.await
929
930 // The run parks on the second gate after reading the first completion
931 // directly or replaying once while the completion is committed.
932 assert([1, 2].includes(flags.get("two-step-branch-runs") as number))
933
934 const tokenB = DurableDeferred.tokenFromExecutionId(TwoStepGateB, {
935 workflow: TwoStepWorkflow,
936 executionId
937 })
938 yield* DurableDeferred.succeed(TwoStepGateB, { token: tokenB, value: "b" })
939 yield* sharding.pollStorage
940 yield* TestClock.adjust("1 second")
941
942 expect(yield* Fiber.join(fiber)).toEqual("a:b")
943 assert([2, 3].includes(flags.get("two-step-branch-runs") as number))
944 }).pipe(Effect.provide(TestWorkflowLayer)), 20_000)
945
946 it.effect(
947 "DurableDeferred resumes a discarded execution when completion precedes the suspension commit",
948 () =>
949 Effect.gen(function*() {
950 const savingRun = yield* Latch.make()
951 const releaseRun = yield* Latch.make()
952 const readBeforeCommit = yield* Latch.make()
953 const savedDeferred = yield* Latch.make()
954 let runRequestId: Snowflake.Snowflake | undefined
955 const gate = DurableDeferred.make("DiscardedSuspension/Gate", { success: Schema.String })
956 const workflow = Workflow.make("DiscardedSuspension", {
957 payload: {},
958 success: Schema.String,
959 idempotencyKey: () => "one"
960 })
961 const storageLayer = Layer.effect(
962 MessageStorage.MessageStorage,
963 Effect.map(MessageStorage.MessageStorage, (storage) => ({
964 ...storage,
965 saveReply: (reply) => {
966 if (reply.rpc._tag === "run" && !savingRun.isOpen()) {
967 runRequestId = reply.reply.requestId
968 assert(reply.reply._tag === "WithExit" && reply.reply.exit._tag === "Success")
969 assert(Schema.is(Workflow.Suspended)(reply.reply.exit.value))
970 return savingRun.open.pipe(
971 Effect.andThen(releaseRun.await),
972 Effect.andThen(storage.saveReply(reply))
973 )
974 }
975 return storage.saveReply(reply).pipe(
976 Effect.tap(() => reply.rpc._tag === "deferred" ? savedDeferred.open : Effect.void)
977 )
978 },
979 repliesForUnfiltered: (requestIds) => {
980 const ids = Array.from(requestIds)
981 return storage.repliesForUnfiltered(ids).pipe(
982 Effect.tap((replies) => {
983 if (!releaseRun.isOpen() && runRequestId !== undefined && ids.includes(runRequestId)) {
984 assert.deepStrictEqual(replies, [])
985 return readBeforeCommit.open
986 }
987 return Effect.void
988 })
989 )
990 }
991 }))
992 ).pipe(Layer.provide(MessageStorage.layerMemory))
993 const context = yield* Layer.build(
994 workflow.toLayer(() => DurableDeferred.await(gate)).pipe(
995 Layer.provideMerge(makeTestWorkflowEngine({ storageLayer }))
996 )
997 )
998 yield* Effect.addFinalizer(() => releaseRun.open)
999 yield* Effect.gen(function*() {
1000 // No execute waiter may retry a Suspended reply and repair the lost wake-up.
1001 const executionId = yield* workflow.execute({}, { discard: true })
1002 yield* advanceUntil(() => savingRun.isOpen(), "run must reach suspension persistence")
1003 yield* DurableDeferred.succeed(gate, {
1004 token: DurableDeferred.tokenFromExecutionId(gate, { workflow, executionId }),
1005 value: "signal"
1006 })
1007 // Both resume and waitForRunReply read storage. Hold the commit until
1008 // that read observes no reply, allowing a fixed handler to park on it.
1009 yield* advanceUntil(() => readBeforeCommit.isOpen(), "completion must read the uncommitted run")
1010 yield* releaseRun.open
1011 yield* advanceUntil(() => savedDeferred.isOpen(), "deferred completion must be persisted")
1012 const result = yield* pollUntil(workflow, executionId, "Complete")
1013 assert.deepStrictEqual(result, new Workflow.Complete({ exit: Exit.succeed("signal") }))
1014 }).pipe(Effect.provide(context))
1015 }),
1016 20_000
1017 )
1018
1019 it.effect(
1020 "DurableDeferred.raceAll delivers a completion that lands while a suspension commits",
1021 () =>
1022 Effect.gen(function*() {
1023 const flags = yield* Flags
1024 const sharding = yield* Sharding.Sharding
1025 const executionId = yield* SlowUnwindWorkflow.executionId({ id: "slow-unwind" })
1026 const fiber = yield* SlowUnwindWorkflow.execute({ id: "slow-unwind" }).pipe(
1027 Effect.forkChild({ startImmediately: true })
1028 )
1029
1030 // Wait until both branches have parked and the ensuring sleep has
1031 // started, so the completion deterministically lands during unwind.
1032 while (flags.get("slow-unwind-started") !== true) {
1033 yield* Effect.yieldNow
1034 }
1035
1036 const token = DurableDeferred.tokenFromExecutionId(SlowUnwindGateB, {
1037 workflow: SlowUnwindWorkflow,
1038 executionId
1039 })
1040 yield* DurableDeferred.succeed(SlowUnwindGateB, { token, value: "signal-b" })
1041 // Finish the unwind, then the replay and its own ensuring sleep.
1042 for (let i = 0; i < 4; i++) {
1043 yield* TestClock.adjust("10 seconds")
1044 yield* sharding.pollStorage
1045 }
1046
1047 expect(yield* Fiber.join(fiber)).toEqual("signal-b")
1048 expect(flags.get("slow-unwind-runs")).toEqual(2)
1049 }).pipe(Effect.provide(TestWorkflowLayer)),
1050 20_000
1051 )
1052
1053 it.effect(
1054 "DurableDeferred.raceAll suspends when every branch is pending and resumes with the winner",
1055 () =>
1056 Effect.gen(function*() {
1057 const sharding = yield* Sharding.Sharding
1058 const executionId = yield* TwoGateWorkflow.executionId({ id: "two-gates" })
1059 const fiber = yield* TwoGateWorkflow.execute({ id: "two-gates" }).pipe(
1060 Effect.forkChild({ startImmediately: true })
1061 )
1062
1063 // Wait for the race to suspend with both gates pending.
1064 yield* TestClock.adjust(1)
1065 let polled = yield* TwoGateWorkflow.poll(executionId)
1066 while (Option.isNone(polled) || polled.value._tag !== "Suspended") {
1067 yield* Effect.yieldNow
1068 polled = yield* TwoGateWorkflow.poll(executionId)
1069 }
1070
1071 const token = DurableDeferred.tokenFromExecutionId(TwoGateB, {
1072 workflow: TwoGateWorkflow,
1073 executionId
1074 })
1075 yield* DurableDeferred.succeed(TwoGateB, { token, value: "signal-b" })
1076 yield* sharding.pollStorage
1077 yield* TestClock.adjust("5 seconds")
1078
1079 expect(yield* Fiber.join(fiber)).toEqual("signal-b")
1080 }).pipe(Effect.provide(TestWorkflowLayer)),
1081 20_000
1082 )
1083
1084 it.effect("nested workflows", () =>
1085 Effect.gen(function*() {
1086 const flags = yield* Flags
1087 const sharding = yield* Sharding.Sharding
1088 yield* TestClock.adjust(1)
1089
1090 yield* ParentWorkflow.execute({
1091 id: "123"
1092 }).pipe(Effect.forkChild)
1093 yield* TestClock.adjust(1000)
1094 while (flags.get("parent-suspended") === undefined) {
1095 yield* Effect.yieldNow
1096 }
1097
1098 assert.isUndefined(flags.get("parent-end"))
1099 assert.isUndefined(flags.get("child-end"))
1100 assert.isTrue(flags.get("parent-suspended"))
1101 const token = flags.get("child-token")
1102 assert(typeof token === "string")
1103
1104 yield* DurableDeferred.done(ChildDeferred, {
1105 token: DurableDeferred.Token.make(token),
1106 exit: Exit.void
1107 })
1108 yield* TestClock.adjust(5000)
1109 yield* sharding.pollStorage
1110 assert.isTrue(flags.get("parent-end"))
1111 assert.isTrue(flags.get("child-end"))
1112 }).pipe(Effect.provide(TestWorkflowLayer)))
1113
1114 it.effect("bounds a durable clock notification when its workflow is absent after restart", () =>
1115 Effect.gen(function*() {
1116 const clockDuration = 3000
1117 const registrationTimeout = 5000
1118 const RemovedWorkflow = Workflow.make("RemovedWorkflow", {
1119 payload: {},
1120 success: Schema.Void,
1121 idempotencyKey: () => "one"
1122 })
1123 const shared = yield* Layer.build(
1124 MessageStorage.layerMemory.pipe(Layer.provide(ShardingConfig.layerDefaults))
1125 )
1126 const driver = Context.get(shared, MessageStorage.MemoryDriver)
1127 const storageLayer = Layer.succeed(
1128 MessageStorage.MessageStorage,
1129 Context.get(shared, MessageStorage.MessageStorage)
1130 )
1131 const config = { entityRegistrationTimeout: registrationTimeout }
1132 const clock = DurableClock.make({ name: "wait", duration: clockDuration })
1133
1134 const executionId = yield* Effect.gen(function*() {
1135 const executionId = yield* RemovedWorkflow.execute({}, { discard: true })
1136 yield* pollUntil(RemovedWorkflow, executionId, "Suspended")
1137 return executionId
1138 }).pipe(Effect.provide(
1139 RemovedWorkflow.toLayer(() =>
1140 DurableClock.sleep({ name: "wait", duration: clockDuration, inMemoryThreshold: Duration.zero })
1141 ).pipe(Layer.provideMerge(makeTestWorkflowEngine({ storageLayer, config })))
1142 ))
1143 const clockRequest = driver.journal.find((message) =>
1144 message._tag === "Request" && message.address.entityType === "Workflow/-/DurableClock"
1145 )
1146 assert(clockRequest !== undefined && clockRequest._tag === "Request")
1147
1148 yield* Effect.gen(function*() {
1149 // Fire the persisted timer before the registration-start deadline.
1150 yield* TestClock.adjust(clockDuration)
1151 // This is the completion emitted by ClockEntity when the persisted timer fires.
1152 // Calling it directly isolates the notifyLocal registration wait from storage claims.
1153 const fiber = yield* DurableDeferred.done(clock.deferred, {
1154 token: DurableDeferred.tokenFromExecutionId(clock.deferred, {
1155 workflow: RemovedWorkflow,
1156 executionId
1157 }),
1158 exit: Exit.void
1159 }).pipe(Effect.forkDetach({ startImmediately: true }))
1160 yield* Effect.yieldNow
1161 assert.isUndefined(fiber.pollUnsafe())
1162 yield* TestClock.adjust(registrationTimeout - clockDuration)
1163
1164 const exit = fiber.pollUnsafe()
1165 assert(exit !== undefined, "the notifyLocal registration wait must be bounded")
1166 const defect = Exit.findDefect(exit)
1167 assert(Result.isSuccess(defect) && defect.success instanceof Error)
1168 assert.strictEqual(defect.success.message, `Entity type 'Workflow/${RemovedWorkflow._tag}' not registered`)
1169 }).pipe(Effect.provide(makeTestWorkflowEngine({ storageLayer, config })))
1170 }))
1171
1172 for (const [id, threshold] of [["number", 0], ["bigint", 0n]] as const) {
1173 it.effect(`DurableClock.sleep preserves an explicit ${id} zero threshold`, () => verifyZeroThreshold(id, threshold))
1174 }
1175
1176 it.effect("parallel child workflows inside an activity suspend the parent durably", () =>
1177 Effect.gen(function*() {
1178 const sharding = yield* Sharding.Sharding
1179 const flags = yield* Flags
1180 yield* TestClock.adjust(1)
1181
1182 const payload = { id: "parallel-activity", childCount: 3, concurrency: 3, sleep: 2000 }
1183 const executionId = yield* ParallelParentWorkflow.executionId(payload)
1184 const fiber = yield* ParallelParentWorkflow.execute(payload).pipe(
1185 Effect.forkChild({ startImmediately: true })
1186 )
1187
1188 // the parent suspends durably once every child has been dispatched
1189 let suspended = false
1190 while (!suspended) {
1191 yield* Effect.yieldNow
1192 yield* sharding.pollStorage
1193 const result = yield* ParallelParentWorkflow.poll(executionId)
1194 suspended = Option.isSome(result) && result.value._tag === "Suspended"
1195 }
1196 assert.strictEqual(flags.get("parallel-runs-parallel-activity"), 1)
1197 assert.isTrue(flags.get("parallel-child-start-parallel-activity-child-0"))
1198 assert.isTrue(flags.get("parallel-child-start-parallel-activity-child-1"))
1199 assert.isTrue(flags.get("parallel-child-start-parallel-activity-child-2"))
1200 assert.strictEqual(flags.get("parallel-activity-runs-parallel-activity"), 1)
1201 assert.strictEqual(flags.get("parallel-activity-releases-parallel-activity"), 1)
1202
1203 // the children complete after a single sleep window and wake the parent
1204 yield* TestClock.adjust(Duration.seconds(2))
1205 yield* sharding.pollStorage
1206 while (fiber.pollUnsafe() === undefined) {
1207 yield* Effect.yieldNow
1208 yield* TestClock.adjust(1)
1209 yield* sharding.pollStorage
1210 }
1211 assert.deepStrictEqual(fiber.pollUnsafe(), Exit.succeed(["done-0", "done-1", "done-2"]))
1212
1213 // the parent replayed after suspending, and no run retried the activity body
1214 const parentRuns = Number(flags.get("parallel-runs-parallel-activity"))
1215 const activityRuns = Number(flags.get("parallel-activity-runs-parallel-activity"))
1216 assert.isAtLeast(parentRuns, 2)
1217 assert.isAtMost(activityRuns, parentRuns)
1218 }).pipe(Effect.provide(TestWorkflowLayer)))
1219
1220 it.effect("bounded child concurrency progresses across suspended activity replays", () =>
1221 Effect.gen(function*() {
1222 const sharding = yield* Sharding.Sharding
1223 const flags = yield* Flags
1224 yield* TestClock.adjust(1)
1225 const payload = { id: "parallel-bounded", childCount: 5, concurrency: 2, sleep: 2000 }
1226 const executionId = yield* ParallelParentWorkflow.executionId(payload)
1227 yield* ParallelParentWorkflow.execute(payload, { discard: true })
1228
1229 for (const started of [2, 4, 5]) {
1230 let suspended = false
1231 while (!suspended) {
1232 yield* Effect.yieldNow
1233 // Drive cluster retries between runs without advancing child clocks
1234 // while the activity is computing execution IDs.
1235 if (
1236 flags.get("parallel-activity-runs-parallel-bounded") ===
1237 flags.get("parallel-activity-releases-parallel-bounded")
1238 ) {
1239 yield* TestClock.adjust(1)
1240 }
1241 yield* sharding.pollStorage
1242 const result = yield* ParallelParentWorkflow.poll(executionId)
1243 suspended = Option.isSome(result) && result.value._tag === "Suspended" &&
1244 flags.get(`parallel-child-start-parallel-bounded-child-${started - 1}`) === true
1245 }
1246 for (let index = 0; index < 5; index++) {
1247 assert.strictEqual(
1248 flags.get(`parallel-child-start-parallel-bounded-child-${index}`),
1249 index < started ? true : undefined
1250 )
1251 }
1252 assert.strictEqual(
1253 flags.get("parallel-activity-releases-parallel-bounded"),
1254 flags.get("parallel-activity-runs-parallel-bounded")
1255 )
1256 yield* TestClock.adjust(Duration.seconds(2))
1257 yield* sharding.pollStorage
1258 }
1259 let result = yield* ParallelParentWorkflow.poll(executionId)
1260 while (Option.isNone(result) || result.value._tag !== "Complete") {
1261 yield* Effect.yieldNow
1262 yield* TestClock.adjust(1)
1263 yield* sharding.pollStorage
1264 result = yield* ParallelParentWorkflow.poll(executionId)
1265 }
1266 assert.deepStrictEqual(
1267 result.value,
1268 new Workflow.Complete({ exit: Exit.succeed(["done-0", "done-1", "done-2", "done-3", "done-4"]) })
1269 )
1270 assert.isAtMost(
1271 Number(flags.get("parallel-activity-runs-parallel-bounded")),
1272 Number(flags.get("parallel-runs-parallel-bounded"))
1273 )
1274 }).pipe(Effect.provide(TestWorkflowLayer)))
1275
1276 it.effect("resumes when children complete during activity cleanup", () =>
1277 Effect.gen(function*() {
1278 const cleaningUp = yield* Latch.make()
1279 const release = yield* Latch.make()
1280 const Parent = Workflow.make("CleanupParent", {
1281 payload: {},
1282 success: Schema.Array(Schema.Number),
1283 idempotencyKey: () => "parent"
1284 })
1285 const Child = Workflow.make("CleanupChild", {
1286 payload: { index: Schema.Number },
1287 success: Schema.Number,
1288 idempotencyKey: ({ index }) => String(index)
1289 })
1290 const ParentLayer = Parent.toLayer(() =>
1291 Activity.make({
1292 name: "children",
1293 success: Schema.Array(Schema.Number),
1294 execute: Effect.forEach([0, 1], (index) => Child.execute({ index }), { concurrency: "unbounded" }).pipe(
1295 Effect.ensuring(Effect.andThen(cleaningUp.open, release.await))
1296 )
1297 })
1298 )
1299 const ChildLayer = Child.toLayer(({ index }) =>
1300 DurableClock.sleep({ name: "wait", duration: "2 seconds", inMemoryThreshold: Duration.zero }).pipe(
1301 Effect.as(index)
1302 )
1303 )
1304 yield* Effect.gen(function*() {
1305 const sharding = yield* Sharding.Sharding
1306 yield* TestClock.adjust(1)
1307 const executionId = yield* Parent.execute({}, { discard: true })
1308 yield* cleaningUp.await
1309 yield* TestClock.adjust("2 seconds")
1310 yield* sharding.pollStorage
1311 for (const index of [0, 1]) {
1312 const childId = yield* Child.executionId({ index })
1313 let childResult = yield* Child.poll(childId)
1314 while (Option.isNone(childResult) || childResult.value._tag !== "Complete") {
1315 yield* Effect.yieldNow
1316 yield* TestClock.adjust(1)
1317 yield* sharding.pollStorage
1318 childResult = yield* Child.poll(childId)
1319 }
1320 assert.deepStrictEqual(
1321 childResult.value,
1322 new Workflow.Complete({ exit: Exit.succeed(index) })
1323 )
1324 }
1325 yield* release.open
1326 let result = yield* Parent.poll(executionId)
1327 while (Option.isNone(result) || result.value._tag !== "Complete") {
1328 yield* Effect.yieldNow
1329 yield* TestClock.adjust(1)
1330 yield* sharding.pollStorage
1331 result = yield* Parent.poll(executionId)
1332 }
1333 assert.deepStrictEqual(
1334 result.value,
1335 new Workflow.Complete({ exit: Exit.succeed([0, 1]) })
1336 )
1337 }).pipe(Effect.provide(Layer.mergeAll(ParentLayer, ChildLayer).pipe(Layer.provideMerge(TestWorkflowLayer))))
1338 }))
1339
1340 it.effect("coalesces parked child wakeups while parent cleanup is pending", () =>
1341 Effect.gen(function*() {
1342 const cleaningUp = yield* Latch.make()
1343 const release = yield* Latch.make()
1344 const indices = Array.from({ length: 64 }, (_, index) => index)
1345 let parked = 0
1346 let peak = 0
1347 const storageLayer = Layer.effect(
1348 MessageStorage.MessageStorage,
1349 Effect.map(
1350 MessageStorage.MessageStorage,
1351 (storage) =>
1352 MessageStorage.MessageStorage.of({
1353 ...storage,
1354 registerReplyHandler: (message) => {
1355 if (
1356 message.envelope.address.entityType !== "Workflow/ManyChildrenParent" ||
1357 message.envelope.tag !== "run"
1358 ) {
1359 return storage.registerReplyHandler(message)
1360 }
1361 return Effect.suspend(() => {
1362 parked++
1363 peak = Math.max(peak, parked)
1364 return storage.registerReplyHandler(message)
1365 }).pipe(Effect.ensuring(Effect.sync(() => {
1366 parked--
1367 })))
1368 }
1369 })
1370 )
1371 ).pipe(Layer.provideMerge(MessageStorage.layerMemory))
1372 const Parent = Workflow.make("ManyChildrenParent", {
1373 payload: {},
1374 success: Schema.Array(Schema.Number),
1375 idempotencyKey: () => "parent"
1376 })
1377 const Child = Workflow.make("ManyChildrenChild", {
1378 payload: { index: Schema.Number },
1379 success: Schema.Number,
1380 idempotencyKey: ({ index }) => String(index)
1381 })
1382 const ParentLayer = Parent.toLayer(() =>
1383 Activity.make({
1384 name: "children",
1385 success: Schema.Array(Schema.Number),
1386 execute: Effect.forEach(indices, (index) => Child.execute({ index }), { concurrency: "unbounded" }).pipe(
1387 Effect.ensuring(Effect.andThen(cleaningUp.open, release.await))
1388 )
1389 })
1390 )
1391 const ChildLayer = Child.toLayer(({ index }) =>
1392 DurableClock.sleep({ name: "wait", duration: "2 seconds", inMemoryThreshold: Duration.zero }).pipe(
1393 Effect.as(index)
1394 )
1395 )
1396 yield* Effect.gen(function*() {
1397 const sharding = yield* Sharding.Sharding
1398 const driver = yield* MessageStorage.MemoryDriver
1399 yield* TestClock.adjust(1)
1400 const executionId = yield* Parent.execute({}, { discard: true })
1401 yield* cleaningUp.await
1402 yield* TestClock.adjust("2 seconds")
1403 const wakeRequests = () =>
1404 driver.journal.filter((message) =>
1405 message._tag === "Request" &&
1406 message.address.entityType === "Workflow/ManyChildrenParent" && message.tag === "resume"
1407 )
1408 const allWakesHandled = () => {
1409 const wakes = wakeRequests()
1410 const replied = wakes.filter((message) =>
1411 message._tag === "Request" && (driver.requests.get(message.requestId)?.replies.length ?? 0) > 0
1412 ).length
1413 // Every wake must have replied or parked before releasing the parent.
1414 return wakes.length === indices.length && replied + parked === indices.length
1415 }
1416 while (!allWakesHandled()) {
1417 yield* Effect.yieldNow
1418 yield* TestClock.adjust(1)
1419 yield* sharding.pollStorage
1420 }
1421 const wakes = wakeRequests()
1422 const parkedBeforeRelease = parked
1423 yield* release.open
1424 let result = yield* Parent.poll(executionId)
1425 while (Option.isNone(result) || result.value._tag !== "Complete" || parked !== 0) { // oxlint-disable-line no-unmodified-loop-condition
1426 yield* Effect.yieldNow
1427 yield* TestClock.adjust(1)
1428 yield* sharding.pollStorage
1429 result = yield* Parent.poll(executionId)
1430 }
1431 assert.deepStrictEqual(
1432 result.value,
1433 new Workflow.Complete({ exit: Exit.succeed(indices) })
1434 )
1435 assert.strictEqual(parked, 0)
1436 assert.strictEqual(wakes.length, indices.length)
1437 // Each wake remains durable, but only one waits on this parent run.
1438 assert.strictEqual(parkedBeforeRelease, 1, `peak parked reply handlers: ${peak}`)
1439 assert.strictEqual(peak, 1)
1440 }).pipe(Effect.provide(
1441 Layer.mergeAll(ParentLayer, ChildLayer).pipe(
1442 Layer.provideMerge(makeTestWorkflowEngine({ config: { entityMailboxCapacity: 1000 }, storageLayer }))
1443 )
1444 ))
1445 }))
1446
1447 for (const queuedReplay of [false, true]) {
1448 it.effect(`resumes a fresh entity from a legacy envelope with queued replay ${queuedReplay}`, () =>
1449 Effect.gen(function*() {
1450 const Parent = Workflow.make("ColdParent", {
1451 payload: {},
1452 success: Schema.Array(Schema.Number),
1453 idempotencyKey: () => "parent"
1454 })
1455 const Child = Workflow.make("ColdChild", {
1456 payload: { index: Schema.Number },
1457 success: Schema.Number,
1458 idempotencyKey: ({ index }) => String(index)
1459 })
1460 const gate = DurableDeferred.make("cold-child")
1461 const ParentLayer = Parent.toLayer(() =>
1462 Activity.make({
1463 name: "children",
1464 success: Schema.Array(Schema.Number),
1465 execute: Effect.forEach([0, 1], (index) => Child.execute({ index }), { concurrency: "unbounded" })
1466 })
1467 )
1468 const ChildLayer = Child.toLayer(({ index }) => DurableDeferred.await(gate).pipe(Effect.as(index)))
1469 const LegacyResume = Entity.make("Workflow/ColdParent", [
1470 Rpc.make("resume", { payload: {}, primaryKey: () => "" }).annotate(ClusterSchema.Persisted, true)
1471 ])
1472 yield* Effect.gen(function*() {
1473 const sharding = yield* Sharding.Sharding
1474 const driver = yield* MessageStorage.MemoryDriver
1475 const engine = yield* WorkflowEngine
1476 yield* TestClock.adjust(1)
1477 const executionId = yield* Parent.execute({}, { discard: true })
1478 let result = yield* Parent.poll(executionId)
1479 while (Option.isNone(result) || result.value._tag !== "Suspended") {
1480 yield* Effect.yieldNow
1481 yield* TestClock.adjust(1)
1482 yield* sharding.pollStorage
1483 result = yield* Parent.poll(executionId)
1484 }
1485 for (let i = 0; i < 12 && (yield* sharding.activeEntityCount) > 0; i++) {
1486 yield* TestClock.adjust(5000)
1487 }
1488 assert.strictEqual(yield* sharding.activeEntityCount, 0)
1489 if (queuedReplay) {
1490 const run = driver.journal.find((message) =>
1491 message._tag === "Request" &&
1492 message.address.entityType === "Workflow/ColdParent" && message.tag === "run"
1493 )!
1494 assert(run._tag === "Request")
1495 assert.isTrue(yield* sharding.reset(Snowflake.Snowflake(run.requestId)))
1496 assert.isTrue(Option.isNone(yield* Parent.poll(executionId)))
1497 }
1498 const client = yield* LegacyResume.client
1499 // The empty payload is the format of resume messages persisted before this fix.
1500 const legacyResume = yield* client(executionId).resume({}).pipe(Effect.forkChild({ startImmediately: true }))
1501 for (const index of [0, 1]) {
1502 yield* engine.deferredDone(gate, {
1503 workflowName: Child._tag,
1504 executionId: yield* Child.executionId({ index }),
1505 deferredName: gate.name,
1506 exit: Exit.void
1507 })
1508 }
1509 result = yield* Parent.poll(executionId)
1510 while (Option.isNone(result) || result.value._tag !== "Complete" || legacyResume.pollUnsafe() === undefined) {
1511 yield* Effect.yieldNow
1512 yield* TestClock.adjust(1)
1513 yield* sharding.pollStorage
1514 result = yield* Parent.poll(executionId)
1515 }
1516 assert.deepStrictEqual(
1517 result.value,
1518 new Workflow.Complete({ exit: Exit.succeed([0, 1]) })
1519 )
1520 assert.deepStrictEqual(legacyResume.pollUnsafe(), Exit.void)
1521 }).pipe(Effect.provide(
1522 Layer.mergeAll(ParentLayer, ChildLayer).pipe(
1523 Layer.provideMerge(makeTestWorkflowEngine({ config: { entityMessagePollInterval: "1 hour" } }))
1524 )
1525 ))
1526 }))
1527 }
1528
1529 it.effect("a parent stays suspended past the activity interrupt retry budget", () =>
1530 Effect.gen(function*() {
1531 const sharding = yield* Sharding.Sharding
1532 const flags = yield* Flags
1533 yield* TestClock.adjust(1)
1534
1535 const payload = { id: "parallel-long", childCount: 2, concurrency: 2, sleep: 60_000 }
1536 const executionId = yield* ParallelParentWorkflow.executionId(payload)
1537 const fiber = yield* ParallelParentWorkflow.execute(payload).pipe(
1538 Effect.forkChild({ startImmediately: true })
1539 )
1540
1541 let suspended = false
1542 while (!suspended) {
1543 yield* Effect.yieldNow
1544 yield* sharding.pollStorage
1545 const result = yield* ParallelParentWorkflow.poll(executionId)
1546 suspended = Option.isSome(result) && result.value._tag === "Suspended"
1547 }
1548
1549 // well past the activity interrupt retry budget the run is still suspended
1550 // and the activity body has not been re-executed
1551 yield* TestClock.adjust(Duration.seconds(50))
1552 yield* sharding.pollStorage
1553 const still = yield* ParallelParentWorkflow.poll(executionId)
1554 assert.isTrue(Option.isSome(still) && still.value._tag === "Suspended")
1555 assert.strictEqual(flags.get("parallel-activity-runs-parallel-long"), 1)
1556 assert.strictEqual(flags.get("parallel-activity-releases-parallel-long"), 1)
1557 assert.isUndefined(fiber.pollUnsafe())
1558
1559 yield* TestClock.adjust(Duration.seconds(10))
1560 yield* sharding.pollStorage
1561 while (fiber.pollUnsafe() === undefined) {
1562 yield* Effect.yieldNow
1563 yield* TestClock.adjust(1)
1564 yield* sharding.pollStorage
1565 }
1566 assert.deepStrictEqual(fiber.pollUnsafe(), Exit.succeed(["done-0", "done-1"]))
1567 }).pipe(Effect.provide(TestWorkflowLayer)))
1568
1569 it.effect("parallel child workflows in the workflow body all dispatch before suspending", () =>
1570 Effect.gen(function*() {
1571 const sharding = yield* Sharding.Sharding
1572 const flags = yield* Flags
1573 yield* TestClock.adjust(1)
1574
1575 const payload = { id: "parallel-direct", childCount: 3 }
1576 const executionId = yield* ParallelDirectWorkflow.executionId(payload)
1577 const fiber = yield* ParallelDirectWorkflow.execute(payload).pipe(
1578 Effect.forkChild({ startImmediately: true })
1579 )
1580
1581 let suspended = false
1582 while (!suspended) {
1583 yield* Effect.yieldNow
1584 yield* sharding.pollStorage
1585 const result = yield* ParallelDirectWorkflow.poll(executionId)
1586 suspended = Option.isSome(result) && result.value._tag === "Suspended"
1587 }
1588 assert.strictEqual(flags.get("parallel-runs-parallel-direct"), 1)
1589 assert.isTrue(flags.get("parallel-child-start-parallel-direct-child-0"))
1590 assert.isTrue(flags.get("parallel-child-start-parallel-direct-child-1"))
1591 assert.isTrue(flags.get("parallel-child-start-parallel-direct-child-2"))
1592
1593 yield* TestClock.adjust(Duration.seconds(2))
1594 yield* sharding.pollStorage
1595 while (fiber.pollUnsafe() === undefined) {
1596 yield* Effect.yieldNow
1597 yield* TestClock.adjust(1)
1598 yield* sharding.pollStorage
1599 }
1600 assert.deepStrictEqual(fiber.pollUnsafe(), Exit.succeed(["done-0", "done-1", "done-2"]))
1601 }).pipe(Effect.provide(TestWorkflowLayer)))
1602
1603 it.effect("routes fractional millisecond durable clock wakeups to the workflow shard group", () =>
1604 Effect.gen(function*() {
1605 const driver = yield* MessageStorage.MemoryDriver
1606 const sharding = yield* Sharding.Sharding
1607 const scheduled = yield* ShardedClockScheduled
1608 const startedAt = yield* DateTime.now
1609
1610 const fiber = yield* ShardedClockWorkflow.execute({
1611 id: "sharded-clock"
1612 }).pipe(Effect.forkChild({ startImmediately: true }))
1613
1614 yield* scheduled.await
1615
1616 const envelope = driver.journal.find((envelope) =>
1617 envelope._tag === "Request" && envelope.address.entityType === "Workflow/-/DurableClock"
1618 )
1619 assert(envelope)
1620 assert.strictEqual(envelope.address.shardId.group, "workflow")
1621 const deliverAt = driver.requests.get(envelope.requestId)?.deliverAt
1622 assert.isNumber(deliverAt)
1623 assert.strictEqual(deliverAt, DateTime.toEpochMillis(startedAt) + 10001)
1624
1625 yield* TestClock.adjust(10001)
1626 yield* sharding.pollStorage
1627 yield* TestClock.adjust(5000)
1628 yield* Fiber.join(fiber)
1629 }).pipe(Effect.provide(TestWorkflowLayer)))
1630
1631 it.effect("routes durable deferred completions to the workflow shard group after a partial client is cached", () =>
1632 Effect.gen(function*() {
1633 const driver = yield* MessageStorage.MemoryDriver
1634 const engine = yield* WorkflowEngine
1635 const executionIdBeforeRegister = yield* ShardedDeferredWorkflow.executionId({ id: "before-register" })
1636 const tokenBeforeRegister = DurableDeferred.tokenFromExecutionId(ShardedDeferred, {
1637 workflow: ShardedDeferredWorkflow,
1638 executionId: executionIdBeforeRegister
1639 })
1640
1641 // Prime the partial client cache without waiting for the unregistered workflow entity.
1642 const beforeRegisterDoneFiber = yield* DurableDeferred.done(ShardedDeferred, {
1643 token: tokenBeforeRegister,
1644 exit: Exit.void
1645 }).pipe(Effect.forkChild({ startImmediately: true }))
1646 yield* Effect.yieldNow
1647 yield* Fiber.interrupt(beforeRegisterDoneFiber)
1648
1649 yield* engine.register(ShardedDeferredWorkflow, () => Effect.void)
1650
1651 const executionIdAfterRegister = yield* ShardedDeferredWorkflow.executionId({ id: "after-register" })
1652 const tokenAfterRegister = DurableDeferred.tokenFromExecutionId(ShardedDeferred, {
1653 workflow: ShardedDeferredWorkflow,
1654 executionId: executionIdAfterRegister
1655 })
1656 const journalLength = driver.journal.length
1657 yield* DurableDeferred.done(ShardedDeferred, {
1658 token: tokenAfterRegister,
1659 exit: Exit.void
1660 })
1661
1662 const envelope = driver.journal.slice(journalLength).find((envelope) =>
1663 envelope._tag === "Request" && envelope.address.entityType === "Workflow/ShardedDeferredWorkflow"
1664 )
1665 assert(envelope)
1666 assert.strictEqual(envelope.address.shardId.group, "workflow")
1667 }).pipe(Effect.provide(TestWorkflowEngine)))
1668
1669 it.effect("warns for conflicting definitions and stays silent for the same definition", () => {
1670 class FirstPayload extends Schema.Class<FirstPayload>("DuplicateClassPayload")({
1671 organizationId: Schema.String
1672 }) {}
1673 class SecondPayload extends Schema.Class<SecondPayload>("DuplicateClassPayload")({
1674 deploymentId: Schema.Number
1675 }) {}
1676 const first = Workflow.make("DuplicateClassPayloadWorkflow", {
1677 payload: FirstPayload,
1678 idempotencyKey: ({ organizationId }) => organizationId
1679 })
1680 const second = Workflow.make("DuplicateClassPayloadWorkflow", {
1681 payload: SecondPayload,
1682 idempotencyKey: ({ deploymentId }) => String(deploymentId)
1683 })
1684 const warnings: Array<unknown> = []
1685 const logger = Logger.make<unknown, void>((options) => {
1686 if (options.logLevel === "Warn") {
1687 warnings.push(options.message)
1688 }
1689 })
1690
1691 return Effect.gen(function*() {
1692 const engine = yield* WorkflowEngine
1693 yield* engine.register(first, () => Effect.void)
1694 yield* engine.register(first, () => Effect.void)
1695
1696 assert.deepStrictEqual(warnings, [])
1697
1698 yield* engine.register(second, () => Effect.void)
1699
1700 const warning = warnings
1701 .map((message) => globalThis.Array.isArray(message) ? message.join(" ") : String(message))
1702 .find((message) => message.includes("DuplicateClassPayloadWorkflow"))
1703 assert(warning, "duplicate workflow registration must emit a warning containing its tag")
1704 const normalized = warning.toLowerCase()
1705 for (const fragment of ["organizationid", "string", "deploymentid", "number"]) {
1706 assert.include(normalized, fragment, `warning must identify both class payload shapes: ${warning}`)
1707 }
1708 }).pipe(Effect.provide(TestWorkflowEngine), Effect.withLogger(logger))
1709 })
1710
1711 it.effect("does not fail when duplicate workflow payload shapes cannot be rendered", () => {
1712 const payloadKey = Symbol("payload")
1713 const first = Workflow.make("UnavailablePayloadShapeWorkflow", {
1714 payload: Schema.Struct({ [payloadKey]: Schema.String }),
1715 idempotencyKey: () => "first"
1716 })
1717 const second = Workflow.make("UnavailablePayloadShapeWorkflow", {
1718 payload: Schema.Struct({ [payloadKey]: Schema.Number }),
1719 idempotencyKey: () => "second"
1720 })
1721 const warnings: Array<unknown> = []
1722 const logger = Logger.make<unknown, void>((options) => {
1723 if (options.logLevel === "Warn") {
1724 warnings.push(options.message)
1725 }
1726 })
1727
1728 return Effect.gen(function*() {
1729 const engine = yield* WorkflowEngine
1730 yield* engine.register(first, () => Effect.void)
1731 yield* engine.register(second, () => Effect.void)
1732
1733 const warning = warnings
1734 .map((message) => globalThis.Array.isArray(message) ? message.join(" ") : String(message))
1735 .find((message) => message.includes("UnavailablePayloadShapeWorkflow"))
1736 assert(warning, "duplicate workflow registration must still warn when rendering a payload shape fails")
1737 }).pipe(Effect.provide(TestWorkflowEngine), Effect.withLogger(logger))
1738 })
1739
1740 it.effect("propagates trace context to persisted workflow requests", () => {
1741 let callerSpan: Tracer.NativeSpan | undefined
1742 const tracer = Tracer.make({
1743 span(options) {
1744 const span = new Tracer.NativeSpan(options)
1745 if (options.name === "WorkflowEngine.deferredDone") {
1746 callerSpan = span
1747 }
1748 return span
1749 }
1750 })
1751 return Effect.gen(function*() {
1752 const driver = yield* MessageStorage.MemoryDriver
1753 const engine = yield* WorkflowEngine
1754 yield* engine.register(ShardedDeferredWorkflow, () => Effect.void)
1755
1756 const executionId = yield* ShardedDeferredWorkflow.executionId({ id: "trace-context" })
1757 const token = DurableDeferred.tokenFromExecutionId(ShardedDeferred, {
1758 workflow: ShardedDeferredWorkflow,
1759 executionId
1760 })
1761 const journalLength = driver.journal.length
1762 yield* DurableDeferred.done(ShardedDeferred, {
1763 token,
1764 exit: Exit.void
1765 })
1766
1767 const envelope = driver.journal.slice(journalLength).find((envelope) =>
1768 envelope._tag === "Request" &&
1769 envelope.address.entityType === "Workflow/ShardedDeferredWorkflow" &&
1770 envelope.tag === "deferred"
1771 )
1772 assert(envelope?._tag === "Request")
1773 assert(callerSpan)
1774 assert.strictEqual(envelope.traceId, callerSpan.traceId)
1775 assert.strictEqual(envelope.spanId, callerSpan.spanId)
1776 assert.strictEqual(envelope.sampled, callerSpan.sampled)
1777 }).pipe(
1778 Effect.provideService(Tracer.Tracer, tracer),
1779 Effect.provide(TestWorkflowEngine)
1780 )
1781 })
1782
1783 it.effect("routes activities to the workflow shard group after a partial client is cached", () =>
1784 Effect.gen(function*() {
1785 const driver = yield* MessageStorage.MemoryDriver
1786 const engine = yield* WorkflowEngine
1787 const payload = { id: "partial-client-before-execute" }
1788 const executionId = yield* ShardedDeferredWorkflow.executionId(payload)
1789 const token = DurableDeferred.tokenFromExecutionId(ShardedDeferred, {
1790 workflow: ShardedDeferredWorkflow,
1791 executionId
1792 })
1793 // Prime the partial client cache without waiting for the unregistered workflow entity.
1794 const doneFiber = yield* DurableDeferred.done(ShardedDeferred, {
1795 token,
1796 exit: Exit.void
1797 }).pipe(Effect.forkChild({ startImmediately: true }))
1798 yield* Effect.yieldNow
1799 yield* Fiber.interrupt(doneFiber)
1800
1801 yield* engine.register(ShardedDeferredWorkflow, () =>
1802 Activity.make({
1803 name: "ShardedActivity",
1804 execute: Effect.void
1805 }))
1806
1807 const journalLength = driver.journal.length
1808 assert.strictEqual(yield* ShardedDeferredWorkflow.execute(payload), undefined)
1809 const envelope = driver.journal.slice(journalLength).find((envelope) =>
1810 envelope._tag === "Request" &&
1811 envelope.address.entityType === "Workflow/ShardedDeferredWorkflow" &&
1812 envelope.tag === "activity"
1813 )
1814 assert(envelope)
1815 assert.strictEqual(envelope.address.shardId.group, "workflow")
1816 }).pipe(Effect.provide(TestWorkflowEngine)))
1817
1818 it.effect("SuspendOnFailure", () =>
1819 Effect.gen(function*() {
1820 const flags = yield* Flags
1821 yield* TestClock.adjust(1)
1822
1823 yield* SuspendOnFailureWorkflow.execute({
1824 id: ""
1825 }).pipe(Effect.forkChild({ startImmediately: true }))
1826
1827 yield* TestClock.adjust(2000)
1828 while (!flags.has("suspended")) {
1829 yield* Effect.yieldNow
1830 }
1831
1832 assert.isTrue(flags.get("suspended"))
1833 assert.include(flags.get("cause"), "boom")
1834 }).pipe(Effect.provide(TestWorkflowLayer)))
1835
1836 it.effect("catchCause activity", () =>
1837 Effect.gen(function*() {
1838 const flags = yield* Flags
1839 yield* TestClock.adjust(1)
1840
1841 const fiber = yield* CatchWorkflow.execute({
1842 id: ""
1843 }).pipe(Effect.forkScoped)
1844 yield* TestClock.adjust(1)
1845 yield* Fiber.join(fiber)
1846
1847 assert.isTrue(flags.get("catch"))
1848 }).pipe(Effect.provide(TestWorkflowLayer)))
1849
1850 it.effect("can serialize workflow defects", () =>
1851 Effect.gen(function*() {
1852 yield* TestClock.adjust(1)
1853
1854 const exit = yield* ErrorDefectWorkflow.execute({
1855 id: "raw-error-defect"
1856 }).pipe(Effect.exit)
1857
1858 assert(Exit.isFailure(exit))
1859 const defect = Cause.findDefect(exit.cause)
1860 assert(Result.isSuccess(defect))
1861 assert(defect.success instanceof Error)
1862 assert.strictEqual(defect.success.message, "Batch request error: Request timed out")
1863 }).pipe(Effect.provide(TestWorkflowLayer)))
1864})
1865
1866const makeTestWorkflowEngine = <Storage = MessageStorage.MemoryDriver>(options?: {
1867 readonly config?: Partial<ShardingConfig.ShardingConfig["Service"]> | undefined
1868 readonly shardingLayer?: typeof Sharding.layer | undefined
1869 readonly storageLayer?:
1870 | Layer.Layer<MessageStorage.MessageStorage | Storage, never, ShardingConfig.ShardingConfig>
1871 | undefined
1872}) =>
1873 ClusterWorkflowEngine.layer.pipe(
1874 Layer.provideMerge(options?.shardingLayer ?? Sharding.layer),
1875 Layer.provide(Runners.layerNoop),
1876 Layer.provideMerge(
1877 (options?.storageLayer ?? MessageStorage.layerMemory) as Layer.Layer<
1878 MessageStorage.MessageStorage | Storage,
1879 never,
1880 ShardingConfig.ShardingConfig
1881 >
1882 ),
1883 Layer.provide(RunnerStorage.layerMemory),
1884 Layer.provide(RunnerHealth.layerNoop),
1885 Layer.provide(ShardingConfig.layer({
1886 shardsPerGroup: 300,
1887 availableShardGroups: ["default", "workflow"],
1888 assignedShardGroups: ["default", "workflow"],
1889 entityMailboxCapacity: 10,
1890 entityTerminationTimeout: 0,
1891 entityMessagePollInterval: 5000,
1892 sendRetryInterval: 100,
1893 ...options?.config
1894 }))
1895 )
1896
1897const pollUntil = <A extends Schema.Top, E extends Schema.Top>(
1898 workflow: Workflow.Workflow<any, any, A, E>,
1899 executionId: string,
1900 tag: "Suspended" | "Complete"
1901) =>
1902 Effect.gen(function*() {
1903 const sharding = yield* Sharding.Sharding
1904 let result = yield* workflow.poll(executionId)
1905 for (let i = 0; i < 2000 && !(Option.isSome(result) && result.value._tag === tag); i++) {
1906 yield* Effect.yieldNow
1907 yield* TestClock.adjust(1)
1908 yield* sharding.pollStorage
1909 result = yield* workflow.poll(executionId)
1910 }
1911 assert(Option.isSome(result) && result.value._tag === tag, `workflow must reach ${tag}`)
1912 return result.value
1913 })
1914
1915const advanceUntil = (ready: () => boolean, message: string, step = 1, rounds = 2000) =>
1916 Effect.gen(function*() {
1917 for (let i = 0; i < rounds && !ready(); i++) yield* TestClock.adjust(step)
1918 assert(ready(), message)
1919 })
1920
1921const TestWorkflowEngine = makeTestWorkflowEngine()
1922
1923class SendEmailError extends Schema.Error<SendEmailError>("SendEmailError")({
1924 _tag: Schema.tag("SendEmailError"),
1925 message: Schema.String
1926}) {}
1927
1928const EmailWorkflow = Workflow.make("EmailWorkflow", {
1929 payload: {
1930 to: Schema.String,
1931 id: Schema.String
1932 },
1933 error: SendEmailError,
1934 idempotencyKey(payload) {
1935 return payload.id
1936 }
1937})
1938
1939class Flags extends Context.Service<Flags>()("Flags", {
1940 make: Effect.sync(() => new Map<string, boolean | number | string>())
1941}) {
1942 static readonly layer = Layer.effect(Flags, this.make)
1943}
1944
1945const EmailWorkflowLayer = EmailWorkflow.toLayer(Effect.fn(function*(payload) {
1946 const flags = yield* Flags
1947
1948 yield* Effect.addFinalizer(() =>
1949 Effect.sync(() => {
1950 flags.set("finalizer", true)
1951 })
1952 )
1953
1954 yield* Activity.make({
1955 name: "SendEmail",
1956 error: SendEmailError,
1957 execute: Effect.gen(function*() {
1958 const attempt = yield* Activity.CurrentAttempt
1959
1960 if (attempt !== 5) {
1961 return yield* new SendEmailError({
1962 message: `Failed to send email for ${payload.id} on attempt ${attempt}`
1963 })
1964 }
1965 })
1966 }).pipe(
1967 EmailWorkflow.withCompensation(Effect.fnUntraced(function*() {
1968 flags.set("compensation", true)
1969 })),
1970 Activity.retry({ times: 5 })
1971 )
1972
1973 if (payload.to === "compensation") {
1974 return yield* new SendEmailError({ message: `Compensation triggered` })
1975 }
1976
1977 const result = yield* Activity.make({
1978 name: "Sleep",
1979 success: Schema.DateTimeUtc,
1980 execute: Effect.gen(function*() {
1981 // suspended inside Activity
1982 yield* DurableClock.sleep({
1983 name: "Some sleep",
1984 duration: "10 seconds",
1985 inMemoryThreshold: Duration.zero
1986 })
1987 return yield* DateTime.now
1988 })
1989 })
1990 // test serialization from Activity
1991 assert(DateTime.isUtc(result))
1992
1993 yield* DurableDeferred.token(EmailTrigger)
1994 // suspended outside Activity
1995 yield* DurableDeferred.await(EmailTrigger).pipe(
1996 Effect.catchCause(() => {
1997 flags.set("catchCause", true)
1998 return Effect.void
1999 }),
2000 Effect.ensuring(Effect.sync(() => {
2001 flags.set("ensuring", true)
2002 }))
2003 )
2004})).pipe(
2005 Layer.provideMerge(Flags.layer)
2006)
2007
2008const EmailTrigger = DurableDeferred.make("EmailTrigger", {
2009 success: Schema.String
2010})
2011
2012const RaceWorkflow = Workflow.make("RaceWorkflow", {
2013 payload: {
2014 id: Schema.String
2015 },
2016 success: Schema.String,
2017 idempotencyKey: ({ id }) => id
2018})
2019
2020const RaceWorkflowLayer = RaceWorkflow.toLayer(Effect.fnUntraced(function*() {
2021 const flags = yield* Flags
2022
2023 yield* Effect.addFinalizer(() =>
2024 Effect.sync(() => {
2025 flags.set("finalizer", true)
2026 })
2027 )
2028
2029 return yield* Activity.raceAll("race", [
2030 Activity.make({
2031 name: "Activity1",
2032 success: Schema.String,
2033 error: Schema.Never,
2034 execute: Effect.onInterrupt(Effect.delay(Effect.succeed("Activity1"), 1000), () =>
2035 Effect.sync(() => {
2036 flags.set("interrupt1", true)
2037 }))
2038 }),
2039 Activity.make({
2040 name: "Activity2",
2041 success: Schema.String,
2042 error: Schema.Never,
2043 execute: Effect.onInterrupt(Effect.delay(Effect.succeed("Activity2"), 500), () =>
2044 Effect.sync(() => {
2045 flags.set("interrupt2", true)
2046 }))
2047 }),
2048 Activity.make({
2049 name: "Activity3",
2050 success: Schema.String,
2051 error: Schema.Never,
2052 execute: Effect.onInterrupt(Effect.delay(Effect.succeed("Activity3"), 100), () =>
2053 Effect.sync(() => {
2054 flags.set("interrupt3", true)
2055 }))
2056 })
2057 ])
2058}))
2059
2060const FailureRaceWorkflow = Workflow.make("FailureRaceWorkflow", {
2061 payload: { id: Schema.String },
2062 success: Schema.String,
2063 error: Schema.String,
2064 idempotencyKey: ({ id }) => id
2065})
2066
2067const FailureRaceWorkflowLayer = FailureRaceWorkflow.toLayer(() =>
2068 Activity.raceAll("failure-race", [
2069 Activity.make({
2070 name: "failure-race-fast",
2071 success: Schema.String,
2072 error: Schema.String,
2073 execute: Effect.fail("boom")
2074 }),
2075 Activity.make({
2076 name: "failure-race-slow",
2077 success: Schema.String,
2078 error: Schema.String,
2079 execute: Effect.sleep("1 second").pipe(Effect.as("slow"))
2080 })
2081 ])
2082)
2083
2084const DurableRaceWorkflow = Workflow.make("DurableRaceWorkflow", {
2085 payload: {
2086 id: Schema.String
2087 },
2088 success: Schema.String,
2089 idempotencyKey: ({ id }) => id
2090})
2091
2092const DurableRaceGate = DurableDeferred.make("DurableRaceGate")
2093
2094const DurableRaceWorkflowLayer = DurableRaceWorkflow.toLayer(Effect.fnUntraced(function*() {
2095 const flags = yield* Flags
2096
2097 yield* Effect.addFinalizer(() =>
2098 Effect.sync(() => {
2099 flags.set("finalizer", true)
2100 })
2101 )
2102
2103 const result = yield* Activity.raceAll("race", [
2104 Activity.make({
2105 name: "Activity1",
2106 success: Schema.String,
2107 error: Schema.Never,
2108 execute: DurableClock.sleep({
2109 name: "Activity1",
2110 duration: 50000,
2111 inMemoryThreshold: Duration.zero
2112 }).pipe(
2113 Effect.as("Activity1")
2114 )
2115 }),
2116 Activity.make({
2117 name: "Activity2",
2118 success: Schema.String,
2119 error: Schema.Never,
2120 execute: DurableClock.sleep({
2121 name: "Activity2",
2122 duration: 10000,
2123 inMemoryThreshold: Duration.zero
2124 }).pipe(
2125 Effect.as("Activity2")
2126 )
2127 }),
2128 Activity.make({
2129 name: "Activity3",
2130 success: Schema.String,
2131 error: Schema.Never,
2132 execute: DurableClock.sleep({
2133 name: "Activity3",
2134 duration: 1000,
2135 inMemoryThreshold: Duration.zero
2136 }).pipe(
2137 Effect.as("Activity3")
2138 )
2139 })
2140 ])
2141 flags.set("durable-race-token", yield* DurableDeferred.token(DurableRaceGate))
2142 yield* DurableDeferred.await(DurableRaceGate)
2143 return result
2144}))
2145
2146const MixedRaceWorkflow = Workflow.make("MixedRaceWorkflow", {
2147 payload: { id: Schema.String },
2148 success: Schema.String,
2149 idempotencyKey: ({ id }) => id
2150})
2151
2152const MixedRaceGate = DurableDeferred.make("MixedRaceGate", {
2153 success: Schema.String
2154})
2155
2156const MixedRaceWorkflowLayer = MixedRaceWorkflow.toLayer(() =>
2157 DurableDeferred.raceAll({
2158 name: "mixed-race",
2159 success: Schema.String,
2160 error: Schema.Never,
2161 effects: [
2162 DurableDeferred.await(MixedRaceGate),
2163 Activity.make({
2164 name: "mixed-race-activity",
2165 success: Schema.String,
2166 execute: Effect.sleep("1 second").pipe(Effect.as("activity"))
2167 })
2168 ]
2169 })
2170)
2171
2172const LosingDeferredWorkflow = Workflow.make("LosingDeferredWorkflow", {
2173 payload: { id: Schema.String },
2174 success: Schema.String,
2175 idempotencyKey: ({ id }) => id
2176})
2177
2178const LosingDeferredGate = DurableDeferred.make("LosingDeferredGate", {
2179 success: Schema.String
2180})
2181
2182const LosingDeferredWorkflowLayer = LosingDeferredWorkflow.toLayer(Effect.fnUntraced(function*() {
2183 const flags = yield* Flags
2184 const winner = yield* DurableDeferred.raceAll({
2185 name: "losing-deferred",
2186 success: Schema.String,
2187 error: Schema.Never,
2188 effects: [
2189 DurableDeferred.await(LosingDeferredGate),
2190 Activity.make({
2191 name: "losing-deferred-activity",
2192 success: Schema.String,
2193 execute: Effect.sleep("1 second").pipe(Effect.as("activity"))
2194 })
2195 ]
2196 })
2197 const tail = yield* Activity.make({
2198 name: "losing-deferred-tail",
2199 success: Schema.String,
2200 execute: Effect.suspend(() => {
2201 const runs = flags.get("losing-deferred-tail-runs")
2202 flags.set("losing-deferred-tail-runs", typeof runs === "number" ? runs + 1 : 1)
2203 return Effect.sleep("10 seconds").pipe(Effect.as("tail"))
2204 })
2205 })
2206 return `${winner}:${tail}`
2207}))
2208
2209const InPlaceWakeWorkflow = Workflow.make("InPlaceWakeWorkflow", {
2210 payload: { id: Schema.String },
2211 success: Schema.String,
2212 idempotencyKey: ({ id }) => id
2213})
2214
2215const InPlaceWakeGate = DurableDeferred.make("InPlaceWakeGate", {
2216 success: Schema.String
2217})
2218
2219const InPlaceWakeWorkflowLayer = InPlaceWakeWorkflow.toLayer(Effect.fnUntraced(function*() {
2220 const flags = yield* Flags
2221 const runs = flags.get("in-place-wake-runs")
2222 flags.set("in-place-wake-runs", typeof runs === "number" ? runs + 1 : 1)
2223 return yield* DurableDeferred.raceAll({
2224 name: "in-place-wake",
2225 success: Schema.String,
2226 error: Schema.Never,
2227 effects: [
2228 DurableDeferred.await(InPlaceWakeGate),
2229 Activity.make({
2230 name: "in-place-wake-activity",
2231 success: Schema.String,
2232 execute: Effect.sleep("5 seconds").pipe(Effect.as("activity"))
2233 })
2234 ]
2235 })
2236}))
2237
2238const IntoWrapWorkflow = Workflow.make("IntoWrapWorkflow", {
2239 payload: { id: Schema.String },
2240 success: Schema.String,
2241 idempotencyKey: ({ id }) => id
2242})
2243
2244const IntoWrapGate = DurableDeferred.make("IntoWrapGate", {
2245 success: Schema.String
2246})
2247
2248const IntoWrapAux = DurableDeferred.make("IntoWrapAux", {
2249 success: Schema.String
2250})
2251
2252const IntoWrapWorkflowLayer = IntoWrapWorkflow.toLayer(() =>
2253 DurableDeferred.raceAll({
2254 name: "into-wrap",
2255 success: Schema.String,
2256 error: Schema.Never,
2257 effects: [
2258 DurableDeferred.into(DurableDeferred.await(IntoWrapGate), IntoWrapAux),
2259 Activity.make({
2260 name: "into-wrap-activity",
2261 success: Schema.String,
2262 execute: Effect.sleep("30 seconds").pipe(Effect.as("activity"))
2263 })
2264 ]
2265 })
2266)
2267
2268const ClockCaptureWorkflow = Workflow.make("ClockCaptureWorkflow", {
2269 payload: { id: Schema.String },
2270 success: Schema.String,
2271 idempotencyKey: ({ id }) => id
2272})
2273
2274const ClockCaptureWorkflowLayer = ClockCaptureWorkflow.toLayer(() =>
2275 Activity.raceAll("clock-capture", [
2276 Activity.make({
2277 name: "clock-capture-durable",
2278 success: Schema.String,
2279 execute: DurableClock.sleep({
2280 name: "clock-capture-durable",
2281 duration: 5000,
2282 inMemoryThreshold: Duration.zero
2283 }).pipe(Effect.as("clock"))
2284 }),
2285 Activity.make({
2286 name: "clock-capture-slow",
2287 success: Schema.String,
2288 execute: Effect.sleep("30 seconds").pipe(Effect.as("slow"))
2289 })
2290 ])
2291)
2292
2293const BareClockWorkflow = Workflow.make("BareClockWorkflow", {
2294 payload: { id: Schema.String },
2295 success: Schema.String,
2296 idempotencyKey: ({ id }) => id
2297})
2298
2299const BareClockWorkflowLayer = BareClockWorkflow.toLayer(() =>
2300 DurableDeferred.raceAll({
2301 name: "bare-clock",
2302 success: Schema.String,
2303 error: Schema.Never,
2304 effects: [
2305 DurableClock.sleep({
2306 name: "bare-clock-timer",
2307 duration: 5000,
2308 inMemoryThreshold: Duration.zero
2309 }).pipe(Effect.as("clock")),
2310 Activity.make({
2311 name: "bare-clock-slow",
2312 success: Schema.String,
2313 execute: Effect.sleep("30 seconds").pipe(Effect.as("slow"))
2314 })
2315 ]
2316 })
2317)
2318
2319const MappedGateWorkflow = Workflow.make("MappedGateWorkflow", {
2320 payload: { id: Schema.String },
2321 success: Schema.String,
2322 idempotencyKey: ({ id }) => id
2323})
2324
2325const MappedGate = DurableDeferred.make("MappedGate", {
2326 success: Schema.String
2327})
2328
2329const MappedGateWorkflowLayer = MappedGateWorkflow.toLayer(() =>
2330 DurableDeferred.raceAll({
2331 name: "mapped-gate",
2332 success: Schema.String,
2333 error: Schema.Never,
2334 effects: [
2335 DurableDeferred.await(MappedGate).pipe(Effect.map((s) => `${s}!`)),
2336 Activity.make({
2337 name: "mapped-gate-slow",
2338 success: Schema.String,
2339 execute: Effect.sleep("30 seconds").pipe(Effect.as("slow"))
2340 })
2341 ]
2342 })
2343)
2344
2345const PreGateWorkflow = Workflow.make("PreGateWorkflow", {
2346 payload: { id: Schema.String },
2347 success: Schema.String,
2348 idempotencyKey: ({ id }) => id
2349})
2350
2351const PreGate = DurableDeferred.make("PreGate", {
2352 success: Schema.String
2353})
2354
2355const PreGateWorkflowLayer = PreGateWorkflow.toLayer(() =>
2356 DurableDeferred.raceAll({
2357 name: "pre-gate",
2358 success: Schema.String,
2359 error: Schema.Never,
2360 effects: [
2361 Effect.gen(function*() {
2362 const a = yield* Activity.make({
2363 name: "pre-gate-activity",
2364 success: Schema.String,
2365 execute: Effect.succeed("act")
2366 })
2367 const s = yield* DurableDeferred.await(PreGate)
2368 return `${a}:${s}`
2369 }),
2370 Activity.make({
2371 name: "pre-gate-slow",
2372 success: Schema.String,
2373 execute: Effect.sleep("30 seconds").pipe(Effect.as("slow"))
2374 })
2375 ]
2376 })
2377)
2378
2379const TwoStepWorkflow = Workflow.make("TwoStepWorkflow", {
2380 payload: { id: Schema.String },
2381 success: Schema.String,
2382 idempotencyKey: ({ id }) => id
2383})
2384
2385const TwoStepGateA = DurableDeferred.make("TwoStepGateA", {
2386 success: Schema.String
2387})
2388
2389const TwoStepGateB = DurableDeferred.make("TwoStepGateB", {
2390 success: Schema.String
2391})
2392
2393class TwoStepBranchControl extends Context.Service<TwoStepBranchControl>()("TwoStepBranchControl", {
2394 make: Effect.all({
2395 entered: Latch.make(),
2396 release: Latch.make(),
2397 started: Latch.make()
2398 })
2399}) {
2400 static readonly layer = Layer.effect(TwoStepBranchControl, this.make)
2401}
2402
2403const TwoStepWorkflowLayer = TwoStepWorkflow.toLayer(() =>
2404 DurableDeferred.raceAll({
2405 name: "two-step",
2406 success: Schema.String,
2407 error: Schema.Never,
2408 effects: [
2409 Effect.gen(function*() {
2410 const flags = yield* Flags
2411 const branchControl = yield* TwoStepBranchControl
2412 branchControl.entered.openUnsafe()
2413 yield* branchControl.release.await
2414 const runs = flags.get("two-step-branch-runs")
2415 flags.set("two-step-branch-runs", typeof runs === "number" ? runs + 1 : 1)
2416 branchControl.started.openUnsafe()
2417 const a = yield* DurableDeferred.await(TwoStepGateA)
2418 const b = yield* DurableDeferred.await(TwoStepGateB)
2419 return `${a}:${b}`
2420 }),
2421 // a live branch that holds no activity slot, so wake re-runs are
2422 // processed while the race stays active
2423 Effect.never
2424 ]
2425 })
2426)
2427
2428const SlowUnwindWorkflow = Workflow.make("SlowUnwindWorkflow", {
2429 payload: { id: Schema.String },
2430 success: Schema.String,
2431 idempotencyKey: ({ id }) => id
2432})
2433
2434const SlowUnwindGateA = DurableDeferred.make("SlowUnwindGateA", {
2435 success: Schema.String
2436})
2437
2438const SlowUnwindGateB = DurableDeferred.make("SlowUnwindGateB", {
2439 success: Schema.String
2440})
2441
2442const SlowUnwindWorkflowLayer = SlowUnwindWorkflow.toLayer(Effect.fnUntraced(function*() {
2443 const flags = yield* Flags
2444 const runs = flags.get("slow-unwind-runs")
2445 flags.set("slow-unwind-runs", typeof runs === "number" ? runs + 1 : 1)
2446 return yield* DurableDeferred.raceAll({
2447 name: "slow-unwind",
2448 success: Schema.String,
2449 error: Schema.Never,
2450 effects: [
2451 DurableDeferred.await(SlowUnwindGateA),
2452 DurableDeferred.await(SlowUnwindGateB)
2453 ]
2454 }).pipe(
2455 // slows the unwind so completions can land while a suspension commits
2456 Effect.ensuring(
2457 Effect.sync(() => flags.set("slow-unwind-started", true)).pipe(
2458 Effect.andThen(Effect.sleep("10 seconds"))
2459 )
2460 )
2461 )
2462}))
2463
2464const TwoGateWorkflow = Workflow.make("TwoGateWorkflow", {
2465 payload: { id: Schema.String },
2466 success: Schema.String,
2467 idempotencyKey: ({ id }) => id
2468})
2469
2470const TwoGateA = DurableDeferred.make("TwoGateA", {
2471 success: Schema.String
2472})
2473
2474const TwoGateB = DurableDeferred.make("TwoGateB", {
2475 success: Schema.String
2476})
2477
2478const TwoGateWorkflowLayer = TwoGateWorkflow.toLayer(() =>
2479 DurableDeferred.raceAll({
2480 name: "two-gates",
2481 success: Schema.String,
2482 error: Schema.Never,
2483 effects: [
2484 DurableDeferred.await(TwoGateA),
2485 DurableDeferred.await(TwoGateB)
2486 ]
2487 })
2488)
2489
2490const ParentWorkflow = Workflow.make("ParentWorkflow", {
2491 payload: {
2492 id: Schema.String
2493 },
2494 idempotencyKey(payload) {
2495 return payload.id
2496 }
2497})
2498
2499const ChildWorkflow = Workflow.make("ChildWorkflow", {
2500 payload: {
2501 id: Schema.String
2502 },
2503 idempotencyKey(payload) {
2504 return payload.id
2505 }
2506})
2507
2508const ParentWorkflowLayer = ParentWorkflow.toLayer(Effect.fnUntraced(function*({ id }) {
2509 const flags = yield* Flags
2510 const instance = yield* WorkflowInstance
2511 yield* Effect.addFinalizer(() =>
2512 Effect.sync(() => {
2513 flags.set("parent-suspended", instance.suspended)
2514 })
2515 )
2516 yield* ChildWorkflow.execute({ id })
2517 flags.set("parent-end", true)
2518}))
2519
2520const ChildDeferred = DurableDeferred.make("ChildDeferred")
2521const ChildWorkflowLayer = ChildWorkflow.toLayer(Effect.fnUntraced(function*() {
2522 const flags = yield* Flags
2523 flags.set("child-token", yield* DurableDeferred.token(ChildDeferred))
2524 yield* DurableDeferred.await(ChildDeferred)
2525 flags.set("child-end", true)
2526}))
2527
2528const ParallelParentWorkflow = Workflow.make("ParallelParentWorkflow", {
2529 payload: {
2530 id: Schema.String,
2531 childCount: Schema.Number,
2532 concurrency: Schema.Number,
2533 sleep: Schema.Number
2534 },
2535 success: Schema.Array(Schema.String),
2536 idempotencyKey(payload) {
2537 return payload.id
2538 }
2539})
2540
2541const ParallelDirectWorkflow = Workflow.make("ParallelDirectWorkflow", {
2542 payload: {
2543 id: Schema.String,
2544 childCount: Schema.Number
2545 },
2546 success: Schema.Array(Schema.String),
2547 idempotencyKey(payload) {
2548 return payload.id
2549 }
2550})
2551
2552const ParallelChildWorkflow = Workflow.make("ParallelChildWorkflow", {
2553 payload: {
2554 id: Schema.String,
2555 index: Schema.Number,
2556 sleep: Schema.Number
2557 },
2558 success: Schema.String,
2559 idempotencyKey(payload) {
2560 return payload.id
2561 }
2562})
2563
2564const increment = (flags: Map<string, boolean | number | string>, key: string) => {
2565 flags.set(key, Number(flags.get(key) ?? 0) + 1)
2566}
2567
2568const ParallelParentWorkflowLayer = ParallelParentWorkflow.toLayer(Effect.fnUntraced(function*(payload) {
2569 const flags = yield* Flags
2570 increment(flags, `parallel-runs-${payload.id}`)
2571 const indices = Array.from({ length: payload.childCount }, (_, i) => i)
2572 return yield* Activity.make({
2573 name: "parallel-children",
2574 success: Schema.Array(Schema.String),
2575 error: Schema.Never,
2576 execute: Effect.suspend(() => {
2577 increment(flags, `parallel-activity-runs-${payload.id}`)
2578 return Effect.forEach(
2579 indices,
2580 (index) => ParallelChildWorkflow.execute({ id: `${payload.id}-child-${index}`, index, sleep: payload.sleep }),
2581 { concurrency: payload.concurrency }
2582 )
2583 }).pipe(Effect.ensuring(Effect.sync(() => increment(flags, `parallel-activity-releases-${payload.id}`))))
2584 })
2585}))
2586
2587const ParallelDirectWorkflowLayer = ParallelDirectWorkflow.toLayer(Effect.fnUntraced(function*(payload) {
2588 const flags = yield* Flags
2589 increment(flags, `parallel-runs-${payload.id}`)
2590 const indices = Array.from({ length: payload.childCount }, (_, i) => i)
2591 return yield* Effect.forEach(
2592 indices,
2593 (index) => ParallelChildWorkflow.execute({ id: `${payload.id}-child-${index}`, index, sleep: 2000 }),
2594 { concurrency: "unbounded" }
2595 )
2596}))
2597
2598const ParallelChildWorkflowLayer = ParallelChildWorkflow.toLayer(Effect.fnUntraced(function*(payload) {
2599 const flags = yield* Flags
2600 flags.set(`parallel-child-start-${payload.id}`, true)
2601 yield* DurableClock.sleep({
2602 name: "parallel-child",
2603 duration: payload.sleep,
2604 inMemoryThreshold: Duration.zero
2605 })
2606 return `done-${payload.index}`
2607}))
2608
2609const ZeroThresholdWorkflow = Workflow.make("DurableClock/ZeroThreshold", {
2610 payload: { id: Schema.String },
2611 idempotencyKey: ({ id }) => id
2612})
2613
2614const verifyZeroThreshold = Effect.fn(function*(id: string, inMemoryThreshold: 0 | 0n) {
2615 const instance = WorkflowInstance.initial(ZeroThresholdWorkflow, `execution/${id}`)
2616 const calls: Array<string> = []
2617 const unexpected = () => Effect.die("unexpected engine operation")
2618 const engine = makeWorkflowEngineUnsafe({
2619 register: unexpected,
2620 execute: unexpected,
2621 poll: unexpected,
2622 interrupt: unexpected,
2623 interruptUnsafe: unexpected,
2624 resume: unexpected,
2625 deferredDone: unexpected,
2626 activityExecute: () =>
2627 Effect.sync(() => {
2628 calls.push("activityExecute")
2629 return new Workflow.Complete({ exit: Exit.void })
2630 }),
2631 scheduleClock: () =>
2632 Effect.sync(() => {
2633 calls.push("scheduleClock")
2634 }),
2635 deferredResult: () =>
2636 Effect.sync(() => {
2637 calls.push("deferredResult")
2638 return Option.some(Exit.void)
2639 })
2640 })
2641 yield* DurableClock.sleep({ name: `clock/${id}`, duration: 10, inMemoryThreshold }).pipe(
2642 Effect.provideService(WorkflowEngine, engine),
2643 Effect.provideService(WorkflowInstance, instance)
2644 )
2645 assert.deepStrictEqual(calls, ["scheduleClock", "deferredResult"])
2646})
2647
2648const ShardedClockWorkflow = Workflow.make("ShardedClockWorkflow", {
2649 payload: {
2650 id: Schema.String
2651 },
2652 idempotencyKey(payload) {
2653 return payload.id
2654 }
2655}).annotate(ClusterSchema.ShardGroup, () => "workflow")
2656
2657class ShardedClockScheduled extends Context.Service<ShardedClockScheduled>()("ShardedClockScheduled", {
2658 make: Latch.make()
2659}) {
2660 static readonly layer = Layer.effect(ShardedClockScheduled, this.make)
2661}
2662
2663const ShardedClockWorkflowLayer = ShardedClockWorkflow.toLayer(Effect.fnUntraced(function*() {
2664 const engine = yield* WorkflowEngine
2665 const instance = yield* WorkflowInstance
2666 const scheduled = yield* ShardedClockScheduled
2667 const clock = DurableClock.make({
2668 name: "ShardedClock",
2669 duration: 10000.5
2670 })
2671 yield* engine.scheduleClock(instance.workflow, {
2672 executionId: instance.executionId,
2673 clock
2674 })
2675 yield* scheduled.open
2676 yield* DurableDeferred.await(clock.deferred)
2677}))
2678
2679const ShardedDeferred = DurableDeferred.make("ShardedDeferred")
2680
2681const ShardedDeferredWorkflow = Workflow.make("ShardedDeferredWorkflow", {
2682 payload: {
2683 id: Schema.String
2684 },
2685 idempotencyKey(payload) {
2686 return payload.id
2687 }
2688}).annotate(ClusterSchema.ShardGroup, () => "workflow")
2689
2690const SuspendOnFailureWorkflow = Workflow.make("SuspendOnFailureWorkflow", {
2691 payload: {
2692 id: Schema.String
2693 },
2694 idempotencyKey(payload) {
2695 return payload.id
2696 }
2697}).annotate(Workflow.SuspendOnFailure, true)
2698
2699const SuspendOnFailureWorkflowLayer = SuspendOnFailureWorkflow.toLayer(Effect.fnUntraced(function*() {
2700 const flags = yield* Flags
2701 const instance = yield* WorkflowInstance
2702 yield* Effect.addFinalizer(() =>
2703 Effect.sync(() => {
2704 flags.set("suspended", instance.suspended)
2705 flags.set("cause", Cause.pretty(instance.cause!))
2706 })
2707 )
2708 yield* Activity.make({
2709 name: "fail",
2710 execute: Effect.die("boom")
2711 })
2712}))
2713
2714const CatchWorkflow = Workflow.make("CatchWorkflow", {
2715 payload: {
2716 id: Schema.String
2717 },
2718 idempotencyKey(payload) {
2719 return payload.id
2720 }
2721})
2722
2723const CatchWorkflowLayer = CatchWorkflow.toLayer(Effect.fnUntraced(function*() {
2724 const flags = yield* Flags
2725 yield* Activity.make({
2726 name: "fail",
2727 execute: Effect.die("boom")
2728 }).pipe(
2729 Effect.catchCause((cause) =>
2730 Activity.make({
2731 name: "log",
2732 execute: Effect.suspend(() => {
2733 flags.set("catch", true)
2734 return Effect.log(cause)
2735 })
2736 })
2737 )
2738 )
2739}))
2740
2741const ErrorDefectWorkflow = Workflow.make("ErrorDefectWorkflow", {
2742 payload: {
2743 id: Schema.String
2744 },
2745 idempotencyKey(payload) {
2746 return payload.id
2747 }
2748})
2749
2750const ErrorDefectWorkflowLayer = ErrorDefectWorkflow.toLayer(Effect.fnUntraced(function*() {
2751 yield* Activity.make({
2752 name: "raw-error-defect",
2753 execute: Effect.die(makeBatchRequestError())
2754 })
2755}))
2756
2757const makeBatchRequestError = () => {
2758 const error = new Error("Batch request error: Request timed out")
2759 Object.defineProperty(error, "nonJson", {
2760 enumerable: true,
2761 value: 1n
2762 })
2763 return error
2764}
2765
2766const RaceWorkflowLayers = RaceWorkflowLayer.pipe(
2767 Layer.merge(FailureRaceWorkflowLayer),
2768 Layer.merge(DurableRaceWorkflowLayer),
2769 Layer.merge(MixedRaceWorkflowLayer),
2770 Layer.merge(LosingDeferredWorkflowLayer),
2771 Layer.merge(InPlaceWakeWorkflowLayer),
2772 Layer.merge(IntoWrapWorkflowLayer),
2773 Layer.merge(ClockCaptureWorkflowLayer),
2774 Layer.merge(BareClockWorkflowLayer),
2775 Layer.merge(MappedGateWorkflowLayer),
2776 Layer.merge(TwoStepWorkflowLayer),
2777 Layer.merge(PreGateWorkflowLayer),
2778 Layer.merge(SlowUnwindWorkflowLayer),
2779 Layer.merge(TwoGateWorkflowLayer)
2780)
2781
2782const TestWorkflowLayer = EmailWorkflowLayer.pipe(
2783 Layer.merge(RaceWorkflowLayers),
2784 Layer.merge(ParentWorkflowLayer),
2785 Layer.merge(ChildWorkflowLayer),
2786 Layer.merge(ParallelParentWorkflowLayer),
2787 Layer.merge(ParallelDirectWorkflowLayer),
2788 Layer.merge(ParallelChildWorkflowLayer),
2789 Layer.merge(ShardedClockWorkflowLayer),
2790 Layer.merge(SuspendOnFailureWorkflowLayer),
2791 Layer.merge(CatchWorkflowLayer),
2792 Layer.merge(ErrorDefectWorkflowLayer),
2793 Layer.provideMerge(Flags.layer),
2794 Layer.provideMerge(TwoStepBranchControl.layer),
2795 Layer.provideMerge(ShardedClockScheduled.layer),
2796 Layer.provideMerge(TestWorkflowEngine)
2797)