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..fbb498ad5 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -553,15 +553,23 @@ 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 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) {'); @@ -599,7 +607,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) } : {}) };', ); diff --git a/packages/void/test/integration/queue-dispatch.test.ts b/packages/void/test/integration/queue-dispatch.test.ts index 4d25e2396..72d5409ac 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'], @@ -179,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); @@ -190,6 +195,27 @@ 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 () => { + 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'])( @@ -219,6 +245,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 +298,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 +314,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, );