Skip to content

Commit 987bb4f

Browse files
committed
fix(turso): replace client.transaction() with UPDATE...RETURNING to prevent connection leak in queue poller
@libsql/client's transaction() method sets this.#db = null after BEGIN so that concurrent execute() calls use a separate connection. After commit/rollback, the detached Database object is abandoned to GC. At the default 100ms poll interval this orphans ~10 native SQLite connections per second — more than GC can reclaim — exhausting OS file-handle or native-heap limits and crashing the host process (exit code 5) after 5-10 minutes of idle operation. Replacing the three-step SELECT + UPDATE + COMMIT with a single atomic UPDATE...RETURNING via client.execute() preserves atomicity through SQLite's implicit per-statement transaction while reusing the single cached connection (execute() calls #getDb() without nulling #db). Fixes: https://github.com/mizzle-dev/workflow-worlds/issues/TBD
1 parent 4c2fed0 commit 987bb4f

1 file changed

Lines changed: 20 additions & 38 deletions

File tree

packages/turso/src/queue.ts

Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,6 @@ export function createQueue(config: QueueConfig): {
237237

238238
/**
239239
* Poll for and process pending messages.
240-
* Uses a write transaction to atomically claim messages and prevent race conditions.
241240
*/
242241
async function pollAndProcess(): Promise<void> {
243242
if (!isRunning || isShuttingDown) {
@@ -247,47 +246,30 @@ export function createQueue(config: QueueConfig): {
247246
try {
248247
const now = new Date().toISOString();
249248

250-
// Use a write transaction to atomically select and claim a message
251-
// This prevents race conditions where multiple pollers claim the same message
252-
const tx = await client.transaction('write');
253-
let messageId: MessageId | null = null;
254-
let queueName: ValidQueueName | null = null;
255-
let payload: string | null = null;
256-
let attempt = 1;
257-
258-
try {
259-
// Find a pending message that's ready to process
260-
const result = await tx.execute({
261-
sql: `SELECT message_id, queue_name, payload, attempt
262-
FROM queue_messages
249+
// Atomically claim the oldest pending message that is ready to run.
250+
// The subquery SELECT + outer UPDATE execute as a single implicit
251+
// transaction in SQLite, so no two pollers can claim the same message.
252+
const result = await client.execute({
253+
sql: `UPDATE queue_messages
254+
SET status = 'processing'
255+
WHERE message_id = (
256+
SELECT message_id FROM queue_messages
263257
WHERE status = 'pending' AND (not_before IS NULL OR not_before <= ?)
264258
ORDER BY created_at ASC
265-
LIMIT 1`,
266-
args: [now],
267-
});
268-
269-
if (result.rows.length > 0) {
270-
const row = result.rows[0];
271-
messageId = row.message_id as MessageId;
272-
queueName = row.queue_name as ValidQueueName;
273-
payload = row.payload as string;
274-
attempt = (row.attempt as number) || 1;
275-
276-
// Mark as processing within the same transaction
277-
await tx.execute({
278-
sql: `UPDATE queue_messages SET status = 'processing' WHERE message_id = ?`,
279-
args: [messageId],
280-
});
281-
}
259+
LIMIT 1
260+
)
261+
RETURNING message_id, queue_name, payload, attempt`,
262+
args: [now],
263+
});
282264

283-
await tx.commit();
284-
} catch (err) {
285-
await tx.rollback();
286-
throw err;
287-
}
265+
// Process outside the implicit transaction (it already committed above).
266+
if (result.rows.length > 0) {
267+
const row = result.rows[0];
268+
const messageId = row.message_id as MessageId;
269+
const queueName = row.queue_name as ValidQueueName;
270+
const payload = row.payload as string;
271+
const attempt = (row.attempt as number) || 1;
288272

289-
// Process outside the transaction to avoid holding the lock
290-
if (messageId && queueName && payload) {
291273
await acquireConcurrency();
292274
processMessage(messageId, queueName, payload, attempt)
293275
.catch((err) => {

0 commit comments

Comments
 (0)