From 3e25f4fa8b2cb61eaca811b8899f58b22745ed63 Mon Sep 17 00:00:00 2001 From: Jason Calem Date: Tue, 19 May 2026 16:33:52 -0400 Subject: [PATCH 1/2] fix(workflow): forward parent pointer when spawning a child with discard:true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discard fast-path in WorkflowEngine.execute omitted the `parent` option when delegating to the engine, so ClusterWorkflowEngine never wrote `"~@effect/workflow/parent"` into the child's persisted payload. This broke causality tracking for fire-and-forget fan-outs — a common pattern when one workflow spawns many children and doesn't need to await them. Discarded children now carry the same parent pointer non-discarded children already did, restoring observability without changing fire-and-forget semantics. Co-Authored-By: Claude Opus 4.7 --- .changeset/forward-parent-pointer-discard.md | 7 +++ .../test/ClusterWorkflowEngine.test.ts | 62 +++++++++++++++++++ packages/workflow/src/WorkflowEngine.ts | 3 +- 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 .changeset/forward-parent-pointer-discard.md diff --git a/.changeset/forward-parent-pointer-discard.md b/.changeset/forward-parent-pointer-discard.md new file mode 100644 index 00000000000..8025f413afe --- /dev/null +++ b/.changeset/forward-parent-pointer-discard.md @@ -0,0 +1,7 @@ +--- +"@effect/workflow": patch +--- + +Forward the parent pointer when spawning a child workflow with `discard: true`. + +`Workflow.execute(payload, { discard: true })` took a fast path inside `WorkflowEngine.execute` that omitted the `parent` option when delegating to the underlying engine. As a result, the cluster engine never wrote `"~@effect/workflow/parent"` into the child's persisted payload, breaking causality tracking for fire-and-forget fan-outs (the common pattern for batching workflows over many items). Discarded children now carry the same parent pointer that non-discarded children already did, so observability tools can link them back to their parent. diff --git a/packages/cluster/test/ClusterWorkflowEngine.test.ts b/packages/cluster/test/ClusterWorkflowEngine.test.ts index dc4a9317e4e..01e60053686 100644 --- a/packages/cluster/test/ClusterWorkflowEngine.test.ts +++ b/packages/cluster/test/ClusterWorkflowEngine.test.ts @@ -245,6 +245,38 @@ describe.concurrent("ClusterWorkflowEngine", () => { assert.isTrue(flags.get("catch")) }).pipe(Effect.provide(TestWorkflowLayer))) + + // Regression: the discard fast-path in WorkflowEngine.execute used to drop the `parent` option, + // leaving observability tools unable to link discarded children back to their parent. + it.effect("forwards parent pointer when spawning a child with discard:true", () => + Effect.gen(function*() { + const driver = yield* MessageStorage.MemoryDriver + const fiber = yield* DiscardParentWorkflow.execute({ id: "discard-parent-1" }).pipe( + Effect.fork + ) + yield* TestClock.adjust(1) + yield* Fiber.join(fiber) + + const findRun = (entityType: string) => + driver.journal.find( + (envelope) => + envelope._tag === "Request" && + envelope.address.entityType === entityType && + envelope.tag === "run" + ) + const parentRun = findRun("Workflow/DiscardParentWorkflow") + const childRun = findRun("Workflow/DiscardChildWorkflow") + assert.exists(parentRun, "expected a run envelope for the parent workflow") + assert.exists(childRun, "expected a run envelope for the child workflow") + + const childPayload = (childRun as { payload: Record }).payload + const parent = childPayload["~@effect/workflow/parent"] as + | { workflowName: string; executionId: string } + | undefined + assert.exists(parent, "child payload should carry the parent pointer") + expect(parent!.workflowName).toEqual("DiscardParentWorkflow") + expect(parent!.executionId).toEqual((parentRun as { address: { entityId: string } }).address.entityId) + }).pipe(Effect.provide(TestWorkflowLayer))) }) const TestShardingConfig = ShardingConfig.layer({ @@ -498,6 +530,34 @@ const ChildWorkflowLayer = ChildWorkflow.toLayer(Effect.fnUntraced(function*() { flags.set("child-end", true) })) +const DiscardParentWorkflow = Workflow.make({ + name: "DiscardParentWorkflow", + payload: { id: Schema.String }, + idempotencyKey(payload) { + return payload.id + } +}) + +const DiscardChildWorkflow = Workflow.make({ + name: "DiscardChildWorkflow", + payload: { id: Schema.String }, + idempotencyKey(payload) { + return payload.id + } +}) + +const DiscardParentWorkflowLayer = DiscardParentWorkflow.toLayer( + Effect.fnUntraced(function*({ id }) { + yield* DiscardChildWorkflow.execute({ id: `${id}-child` }, { discard: true }) + }) +) + +const DiscardChildWorkflowLayer = DiscardChildWorkflow.toLayer( + Effect.fnUntraced(function*() { + return yield* Effect.void + }) +) + const SuspendOnFailureWorkflow = Workflow.make({ name: "SuspendOnFailureWorkflow", payload: { @@ -556,6 +616,8 @@ const TestWorkflowLayer = EmailWorkflowLayer.pipe( Layer.merge(DurableRaceWorkflowLayer), Layer.merge(ParentWorkflowLayer), Layer.merge(ChildWorkflowLayer), + Layer.merge(DiscardParentWorkflowLayer), + Layer.merge(DiscardChildWorkflowLayer), Layer.merge(SuspendOnFailureWorkflowLayer), Layer.merge(CatchWorkflowLayer), Layer.provideMerge(Flags.Default), diff --git a/packages/workflow/src/WorkflowEngine.ts b/packages/workflow/src/WorkflowEngine.ts index 5a2b58e0280..bf84d944b4c 100644 --- a/packages/workflow/src/WorkflowEngine.ts +++ b/packages/workflow/src/WorkflowEngine.ts @@ -366,7 +366,8 @@ export const makeUnsafe = (options: Encoded): WorkflowEngine["Type"] => yield* options.execute(self, { executionId, payload: payload as object, - discard: true + discard: true, + parent: Option.getOrUndefined(parentInstance) }) return executionId } From fdd957bdd6168cef58c54250e8a5ddf283815bd5 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 20 May 2026 09:32:25 +1200 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Tim --- .changeset/forward-parent-pointer-discard.md | 4 +--- packages/cluster/test/ClusterWorkflowEngine.test.ts | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.changeset/forward-parent-pointer-discard.md b/.changeset/forward-parent-pointer-discard.md index 8025f413afe..b55505ef1ec 100644 --- a/.changeset/forward-parent-pointer-discard.md +++ b/.changeset/forward-parent-pointer-discard.md @@ -2,6 +2,4 @@ "@effect/workflow": patch --- -Forward the parent pointer when spawning a child workflow with `discard: true`. - -`Workflow.execute(payload, { discard: true })` took a fast path inside `WorkflowEngine.execute` that omitted the `parent` option when delegating to the underlying engine. As a result, the cluster engine never wrote `"~@effect/workflow/parent"` into the child's persisted payload, breaking causality tracking for fire-and-forget fan-outs (the common pattern for batching workflows over many items). Discarded children now carry the same parent pointer that non-discarded children already did, so observability tools can link them back to their parent. +Forward the parent pointer when spawning a child workflow with `discard: true` diff --git a/packages/cluster/test/ClusterWorkflowEngine.test.ts b/packages/cluster/test/ClusterWorkflowEngine.test.ts index 01e60053686..5b42e034204 100644 --- a/packages/cluster/test/ClusterWorkflowEngine.test.ts +++ b/packages/cluster/test/ClusterWorkflowEngine.test.ts @@ -246,8 +246,6 @@ describe.concurrent("ClusterWorkflowEngine", () => { assert.isTrue(flags.get("catch")) }).pipe(Effect.provide(TestWorkflowLayer))) - // Regression: the discard fast-path in WorkflowEngine.execute used to drop the `parent` option, - // leaving observability tools unable to link discarded children back to their parent. it.effect("forwards parent pointer when spawning a child with discard:true", () => Effect.gen(function*() { const driver = yield* MessageStorage.MemoryDriver