Skip to content

Commit c5d8d76

Browse files
Claude Engineerclaude
authored andcommitted
feat(platform-cloudflare): add DurableQueue Durable Object
One queue name is one Durable Object: items, attempt counts, and in-flight leases live on the object's SQLite storage behind its single alarm, which acts as a watchdog redelivering items whose worker died. CloudflareCluster now also provides PersistedQueueFactory, so the DurableQueue user API works on the Cloudflare path out of the box. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 031610d commit c5d8d76

8 files changed

Lines changed: 986 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@effect/platform-cloudflare": minor
3+
---
4+
5+
Add `CloudflarePersistedQueue`, running persisted queues on the dedicated
6+
queue Durable Object class. One queue name is one Durable Object: items,
7+
attempt counts, and in-flight leases live on the object's SQLite storage
8+
behind its single alarm, which acts as a watchdog redelivering items whose
9+
worker died before completing them. `CloudflareCluster.layer` now also
10+
provides the `PersistedQueueFactory` service, so the `DurableQueue` user API
11+
works on the Cloudflare path out of the box.

packages/platform/cloudflare/src/CloudflareCluster.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ import * as Envelope from "effect/unstable/cluster/Envelope"
2626
import * as RunnerAddress from "effect/unstable/cluster/RunnerAddress"
2727
import * as ShardId from "effect/unstable/cluster/ShardId"
2828
import { Sharding } from "effect/unstable/cluster/Sharding"
29+
import type { PersistedQueueFactory } from "effect/unstable/persistence/PersistedQueue"
2930
import type * as Rpc from "effect/unstable/rpc/Rpc"
3031
import * as RpcClient from "effect/unstable/rpc/RpcClient"
3132
import * as RpcSchema from "effect/unstable/rpc/RpcSchema"
3233
import type { WorkflowEngine } from "effect/unstable/workflow/WorkflowEngine"
34+
import * as CloudflarePersistedQueue from "./CloudflarePersistedQueue.ts"
3335
import * as CloudflareWorkflowEngine from "./CloudflareWorkflowEngine.ts"
3436
import * as Internal from "./internal/clusterName.ts"
3537
import { registerEntity as registerEntityHandler, unregisterEntity } from "./internal/entityRegistry.ts"
@@ -473,7 +475,9 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) {
473475
*
474476
* Provides the cluster `Sharding` service on top of the four same-Worker
475477
* Durable Object namespace bindings, plus the `WorkflowEngine` backed by the
476-
* workflow class. `Entity.client` resolves an entity to its Durable Object by
478+
* workflow class and the `PersistedQueueFactory` backed by the queue class, so
479+
* the `DurableQueue` user API works out of the box. `Entity.client` resolves
480+
* an entity to its Durable Object by
477481
* encoding `(type, id)` with {@link encodeName} and calling `getByName`; an
478482
* unknown entity type or a bad encode fails at the Worker before any Durable
479483
* Object is contacted. Entity handlers registered with `Entity.toLayer` are
@@ -484,8 +488,9 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) {
484488
* @category layers
485489
* @since 4.0.0
486490
*/
487-
export const layer = (options: LayerOptions): Layer.Layer<Sharding | WorkflowEngine> =>
488-
Layer.merge(
491+
export const layer = (options: LayerOptions): Layer.Layer<Sharding | WorkflowEngine | PersistedQueueFactory> =>
492+
Layer.mergeAll(
489493
Layer.effect(Sharding)(make(options)),
490-
CloudflareWorkflowEngine.layer({ workflowNamespace: options.workflowNamespace })
494+
CloudflareWorkflowEngine.layer({ workflowNamespace: options.workflowNamespace }),
495+
CloudflarePersistedQueue.layer({ queueNamespace: options.queueNamespace })
491496
)

packages/platform/cloudflare/src/CloudflareDurableObjects.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import { deliverReply as deliverEntityReply } from "./internal/entityReply.ts"
4545
import { makeEntityRuntime } from "./internal/entityRuntime.ts"
4646
import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts"
4747
import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts"
48+
import { makeQueueRuntime } from "./internal/queueRuntime.ts"
49+
import { earliestLeaseExpiry, ensureQueueStorage } from "./internal/queueStorage.ts"
4850
import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts"
4951
import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts"
5052
import { earliestClockWakeUp, ensureWorkflowStorage, loadExecution } from "./internal/workflowStorage.ts"
@@ -59,6 +61,14 @@ type EntityRuntime = Effect.Success<ReturnType<typeof makeEntityRuntime>>
5961

6062
type WorkflowRuntime = ReturnType<typeof makeWorkflowRuntime>
6163

64+
type QueueRuntime = ReturnType<typeof makeQueueRuntime>
65+
66+
interface QueueItem {
67+
readonly id: string
68+
readonly element: string
69+
readonly attempts: number
70+
}
71+
6272
interface ReplySession {
6373
readonly replies: Array<string>
6474
readonly takers: Array<{
@@ -743,13 +753,86 @@ export class ClusterWorkflow extends DurableObject<unknown> {
743753
}
744754

745755
/**
746-
* The durable queue class. Placeholder for `DurableQueue`; one object per
747-
* queue name. It only reserves the binding for now.
756+
* The durable queue class behind the `PersistedQueue` implementation used by
757+
* `DurableQueue`. One instance holds one named queue.
758+
*
759+
* **Details**
760+
*
761+
* The constructor stays cheap: it opens SQLite, ensures the queue table, and
762+
* re-arms the single alarm from the earliest pending lease expiry. Items are
763+
* leased to takers for a bounded time; the alarm watchdog expires overdue
764+
* leases so an item whose worker died is redelivered.
748765
*
749766
* @category durable objects
750767
* @since 4.0.0
751768
*/
752769
export class ClusterDurableQueue extends DurableObject<unknown> {
770+
readonly #state: DurableObjectState
771+
#runtime: QueueRuntime | undefined
772+
773+
constructor(ctx: DurableObjectState, env: unknown) {
774+
super(ctx, env)
775+
this.#state = ctx
776+
if (ctx.id.name !== undefined && decodeName(ctx.id.name) === undefined) {
777+
throw new Error("ClusterDurableQueue requires a canonical queue Durable Object name")
778+
}
779+
ensureQueueStorage(ctx.storage.sql)
780+
const expiry = earliestLeaseExpiry(ctx.storage.sql)
781+
if (expiry !== undefined) {
782+
void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, expiry)))
783+
}
784+
}
785+
786+
#getRuntime(): QueueRuntime {
787+
if (this.#runtime === undefined) {
788+
this.#runtime = makeQueueRuntime({
789+
sql: this.#state.storage.sql,
790+
alarm: this.#state.storage,
791+
now: () => Date.now()
792+
})
793+
}
794+
return this.#runtime
795+
}
796+
797+
/** @internal Same-Worker RPC transport used by `CloudflarePersistedQueue.layer`. */
798+
offer(id: string, element: string): Promise<void> {
799+
return this.#getRuntime().offer(id, element)
800+
}
801+
802+
/** @internal Waits until an item is available, then leases it to the caller. */
803+
take(takerId: string, maxAttempts: number, leaseMillis: number): Promise<QueueItem> {
804+
return this.#getRuntime().take(takerId, maxAttempts, leaseMillis)
805+
}
806+
807+
/** @internal Cancels a waiting take, releasing an item already leased to it. */
808+
cancelTake(takerId: string): Promise<void> {
809+
return this.#getRuntime().cancelTake(takerId)
810+
}
811+
812+
/** @internal */
813+
complete(id: string): Promise<void> {
814+
return this.#getRuntime().complete(id)
815+
}
816+
817+
/** @internal Records a failed attempt and requeues the item. */
818+
fail(id: string, lastFailure: string): Promise<void> {
819+
return this.#getRuntime().fail(id, lastFailure)
820+
}
821+
822+
/** @internal Requeues the item without counting an attempt. */
823+
release(id: string): Promise<void> {
824+
return this.#getRuntime().release(id)
825+
}
826+
827+
/** @internal Extends the lease of an item still being processed. */
828+
extend(id: string, leaseMillis: number): Promise<void> {
829+
return this.#getRuntime().extend(id, leaseMillis)
830+
}
831+
832+
override alarm(): Promise<void> {
833+
return this.#getRuntime().runAlarm()
834+
}
835+
753836
override fetch: () => never = notExposed("ClusterDurableQueue")
754837
}
755838

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Runs persisted queues on the dedicated queue Durable Object class.
3+
*
4+
* On this path one queue name is one Durable Object: the Worker encodes the
5+
* queue name into a Durable Object name with the same length-prefix scheme as
6+
* entities and resolves the object through the queue namespace binding. Items,
7+
* their attempt counts, and their in-flight leases live on the object's SQLite
8+
* storage behind its single alarm; a take with no available item waits inside
9+
* the object until an offer, a retry, or an expired lease produces one.
10+
*
11+
* Delivery is at-least-once: a taken item is leased for a bounded time and the
12+
* lease is refreshed while the handler runs, so an item whose worker died is
13+
* redelivered once the alarm watchdog expires the lease.
14+
*
15+
* This backs the `DurableQueue` user API; `CloudflareCluster.layer` already
16+
* includes this layer.
17+
*
18+
* @since 4.0.0
19+
*/
20+
import * as Cause from "effect/Cause"
21+
import * as Effect from "effect/Effect"
22+
import * as Exit from "effect/Exit"
23+
import * as Layer from "effect/Layer"
24+
import * as Schedule from "effect/Schedule"
25+
import * as PersistedQueue from "effect/unstable/persistence/PersistedQueue"
26+
import { encodeName } from "./internal/clusterName.ts"
27+
import type { QueueItem } from "./internal/queueStorage.ts"
28+
29+
/**
30+
* The queue Durable Object namespace binding the store is built from.
31+
*
32+
* @category layers
33+
* @since 4.0.0
34+
*/
35+
export interface LayerOptions {
36+
readonly queueNamespace: DurableObjectNamespace
37+
}
38+
39+
interface QueueStub {
40+
readonly offer: (id: string, element: string) => Promise<void>
41+
readonly take: (takerId: string, maxAttempts: number, leaseMillis: number) => Promise<QueueItem>
42+
readonly cancelTake: (takerId: string) => Promise<void>
43+
readonly complete: (id: string) => Promise<void>
44+
readonly fail: (id: string, lastFailure: string) => Promise<void>
45+
readonly release: (id: string) => Promise<void>
46+
readonly extend: (id: string, leaseMillis: number) => Promise<void>
47+
}
48+
49+
const leaseMillis = 120_000
50+
const leaseRefreshMillis = 30_000
51+
52+
const finalize = (run: () => Promise<void>): Effect.Effect<void> =>
53+
Effect.promise(run).pipe(
54+
Effect.sandbox,
55+
Effect.retry({ times: 5, schedule: Schedule.exponential(100, 1.5) }),
56+
Effect.orDie
57+
)
58+
59+
/**
60+
* Creates the `PersistedQueueStore` backed by the queue Durable Object
61+
* namespace binding.
62+
*
63+
* @category constructors
64+
* @since 4.0.0
65+
*/
66+
export const make = (options: LayerOptions): PersistedQueue.PersistedQueueStore["Service"] => {
67+
const stubFor = (name: string): QueueStub =>
68+
options.queueNamespace.getByName(encodeName("PersistedQueue", name)) as unknown as QueueStub
69+
70+
return PersistedQueue.PersistedQueueStore.of({
71+
offer: ({ element, id, name }) =>
72+
Effect.tryPromise({
73+
try: () => stubFor(name).offer(id, JSON.stringify(element)),
74+
catch: (cause) =>
75+
new PersistedQueue.PersistedQueueError({
76+
message: "Failed to offer element to persisted queue",
77+
cause
78+
})
79+
}),
80+
take: ({ maxAttempts, name }) =>
81+
// Uninterruptible outside `restore` so the release finalizer is always
82+
// registered once an item is leased; an interrupt while still waiting
83+
// cancels the take by taker id, releasing an item that was already
84+
// leased to it on the object.
85+
Effect.uninterruptibleMask((restore) =>
86+
Effect.gen(function*() {
87+
const takerId = crypto.randomUUID()
88+
// A broken take RPC means the object was evicted while this taker
89+
// waited; retrying re-enters the queue with nothing lost.
90+
const item = yield* restore(
91+
Effect.promise(() => stubFor(name).take(takerId, maxAttempts, leaseMillis)).pipe(
92+
Effect.sandbox,
93+
Effect.tapCause((cause) => Effect.logWarning("PersistedQueue take failed, retrying", cause)),
94+
Effect.retry(Schedule.spaced(500)),
95+
Effect.orDie
96+
)
97+
).pipe(
98+
Effect.onInterrupt(() =>
99+
Effect.promise(() => stubFor(name).cancelTake(takerId)).pipe(
100+
Effect.sandbox,
101+
Effect.retry({ times: 5, schedule: Schedule.exponential(100, 1.5) }),
102+
Effect.ignore
103+
)
104+
)
105+
)
106+
yield* Effect.addFinalizer(Exit.match({
107+
onFailure: (cause) =>
108+
Cause.hasInterruptsOnly(cause)
109+
? finalize(() => stubFor(name).release(item.id))
110+
: finalize(() => stubFor(name).fail(item.id, Cause.pretty(cause))),
111+
onSuccess: () => finalize(() => stubFor(name).complete(item.id))
112+
}))
113+
yield* Effect.promise(() => stubFor(name).extend(item.id, leaseMillis)).pipe(
114+
Effect.sandbox,
115+
Effect.ignore,
116+
Effect.schedule(Schedule.spaced(leaseRefreshMillis)),
117+
Effect.forkScoped,
118+
Effect.interruptible
119+
)
120+
return {
121+
id: item.id,
122+
attempts: item.attempts,
123+
element: JSON.parse(item.element)
124+
}
125+
})
126+
)
127+
})
128+
}
129+
130+
/**
131+
* Layer that provides the `PersistedQueueFactory` backed by the queue Durable
132+
* Object namespace binding.
133+
*
134+
* **Details**
135+
*
136+
* `CloudflareCluster.layer` already includes this layer; use it directly only
137+
* when persisted queues are needed without the rest of the cluster.
138+
*
139+
* @category layers
140+
* @since 4.0.0
141+
*/
142+
export const layer = (options: LayerOptions): Layer.Layer<PersistedQueue.PersistedQueueFactory> =>
143+
PersistedQueue.layer.pipe(
144+
Layer.provide(Layer.succeed(PersistedQueue.PersistedQueueStore)(make(options)))
145+
)

packages/platform/cloudflare/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export * as CloudflareCluster from "./CloudflareCluster.ts"
1414
*/
1515
export * as CloudflareDurableObjects from "./CloudflareDurableObjects.ts"
1616

17+
/**
18+
* @since 4.0.0
19+
*/
20+
export * as CloudflarePersistedQueue from "./CloudflarePersistedQueue.ts"
21+
1722
/**
1823
* @since 4.0.0
1924
*/

0 commit comments

Comments
 (0)