Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/queues.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export default defineQueue<Message>(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.

Expand Down
7 changes: 6 additions & 1 deletion packages/void/src/cli/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) } : {}) };
}`);
}
Expand Down
17 changes: 15 additions & 2 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {');
Expand Down Expand Up @@ -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) } : {}) };',
);
Expand Down
45 changes: 41 additions & 4 deletions packages/void/test/integration/queue-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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'],
Expand All @@ -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<Record<string, unknown>> };
const result = await generated.__processQueue(payload(queue), env);
Expand All @@ -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<Record<string, unknown>>, 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'])(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');

Expand All @@ -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);
});
});
11 changes: 9 additions & 2 deletions packages/void/test/unit/wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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();
Expand Down Expand Up @@ -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<number> };
const response = await worker.fetch(relayRequest('token'), env, {});
expect(response.status).toBe(200);
Expand All @@ -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,
);
Expand Down