Skip to content

Commit 5f87780

Browse files
committed
fix(platform-cloudflare): reject ask deduplication to tells
1 parent b020540 commit 5f87780

6 files changed

Lines changed: 97 additions & 19 deletions

File tree

packages/platform/cloudflare/src/CloudflareCluster.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ interface EntityStub {
125125
}) => Promise<{
126126
readonly requestId: string
127127
readonly replies: ReadonlyArray<string>
128-
readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined
128+
readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | "AskDeduplicatedToTell" | undefined
129129
}>
130130
readonly acknowledge: (requestId: string, replyId: string) => Promise<ReadonlyArray<string>>
131131
readonly interrupt?: (storageRequestId: string, clientRequestId?: string) => Promise<void>
@@ -316,6 +316,12 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) {
316316
| MailboxFull
317317
| PersistenceError
318318
)
319+
} else if (result.error === "AskDeduplicatedToTell") {
320+
return Effect.fail(
321+
new PersistenceError({
322+
cause: new Error("Cannot deduplicate an ask onto a tell with the same PrimaryKey")
323+
}) as MailboxFull | PersistenceError
324+
)
319325
}
320326
entry.storageRequestId = result.requestId
321327
if (replyHandler !== undefined && result.requestId !== clientRequestId) {

packages/platform/cloudflare/src/CloudflareDurableObjects.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ interface ReplayMessage {
8080
interface InvokeResult {
8181
readonly requestId: string
8282
readonly replies: ReadonlyArray<string>
83-
readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined
83+
readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | "AskDeduplicatedToTell" | undefined
8484
}
8585

8686
interface InvokeOutcome {
@@ -214,6 +214,17 @@ export class ClusterEntity extends DurableObject<unknown> {
214214
return yield* Effect.die(error)
215215
}
216216
if (persisted._tag === "Duplicate") {
217+
const original = loadMessage(storage.sql, persisted.originalId)
218+
if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared")
219+
if (original.discard && !discard) {
220+
return {
221+
result: {
222+
requestId: persisted.originalId,
223+
replies: [],
224+
error: "AskDeduplicatedToTell" as const
225+
}
226+
}
227+
}
217228
const nextReply = loadNextReply(storage.sql, persisted.originalId)
218229
if (nextReply !== undefined) {
219230
this.#releaseTerminalSession(persisted.originalId, nextReply)
@@ -231,8 +242,6 @@ export class ClusterEntity extends DurableObject<unknown> {
231242
String(envelope.requestId)
232243
)
233244
}
234-
const original = loadMessage(storage.sql, persisted.originalId)
235-
if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared")
236245
if (original.deliverAt !== undefined && original.deliverAt > Date.now()) {
237246
yield* this.#armEarliestAlarm()
238247
return this.#delayedOutcome(

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const persistRequest = (
5353
}
5454

5555
const existing = sql.exec(
56-
`SELECT m.request_id, m.processed, m.reply_to, r.reply AS last_reply
56+
`SELECT m.request_id, m.discard, m.processed, m.reply_to, r.reply AS last_reply
5757
FROM cluster_messages m
5858
LEFT JOIN cluster_replies r ON r.reply_id = m.last_reply_id
5959
WHERE m.request_id = ? OR (? IS NOT NULL AND m.message_id = ?)
@@ -63,7 +63,7 @@ export const persistRequest = (
6363
primaryKey
6464
).toArray()[0]
6565
if (existing !== undefined) {
66-
if (replyTo !== null && Number(existing.processed) === 0) {
66+
if (replyTo !== null && Number(existing.discard) === 0 && Number(existing.processed) === 0) {
6767
const replyTos = decodeReplyTargets(existing.reply_to)
6868
if (!replyTos.includes(replyTo)) replyTos.push(replyTo)
6969
sql.exec(

packages/platform/cloudflare/test/CloudflareCluster.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,38 @@ describe("CloudflareCluster", () => {
430430
}).pipe(Effect.provide(CloudflareCluster.layer(options)))
431431
})
432432

433+
it.effect("surfaces ask-to-tell deduplication as a persistence failure", () => {
434+
const stub = {
435+
invoke() {
436+
return Promise.resolve({
437+
requestId: "original-tell",
438+
replies: [],
439+
error: "AskDeduplicatedToTell" as const
440+
})
441+
},
442+
acknowledge() {
443+
return Promise.resolve([])
444+
}
445+
}
446+
const options: CloudflareCluster.LayerOptions = {
447+
entities: [Scheduled],
448+
entityNamespace: new FakeNamespace(stub) as any,
449+
workflowNamespace: new FakeNamespace() as any,
450+
queueNamespace: new FakeNamespace() as any,
451+
singletonNamespace: new FakeNamespace() as any
452+
}
453+
454+
return Effect.gen(function*() {
455+
const makeClient = yield* Scheduled.client
456+
const exit = yield* makeClient("one").Ask({ deliverAt: Date.now() + 60_000, id: "tell" }).pipe(
457+
Effect.provideService(CurrentEntityName, "6:Callerone"),
458+
Effect.exit
459+
)
460+
assert.isTrue(Exit.isFailure(exit))
461+
assert.isFalse(yield* Effect.promise(() => deliverReply("original-tell", "unused")))
462+
}).pipe(Effect.provide(CloudflareCluster.layer(options)))
463+
})
464+
433465
it.effect("does not retain reset targets for volatile requests", () => {
434466
let requestId = ""
435467
let resets = 0

packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -219,25 +219,54 @@ describe("CloudflareDurableObjects", () => {
219219
assert.deepStrictEqual(result, { firstStatus: "pending", secondStatus: "rejected" })
220220
}), 60_000)
221221

222-
it.effect("does not run a future row when an immediate request deduplicates to it", () =>
222+
it.effect(
223+
"rejects an ask deduplicated onto a future tell without hanging or running it early",
224+
() =>
225+
Effect.gen(function*() {
226+
const miniflare = yield* makeMiniflare
227+
const fetchJson = (path: string) =>
228+
Effect.promise(() =>
229+
Promise.race([
230+
miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => {
231+
const body = await response.text()
232+
assert.strictEqual(response.status, 200, body)
233+
return JSON.parse(body)
234+
}),
235+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${path} did not return`)), 2_000))
236+
])
237+
)
238+
const id = "scheduled-dedup"
239+
yield* fetchJson(
240+
`/delayed?id=${id}&operationId=same&discard=true&deliverAt=${Date.now() + 60_000}`
241+
)
242+
const duplicate = yield* fetchJson(`/mailbox?id=${id}&tag=Add&operationId=same&discard=false`)
243+
assert.strictEqual(duplicate.error, "AskDeduplicatedToTell")
244+
245+
const result = yield* fetchJson(`/mailbox?id=${id}&tag=Get`)
246+
assert.deepStrictEqual(JSON.parse(result.replies[0]).exit, { _tag: "Success", value: 0 })
247+
}),
248+
60_000
249+
)
250+
251+
it.effect("rejects an ask deduplicated onto a processed tell without hanging", () =>
223252
Effect.gen(function*() {
224253
const miniflare = yield* makeMiniflare
225254
const fetchJson = (path: string) =>
226255
Effect.promise(() =>
227-
miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => {
228-
const body = await response.text()
229-
assert.strictEqual(response.status, 200, body)
230-
return JSON.parse(body)
231-
})
256+
Promise.race([
257+
miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => {
258+
const body = await response.text()
259+
assert.strictEqual(response.status, 200, body)
260+
return JSON.parse(body)
261+
}),
262+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${path} did not return`)), 2_000))
263+
])
232264
)
233-
const id = "scheduled-dedup"
234-
yield* fetchJson(
235-
`/delayed?id=${id}&operationId=same&discard=true&deliverAt=${Date.now() + 60_000}`
236-
)
265+
const id = "processed-tell-dedup"
237266
yield* fetchJson(`/mailbox?id=${id}&tag=Add&operationId=same`)
267+
const duplicate = yield* fetchJson(`/mailbox?id=${id}&tag=Add&operationId=same&discard=false`)
238268

239-
const result = yield* fetchJson(`/mailbox?id=${id}&tag=Get`)
240-
assert.deepStrictEqual(JSON.parse(result.replies[0]).exit, { _tag: "Success", value: 0 })
269+
assert.strictEqual(duplicate.error, "AskDeduplicatedToTell")
241270
}), 60_000)
242271

243272
it.effect("delivers a scheduled ask reply to the caller Durable Object", () =>

packages/platform/cloudflare/test/fixtures/worker.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ export default {
204204
const tag = url.searchParams.get("tag") ?? "Get"
205205
const operationId = url.searchParams.get("operationId") ?? "operation"
206206
const requestId = crypto.randomUUID()
207+
const discardParam = url.searchParams.get("discard")
208+
const discard = discardParam === null ? tag === "Add" || tag === "AddVolatile" : discardParam === "true"
207209
try {
208210
const result = await stub.invoke(
209211
JSON.stringify({
@@ -218,7 +220,7 @@ export default {
218220
payload: tag === "Get" || tag === "Watch" ? null : { operationId },
219221
headers: {}
220222
}),
221-
tag === "Add" || tag === "AddVolatile"
223+
discard
222224
)
223225
return Response.json(result)
224226
} catch (error) {

0 commit comments

Comments
 (0)