From b42d421243d95437952ebe059c909e2e72fd9085 Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Wed, 28 Oct 2026 12:27:46 +0900 Subject: [PATCH 1/2] fix(void): log queue handler errors instead of dropping them The generated __processQueue caught a handler's error without binding it, so the reason a batch failed was lost: Cloudflare recorded only "queue: Handler failed.", and the dev server and Void Cloud got {"ok":false} with nothing logged. Log the error with the handler file and queue name, as scheduled() does for jobs. The native queue() also logs why no handler matched a queue, a reason only the HTTP route passed on. The framework wrapper's HTTP relay logs handler errors too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8F8mceGgtLyricGiHmQNt --- docs/guide/queues.md | 2 +- packages/void/src/cli/wrapper.ts | 7 +++- packages/void/src/router/compile.ts | 14 ++++++- .../test/integration/queue-dispatch.test.ts | 38 +++++++++++++++++-- packages/void/test/unit/wrapper.test.ts | 11 +++++- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/docs/guide/queues.md b/docs/guide/queues.md index a5c73083f..3e276c65d 100644 --- a/docs/guide/queues.md +++ b/docs/guide/queues.md @@ -76,7 +76,7 @@ export default defineQueue(async (batch, env) => { - `batch.ackAll()`: acknowledge all messages in the batch - `batch.retryAll(options?)`: retry all messages in the batch -The first call wins for each message. Calling `ack()` after `retry()`, or the reverse, does nothing. Messages with no explicit decision are acknowledged if the handler succeeds, or retried if it throws. +The first call wins for each message. Calling `ack()` after `retry()`, or the reverse, does nothing. Messages with no explicit decision are acknowledged if the handler succeeds, or retried if it throws. The handler's error is logged, so you don't need to catch errors just to log them. The `timestamp` field on each message is a `Date` object. diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index b89de58fd..f23a5cc99 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -903,7 +903,12 @@ export async function handleQueueRelay(payload, env, ctx) { retryAll(options) { for (const message of messages) message.retry(options); }, }; let ok = true; - try { await handleQueue(batch, env, ctx); } catch { ok = false; } + try { + await handleQueue(batch, env, ctx); + } catch (error) { + console.error(\`[void] Queue handler failed for queue "\${payload.queue}":\`, error); + ok = false; + } return { ok, ...(decisions.size ? { decisions: Object.fromEntries(decisions) } : {}) }; }`); } diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 7b0cd545f..d9821b1cd 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -553,8 +553,14 @@ export function compileRouteTable( ), ); lines.push(''); + const queueLabels = scan.queues.map((queue) => + relative(options.projectRoot, `${queuesDir}/${queue.filePath}`), + ); + lines.push(`const __queueLabels = ${JSON.stringify(queueLabels)};`); // Queue dispatch as a standalone function — called directly by native queue handler // and via the authenticated HTTP route. No global mutable state needed. + // It logs a handler's error. Otherwise Cloudflare would record only the native + // handler's "queue: Handler failed.", and the dev server and Void Cloud nothing. lines.push('export async function __processQueue(payload, env) {'); lines.push(' const queueSuffix = payload.queue;'); lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);'); @@ -599,7 +605,12 @@ export function compileRouteTable( lines.push(' }'); } lines.push(' }'); - lines.push(' } catch { __ok = false; }'); + lines.push(' } catch (error) {'); + lines.push( + ' console.error(`[void] Queue handler ${__queueLabels[__queueIndex]} failed for queue "${String(queueSuffix)}":`, error);', + ); + lines.push(' __ok = false;'); + lines.push(' }'); lines.push( ' return { ok: __ok, ...(__decisions.size > 0 ? { decisions: Object.fromEntries(__decisions) } : {}) };', ); @@ -1345,6 +1356,7 @@ ${envValidateCall}${remoteEnvSwap}${isrDeploymentStamp} const userEnv = __filte else if (d.action === "retry") m.retry(d.delaySeconds != null ? { delaySeconds: d.delaySeconds } : undefined); } } + if (__qr.error) console.error(\`[void] \${__qr.error}\`); if (!__qr.ok) throw new Error("queue: Handler failed."); } diff --git a/packages/void/test/integration/queue-dispatch.test.ts b/packages/void/test/integration/queue-dispatch.test.ts index 4d25e2396..812507f7f 100644 --- a/packages/void/test/integration/queue-dispatch.test.ts +++ b/packages/void/test/integration/queue-dispatch.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { build } from 'vite'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vite-plus/test'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vite-plus/test'; import { compileRouteTable, compileWorkerEntry } from '../../src/router/compile.ts'; import type { ScanResult } from '../../src/router/scan.ts'; @@ -37,7 +37,7 @@ function queueHandlerSource(handler: string): string { attempts: batch.messages.map(message => message.attempts), dates: batch.messages.every(message => message.timestamp instanceof Date), }); - if (env.fail === ${JSON.stringify(handler)}) throw new Error("handler failed"); + if (env.fail === ${JSON.stringify(handler)}) throw env.failure; batch.messages[0]?.ack(); batch.messages[1]?.retry({ delaySeconds: 9 }); }\n`; @@ -159,6 +159,10 @@ export { __processQueue } from "virtual:void-routes";\n`, rmSync(temporary, { force: true, recursive: true }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it.each([ ['emails', 'emails'], ['order-emails', 'order-emails'], @@ -192,6 +196,23 @@ export { __processQueue } from "virtual:void-routes";\n`, } }); + it('logs a failed handler with its file and queue and returns failure', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new Error('handler failed'); + const env = { calls: [] as Array>, fail: 'order-emails', failure }; + const result = await generated.__processQueue(payload('project-order-emails'), env); + + expect(result).toEqual({ ok: false }); + expect(env.calls).toEqual([ + { handler: 'order-emails', queue: 'project-order-emails', attempts: [2, 3], dates: true }, + ]); + expect(errorLog.mock.calls).toHaveLength(1); + expect(errorLog.mock.calls[0][0]).toBe( + '[void] Queue handler queues/order-emails.ts failed for queue "project-order-emails":', + ); + expect(errorLog.mock.calls[0][1]).toBe(failure); + }); + it.each(['emails', 'order-emails'])( 'dispatches the hosted relay schema by exact logical name for %s', async (queue) => { @@ -219,6 +240,8 @@ export { __processQueue } from "virtual:void-routes";\n`, ); it('makes native unknown queues and handler failures retry by rejecting the event', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new Error('handler failed'); const makeNativeBatch = (queue: string) => { const firstAck = vi.fn(); const firstRetry = vi.fn(); @@ -270,6 +293,7 @@ export { __processQueue } from "virtual:void-routes";\n`, expect(nativeSuccess.firstRetry).not.toHaveBeenCalled(); expect(nativeSuccess.secondAck).not.toHaveBeenCalled(); expect(nativeSuccess.secondRetry).toHaveBeenCalledWith({ delaySeconds: 9 }); + expect(errorLog).not.toHaveBeenCalled(); const nativeUnknown = makeNativeBatch('project-unknown'); @@ -285,9 +309,17 @@ export { __processQueue } from "virtual:void-routes";\n`, await expect( generated.default.queue( nativeFailure.batch, - { calls: [], fail: 'order-emails' }, + { calls: [], fail: 'order-emails', failure }, {} as ExecutionContext, ), ).rejects.toThrow('queue: Handler failed'); + expect(errorLog.mock.calls).toHaveLength(2); + expect(errorLog.mock.calls[0]).toEqual([ + '[void] No unambiguous queue handler matches "project-unknown"', + ]); + expect(errorLog.mock.calls[1][0]).toBe( + '[void] Queue handler queues/order-emails.ts failed for queue "project-order-emails":', + ); + expect(errorLog.mock.calls[1][1]).toBe(failure); }); }); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index c222712b5..01301d6f5 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -387,6 +387,7 @@ describe('generated wrapper queue dispatch', () => { for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); } + vi.restoreAllMocks(); }); async function createRelayWorker() { @@ -402,7 +403,7 @@ describe('generated wrapper queue dispatch', () => { `export default function(batch, env) { env.calls.push(batch.messages[0].timestamp.getTime()); batch.messages[0].ack(); - if (env.fail) throw new Error('after first message'); + if (env.fail) throw env.fail; batch.messages[0].retry(); batch.messages[1].retry({ delaySeconds: 9 }); batch.ackAll(); @@ -435,6 +436,7 @@ describe('generated wrapper queue dispatch', () => { it('delivers managed HTTP batches and returns first-wins acknowledgment decisions', async () => { const worker = await createRelayWorker(); + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); const env = { __VOID_PROXY_TOKEN: 'token', calls: [] as Array }; const response = await worker.fetch(relayRequest('token'), env, {}); expect(response.status).toBe(200); @@ -446,8 +448,13 @@ describe('generated wrapper queue dispatch', () => { }, }); expect(env.calls).toEqual([Date.parse('2026-01-01T00:00:00Z')]); - const failed = await worker.fetch(relayRequest('token'), { ...env, fail: true }, {}); + expect(errorLog).not.toHaveBeenCalled(); + const failure = new Error('after first message'); + const failed = await worker.fetch(relayRequest('token'), { ...env, fail: failure }, {}); expect(await failed.json()).toEqual({ ok: false, decisions: { one: { action: 'ack' } } }); + expect(errorLog.mock.calls).toHaveLength(1); + expect(errorLog.mock.calls[0][0]).toBe('[void] Queue handler failed for queue "emails":'); + expect(errorLog.mock.calls[0][1]).toBe(failure); expect((await worker.fetch(new Request('https://worker/__void/ready'), env, {})).status).toBe( 200, ); From db9bb2e342bcd15bb18b1a204b801b1fd12acd99 Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Wed, 28 Oct 2026 17:05:52 +0900 Subject: [PATCH 2/2] fix(void): log unmatched queues on every delivery path When no handler matched a queue, only the native queue() logged the reason. The HTTP route returned it as `error`, which the Void Cloud relay ignores, so a batch for an unknown queue was retried with nothing logged. Log the reason in __processQueue, where handler errors are logged, and drop the native queue()'s own log. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8F8mZUqZxveCneED8EgLC --- packages/void/src/router/compile.ts | 9 +++++---- packages/void/test/integration/queue-dispatch.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index d9821b1cd..fbb498ad5 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -559,15 +559,17 @@ export function compileRouteTable( lines.push(`const __queueLabels = ${JSON.stringify(queueLabels)};`); // Queue dispatch as a standalone function — called directly by native queue handler // and via the authenticated HTTP route. No global mutable state needed. - // It logs a handler's error. Otherwise Cloudflare would record only the native - // handler's "queue: Handler failed.", and the dev server and Void Cloud nothing. + // It logs why a batch failed: the native handler throws only "queue: Handler failed.", + // the HTTP route returns no handler error, and the Void Cloud relay ignores `error`. lines.push('export async function __processQueue(payload, env) {'); lines.push(' const queueSuffix = payload.queue;'); lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);'); lines.push(' if (__queueIndex === -1) {'); lines.push( - ' return { ok: false, error: `No unambiguous queue handler matches "${String(queueSuffix)}"` };', + ' const __error = `No unambiguous queue handler matches "${String(queueSuffix)}"`;', ); + lines.push(' console.error(`[void] ${__error}`);'); + lines.push(' return { ok: false, error: __error };'); lines.push(' }'); lines.push(' const __decisions = new Map();'); lines.push(' function __wrapMsg(m) {'); @@ -1356,7 +1358,6 @@ ${envValidateCall}${remoteEnvSwap}${isrDeploymentStamp} const userEnv = __filte else if (d.action === "retry") m.retry(d.delaySeconds != null ? { delaySeconds: d.delaySeconds } : undefined); } } - if (__qr.error) console.error(\`[void] \${__qr.error}\`); if (!__qr.ok) throw new Error("queue: Handler failed."); } diff --git a/packages/void/test/integration/queue-dispatch.test.ts b/packages/void/test/integration/queue-dispatch.test.ts index 812507f7f..72d5409ac 100644 --- a/packages/void/test/integration/queue-dispatch.test.ts +++ b/packages/void/test/integration/queue-dispatch.test.ts @@ -183,7 +183,8 @@ export { __processQueue } from "virtual:void-routes";\n`, expect(env.calls).toEqual([{ handler: expectedHandler, queue, attempts: [2, 3], dates: true }]); }); - it('returns failure without dispatching an unknown logical or physical queue', async () => { + it('logs and returns failure without dispatching an unknown logical or physical queue', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); for (const queue of ['unknown', 'project-unknown']) { const env = { calls: [] as Array> }; const result = await generated.__processQueue(payload(queue), env); @@ -194,6 +195,10 @@ export { __processQueue } from "virtual:void-routes";\n`, }); expect(env.calls).toEqual([]); } + expect(errorLog.mock.calls).toEqual([ + ['[void] No unambiguous queue handler matches "unknown"'], + ['[void] No unambiguous queue handler matches "project-unknown"'], + ]); }); it('logs a failed handler with its file and queue and returns failure', async () => {