Skip to content

Commit 031610d

Browse files
committed
refactor(platform-cloudflare): simplify workflow engine internals
- Fold loadExecutionName into loadExecution and share one detach helper for fire-and-forget promises - Flatten run()'s discard branching and route the wake self-heal through resume() - Deduplicate the per-workflow codec caches and drop the redundant conflict clause on the single-threaded deferred insert Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1672c43 commit 031610d

5 files changed

Lines changed: 60 additions & 66 deletions

File tree

packages/platform/cloudflare/src/CloudflareDurableObjects.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/ent
4747
import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts"
4848
import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts"
4949
import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts"
50-
import { earliestClockWakeUp, ensureWorkflowStorage, loadExecutionName } from "./internal/workflowStorage.ts"
50+
import { earliestClockWakeUp, ensureWorkflowStorage, loadExecution } from "./internal/workflowStorage.ts"
5151

5252
const notExposed = (className: string) => () => {
5353
throw new Error(
@@ -664,7 +664,7 @@ export class ClusterWorkflow extends DurableObject<unknown> {
664664
} else {
665665
// An alarm wake carries no `id.name`; recover it from the stored
666666
// execution so due clocks still fire after eviction.
667-
const stored = loadExecutionName(ctx.storage.sql)
667+
const stored = loadExecution(ctx.storage.sql)
668668
this.#name = stored === undefined ? undefined : encodeName(stored.workflowName, stored.executionId)
669669
}
670670
const wakeUp = earliestClockWakeUp(ctx.storage.sql)

packages/platform/cloudflare/src/internal/workflowRuntime.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu
8484
let inflight: Inflight | undefined
8585
let resumeRequested = false
8686

87+
const detach = (promise: Promise<unknown>): void => {
88+
options.waitUntil(promise.then(() => undefined, () => undefined))
89+
}
90+
8791
const isComplete = (result: string | undefined): boolean =>
8892
result !== undefined && (JSON.parse(result) as { readonly _tag?: unknown })._tag === "Complete"
8993

@@ -139,7 +143,7 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu
139143
if (parent !== undefined) options.waitUntil(resumeParent(parent))
140144
} else if (resumeRequested) {
141145
resumeRequested = false
142-
options.waitUntil(startAttempt(row).then(() => undefined, () => undefined))
146+
detach(startAttempt(row))
143147
} else {
144148
// This attempt observed every persisted deferred and still suspended,
145149
// so the pending resume (if any) has been serviced.
@@ -178,23 +182,20 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu
178182
let row = WorkflowStorage.loadExecution(sql)
179183
if (row === undefined) {
180184
WorkflowStorage.createExecution(sql, workflowName, executionId, payload, opts.parent)
181-
row = { workflowName, payload, parent: opts.parent, result: undefined, resumePending: false }
185+
row = { workflowName, executionId, payload, parent: opts.parent, result: undefined, resumePending: false }
182186
} else if (opts.parent !== undefined && row.parent === undefined) {
183187
// An execution started standalone can gain a parent later; keep the
184188
// first parent so its completion still wakes that parent.
185189
WorkflowStorage.setParent(sql, opts.parent)
186190
row = { ...row, parent: opts.parent }
187191
}
188-
if (inflight !== undefined) {
189-
if (!opts.discard) return inflight.promise
190-
} else if (row.result === undefined) {
191-
const attempt = startAttempt(row)
192-
if (!opts.discard) return attempt
193-
options.waitUntil(attempt.then(() => undefined, () => undefined))
194-
} else if (!opts.discard) {
195-
return Promise.resolve(row.result)
192+
if (opts.discard) {
193+
if (inflight === undefined && row.result === undefined) detach(startAttempt(row))
194+
return Promise.resolve("")
196195
}
197-
return Promise.resolve("")
196+
if (inflight !== undefined) return inflight.promise
197+
if (row.result !== undefined) return Promise.resolve(row.result)
198+
return startAttempt(row)
198199
}
199200

200201
const resume = (): Promise<void> => {
@@ -204,7 +205,7 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu
204205
resumeRequested = true
205206
return Promise.resolve()
206207
}
207-
options.waitUntil(startAttempt(row).then(() => undefined, () => undefined))
208+
detach(startAttempt(row))
208209
return Promise.resolve()
209210
}
210211

@@ -266,14 +267,12 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu
266267
return (pending ? resume() : Promise.resolve()).then(() => {
267268
// While a resume is pending a guard alarm stays armed, so a replay
268269
// lost with this isolate is retried instead of sleeping forever.
269-
const earliest = WorkflowStorage.earliestClockWakeUp(sql)
270-
const guard = pending ? options.now() + resumeGuardMillis : undefined
271-
const target = earliest === undefined
272-
? guard
273-
: guard === undefined
274-
? earliest
275-
: Math.min(earliest, guard)
276-
return target === undefined ? undefined : Effect.runPromise(armAlarm(options.alarm, target))
270+
const targets = [
271+
WorkflowStorage.earliestClockWakeUp(sql),
272+
pending ? options.now() + resumeGuardMillis : undefined
273+
].filter((target) => target !== undefined)
274+
if (targets.length === 0) return undefined
275+
return Effect.runPromise(armAlarm(options.alarm, Math.min(...targets)))
277276
})
278277
})
279278
}).then(() => undefined)
@@ -295,9 +294,8 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu
295294

