WorkflowEngine.layerMemory: `withCompensation` runs the compensation once per workflow run when a replayed workflow fails
I maintainer di solito rispondono entro 1 giorno
Una pull request collegata è già stata integrata.
- #9239 di @effect-bot — integrata
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 42/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- typescript
- Ambito
- backend
Direzione di ricerca
Start with the scope handling in packages/effect/src/workflow/WorkflowEngine.ts around lines 787-791 (layerMemory gives each replay the previous run's scope) and 900 (the interrupt replay), and with Workflow.withCompensation in Workflow.ts around lines 976-980. Run the failing test in packages/effect/test/workflow/WorkflowEngine.test.ts, which the issue includes. Done means the replay case records one refund, matching the cluster engine, and the existing finalizer tests still pass.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
What version of Effect is running?
[email protected] (also main at bc07bd4). Started in 4.0.0-beta.104; 4.0.0-beta.103 runs the compensation once.
What steps can reproduce the bug?
On WorkflowEngine.layerMemory, if a workflow is replayed after a withCompensation step (woken by a DurableDeferred, resumed, or interrupted while suspended) and then fails, the compensation runs once per run of the workflow body. The activity itself runs once.
import { Effect, Layer, Option, Schema } from "effect"
import { Activity, DurableDeferred, Workflow, WorkflowEngine } from "effect/workflow"
const log = []
const Order = Workflow.make("Order", {
payload: { id: Schema.String },
error: Schema.String,
idempotencyKey: ({ id }) => id
})
const Approval = DurableDeferred.make("Approval", { error: Schema.String })
const OrderLayer = Order.toLayer(Effect.fnUntraced(function*() {
yield* Activity.make({
name: "charge",
success: Schema.String,
execute: Effect.sync(() => {
log.push("charge")
return "ch_1"
})
}).pipe(Order.withCompensation((id) => Effect.sync(() => log.push(`refund ${id}`))))
yield* DurableDeferred.await(Approval)
}))
const waitFor = Effect.fnUntraced(function*(executionId, tag) {
while (true) {
const result = yield* Order.poll(executionId)
if (Option.isSome(result) && result.value._tag === tag) return
yield* Effect.sleep("10 millis")
}
})
const program = Effect.gen(function*() {
const executionId = yield* Order.execute({ id: "order-1" }, { discard: true })
yield* waitFor(executionId, "Suspended")
// Wakes the workflow; the replay fails it
yield* DurableDeferred.fail(Approval, {
token: DurableDeferred.tokenFromExecutionId(Approval, { workflow: Order, executionId }),
error: "rejected"
})
yield* waitFor(executionId, "Complete")
console.log(log)
})
Effect.runPromise(program.pipe(Effect.provide(OrderLayer.pipe(Layer.provideMerge(WorkflowEngine.layerMemory)))))
What is the expected behavior?
[ 'charge', 'refund ch_1' ]
One compensation for the one completed step ("The compensation finalizer is called if the entire workflow fails"). ClusterWorkflowEngine does this.
What do you see instead?
[ 'charge', 'refund ch_1', 'refund ch_1' ]
The count grows with the number of runs:
- Calling
Order.resume(executionId)before the rejection gives three refunds. Order.interrupt(executionId)in place of the rejection gives two.- Failing without suspending gives one.
The same program on ClusterWorkflowEngine.layer (with MessageStorage.layerMemory) gives one in each case.
Additional information
withCompensationadds the compensation toinstance.scope(viaWorkflow.addFinalizer) every time the step yields its value, including when a replay reads the cached activity result (Workflow.ts:976-980).- Since #7000,
layerMemorygives each replay the previous run's scope (WorkflowEngine.ts:787-791). Every run's registration stays in that scope, and closing it on failure runs them all. ClusterWorkflowEnginecreates a freshWorkflowInstanceper run (ClusterWorkflowEngine.ts:496), so only the last run's registration fires. The cluster integration suite pins one compensation for this workflow body after it replays on a new owner (cluster-integration/Workflow.test.ts:714).- Both engines say a pending interrupt "replays only to rebuild compensations" (
WorkflowEngine.ts:900). OnlayerMemorythat replay adds another copy instead. - Plain
Workflow.addFinalizerdiffers the same way. On memory every run's finalizer runs ([2, 1], pinned bylayerMemory closes finalizers registered before suspension); on cluster only the last run's runs ([2]). Giving the cluster engine the same whole-lifetime scope therefore wouldn't fix this.
Failing test for packages/effect/test/workflow/WorkflowEngine.test.ts (the replay: false case passes)
for (const replay of [false, true]) {
it.effect(`layerMemory runs a compensation once when the workflow fails${replay ? " after a replay" : ""}`, () =>
Effect.gen(function*() {
const approval = DurableDeferred.make("WorkflowEngine/ReplayCompensation/Approval", {
error: Schema.String
})
const charges: Array<string> = []
const refunds: Array<string> = []
let runs = 0
const Order = Workflow.make("WorkflowEngine/ReplayCompensation", {
payload: { id: Schema.String },
error: Schema.String,
idempotencyKey: ({ id }) => id
})
const charge = Activity.make({
name: "charge",
success: Schema.String,
execute: Effect.sync(() => {
charges.push("charge")
return "ch_1"
})
})
const layer = Order.toLayer(() =>
Effect.gen(function*() {
runs++
yield* charge.pipe(Order.withCompensation((id) => Effect.sync(() => refunds.push(id))))
if (!replay) {
return yield* Effect.fail("rejected")
}
yield* DurableDeferred.await(approval)
})
).pipe(Layer.provideMerge(WorkflowEngine.layerMemory))
yield* Effect.gen(function*() {
const executionId = yield* Order.execute({ id: "one" }, { discard: true })
let result = yield* Order.poll(executionId)
if (replay) {
while (Option.isNone(result) || result.value._tag !== "Suspended") {
yield* Effect.yieldNow
result = yield* Order.poll(executionId)
}
const token = DurableDeferred.tokenFromExecutionId(approval, { workflow: Order, executionId })
yield* DurableDeferred.fail(approval, { token, error: "rejected" })
}
while (Option.isNone(result) || result.value._tag !== "Complete") {
yield* Effect.yieldNow
result = yield* Order.poll(executionId)
}
assert.strictEqual(runs, replay ? 2 : 1)
assert.deepStrictEqual(charges, ["charge"])
assert.deepStrictEqual(refunds, ["ch_1"])
}).pipe(Effect.provide(layer))
}))
}
On main: AssertionError: expected [ 'ch_1', 'ch_1' ] to deeply equal [ 'ch_1' ].
Related: #9201
- Lingua principale
- TypeScript
- Stelle
- 16.7k
- Fork
- 808
- Merge medio
- 11h 24m
- PR unite (30g)
- 848
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di Effect-TS/effect
-
Support {self: this} for fnUntracedForse già presa @ArjunCodess l’ha presa 34 giorni fa. Apertaenhancement
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
Effect-TS/effect#8101 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
HttpApiClient: a path parameter of `.` or `..` changes the request pathForse già presa @effect-bot l’ha presa 1 giorno fa. Aperta
Effect-TS/effect#9766 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Effect 3: add `~standard.jsonSchema` to `Schema.standardSchemaV1`Forse già presa @effect-bot l’ha presa 1 giorno fa. Aperta
Effect-TS/effect#9765 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Support the `openapi-3.0` target in `Schema.toStandardJSONSchemaV1`Forse già presa Una pull request collegata a questa issue è aperta o già unita. Apertawontfix
Difficoltà 4/5 3-5 giorni Idoneità per principianti 48/100
I maintainer di solito rispondono entro 1 giorno
-
`Schema.toStandardJSONSchemaV1` output has no `$schema`Forse già presa @gcanti l’ha presa oggi. Aperta
Effect-TS/effect#9763 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di Effect-TS/effect
Issue simili
-
area:agent-view area:tui bug platform:macos
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
anthropics/claude-code#104558 ·
I maintainer di solito rispondono entro 1 giorno
-
updater
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
UniversityOfHelsinkiCS/oodikone#5141 ·
-
maintanance
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
inmanta/web-console#7606 ·
I maintainer di solito rispondono entro 1 giorno
-
bug skills
Difficoltà 2/5 1-3 ore Idoneità per principianti 77/100
equinor/fusion-framework#5846 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 74/100