296295
// Self-heal on wake: a resume recorded by deferredDone but lost with the
297296
// previous isolate replays now instead of waiting for external contact.
298-
const stored = WorkflowStorage.loadExecution(sql)
299-
if (stored !== undefined && stored.resumePending && !isComplete(stored.result)) {
300-
options.waitUntil(startAttempt(stored).then(() => undefined, () => undefined))
297+
if (WorkflowStorage.loadExecution(sql)?.resumePending === true) {
298+
void resume()
301299
}
302300

303301
return runtime

packages/platform/cloudflare/src/internal/workflowStorage.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,15 @@ export const ensureWorkflowStorage = (sql: SqlStorage): void => {
4141
}
4242
}
4343

44-
/** @internal */
44+
/**
45+
* The stored `(workflowName, executionId)` also serve to recover the object
46+
* name on an alarm wake, where `ctx.id.name` is undefined.
47+
*
48+
* @internal
49+
*/
4550
export interface ExecutionRow {
4651
readonly workflowName: string
52+
readonly executionId: string
4753
readonly payload: string
4854
readonly parent: { readonly workflowName: string; readonly executionId: string } | undefined
4955
readonly result: string | undefined
@@ -53,12 +59,13 @@ export interface ExecutionRow {
5359
/** @internal */
5460
export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => {
5561
const row = sql.exec(
56-
`SELECT workflow_name, payload, parent_name, parent_execution_id, result, resume_pending
62+
`SELECT workflow_name, execution_id, payload, parent_name, parent_execution_id, result, resume_pending
5763
FROM workflow_execution WHERE id = 0`
5864
).toArray()[0]
5965
if (row === undefined) return undefined
6066
return {
6167
workflowName: String(row.workflow_name),
68+
executionId: String(row.execution_id),
6269
payload: String(row.payload),
6370
parent: typeof row.parent_name === "string" && typeof row.parent_execution_id === "string"
6471
? { workflowName: row.parent_name, executionId: row.parent_execution_id }
@@ -68,20 +75,6 @@ export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => {
6875
}
6976
}
7077

71-
/**
72-
* The stored `(workflowName, executionId)` of this object's execution, used to
73-
* recover the object name on an alarm wake where `ctx.id.name` is undefined.
74-
*
75-
* @internal
76-
*/
77-
export const loadExecutionName = (
78-
sql: SqlStorage
79-
): { readonly workflowName: string; readonly executionId: string } | undefined => {
80-
const row = sql.exec("SELECT workflow_name, execution_id FROM workflow_execution WHERE id = 0").toArray()[0]
81-
if (row === undefined) return undefined
82-
return { workflowName: String(row.workflow_name), executionId: String(row.execution_id) }
83-
}
84-
8578
/** @internal */
8679
export const createExecution = (
8780
sql: SqlStorage,
@@ -141,10 +134,15 @@ export const loadDeferred = (sql: SqlStorage, name: string): string | undefined
141134
return row === undefined ? undefined : String(row.exit)
142135
}
143136

144-
/** @internal */
137+
/**
138+
* First write wins; safe without a conflict clause because a Durable Object's
139+
* SQLite access is single-threaded.
140+
*
141+
* @internal
142+
*/
145143
export const saveDeferred = (sql: SqlStorage, name: string, exit: string): boolean => {
146144
if (loadDeferred(sql, name) !== undefined) return false
147-
sql.exec("INSERT OR IGNORE INTO workflow_deferreds (name, exit) VALUES (?, ?)", name, exit)
145+
sql.exec("INSERT INTO workflow_deferreds (name, exit) VALUES (?, ?)", name, exit)
148146
return true
149147
}
150148

packages/platform/cloudflare/src/internal/workflowWire.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,26 @@ export const decodeExit = (
3030
context
3131
)
3232

33-
const resultCodecs = new WeakMap<Workflow.Any, Schema.Top>()
34-
35-
const resultCodec = (workflow: Workflow.Any): Schema.Top => {
36-
let codec = resultCodecs.get(workflow)
37-
if (codec === undefined) {
38-
codec = Schema.toCodecJson(Workflow.Result({
39-
success: workflow.successSchema as any,
40-
error: workflow.errorSchema as any
41-
}))
42-
resultCodecs.set(workflow, codec)
33+
const cachedCodec = (compute: (workflow: Workflow.Any) => Schema.Top): (workflow: Workflow.Any) => Schema.Top => {
34+
const cache = new WeakMap<Workflow.Any, Schema.Top>()
35+
return (workflow) => {
36+
let codec = cache.get(workflow)
37+
if (codec === undefined) {
38+
codec = compute(workflow)
39+
cache.set(workflow, codec)
40+
}
41+
return codec
4342
}
44-
return codec
4543
}
4644

47-
const payloadCodecs = new WeakMap<Workflow.Any, Schema.Top>()
45+
const resultCodec = cachedCodec((workflow) =>
46+
Schema.toCodecJson(Workflow.Result({
47+
success: workflow.successSchema as any,
48+
error: workflow.errorSchema as any
49+
}))
50+
)
4851

49-
const payloadCodec = (workflow: Workflow.Any): Schema.Top => {
50-
let codec = payloadCodecs.get(workflow)
51-
if (codec === undefined) {
52-
codec = Schema.toCodecJson(workflow.payloadSchema)
53-
payloadCodecs.set(workflow, codec)
54-
}
55-
return codec
56-
}
52+
const payloadCodec = cachedCodec((workflow) => Schema.toCodecJson(workflow.payloadSchema))
5753

5854
/** @internal */
5955
export const encodeResult = (

packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as CloudflareWorkflowEngine from "@effect/platform-cloudflare/Cloudflar
33
import { encodeName } from "@effect/platform-cloudflare/internal/clusterName"
44
import type { EntityAlarm } from "@effect/platform-cloudflare/internal/entityStorage"
55
import { makeWorkflowRuntime, type WorkflowRuntime } from "@effect/platform-cloudflare/internal/workflowRuntime"
6-
import { loadExecutionName } from "@effect/platform-cloudflare/internal/workflowStorage"
6+
import { loadExecution } from "@effect/platform-cloudflare/internal/workflowStorage"
77
import { assert, describe, it } from "@effect/vitest"
88
import { Effect, Exit, Layer, Option, Schema } from "effect"
99
import { Activity, DurableClock, DurableDeferred, Workflow, WorkflowEngine } from "effect/unstable/workflow"
@@ -61,7 +61,7 @@ class FakeSql {
6161
const exit = this.activities.get(String(bindings[0]))
6262
return exit === undefined ? [] : [{ exit }]
6363
}
64-
if (query.includes("INSERT OR IGNORE INTO workflow_deferreds")) {
64+
if (query.includes("INSERT INTO workflow_deferreds")) {
6565
if (!this.deferreds.has(String(bindings[0]))) {
6666
this.deferreds.set(String(bindings[0]), String(bindings[1]))
6767
}
@@ -297,7 +297,9 @@ describe("CloudflareWorkflowEngine", () => {
297297
])
298298
assert.strictEqual(store.alarm.current, 30_000)
299299
// An alarm wake has no `id.name`; the stored execution recovers it.
300-
assert.deepStrictEqual(loadExecutionName(store.sql.sql), { workflowName: "Sleeper", executionId })
300+
const stored = loadExecution(store.sql.sql)
301+
assert.strictEqual(stored?.workflowName, "Sleeper")
302+
assert.strictEqual(stored?.executionId, executionId)
301303

302304
namespace.now = 30_000
303305
yield* Effect.promise(() => namespace.fireDueAlarms())

0 commit comments

Comments
 (0)