I had GPT-5.5 write this summary up after debugging tons of hanging transactions in quick-convex when using convex-test.
Summary
I’m seeing convex-test enter an invalid transaction state when a mutation schedules an immediate function and tests drain scheduled functions with real timers.
This reproduces on convex-test@0.0.50 and still reproduces on 0.0.51. It does not appear to be specific to my component logic. The failure seems related to scheduled-function setTimeout callbacks inheriting transaction AsyncLocalStorage from the scheduling mutation, so later scheduled functions are treated as nested transactions even though no parent transaction is active.
Typical errors:
Error when running scheduled function repro:scheduledMutation Error: Transaction already committed or rolled back
at DatabaseFake.rollbackWrites (.../node_modules/convex-test/dist/index.js:220:19)
at TransactionManager.rollback (.../node_modules/convex-test/dist/index.js:1385:26)
Error when running scheduled function repro:scheduledAction Error: Transaction not started
at TransactionManager._endTransaction (.../node_modules/convex-test/dist/index.js:1391:19)
at TransactionManager.rollback (.../node_modules/convex-test/dist/index.js:1387:14)
Minimal Reproduction
convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
events: defineTable({
name: v.string(),
}),
});
convex/repro.ts
import { v } from "convex/values";
import { internal } from "./_generated/api";
import {
internalAction,
internalMutation,
internalQuery,
mutation,
query,
} from "./_generated/server";
export const start = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await ctx.scheduler.runAfter(0, internal.repro.scheduledMutation, {});
return null;
},
});
export const scheduledMutation = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await ctx.scheduler.runAfter(0, internal.repro.scheduledAction, {});
return null;
},
});
export const scheduledAction = internalAction({
args: {},
returns: v.null(),
handler: async (ctx) => {
await ctx.runQuery(internal.repro.noopQuery, {});
await ctx.runMutation(internal.repro.recordActionRan, {});
return null;
},
});
export const noopQuery = internalQuery({
args: {},
returns: v.null(),
handler: async () => null,
});
export const recordActionRan = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await ctx.db.insert("events", { name: "action-ran" });
return null;
},
});
export const eventCount = query({
args: {},
returns: v.number(),
handler: async (ctx) => {
return (await ctx.db.query("events").take(10)).length;
},
});
convex/repro.test.ts
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { expect, test, vi } from "vitest";
import schema from "./schema";
import { api } from "./_generated/api";
const modules = import.meta.glob("./**/*.*s");
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test("real timers scheduled mutation -> action drains successfully", async () => {
vi.useRealTimers();
const t = convexTest(schema, modules);
await t.mutation(api.repro.start, {});
for (let i = 0; i < 20; i++) {
await t.finishInProgressScheduledFunctions();
if ((await t.query(api.repro.eventCount, {})) === 1) {
break;
}
await sleep(25);
}
expect(await t.query(api.repro.eventCount, {})).toBe(1);
});
Expected
The scheduled mutation and scheduled action complete, and eventCount is 1.
Actual
The scheduled functions log transaction errors, and eventCount remains 0.
Notes
Using fake timers and manually advancing them avoids the race in many cases. The failure shows up when real timers fire scheduled callbacks while the test is polling finishInProgressScheduledFunctions().
(human Dan again)
Temp fix
This absolute nightmare:
const CONVEX_TEST_TIMER_PATCH = Symbol.for("quick-convex.convex-test-timer-patch");
function isVitestRuntime() {
return (
"process" in globalThis &&
typeof globalThis.process === "object" &&
globalThis.process !== null &&
"env" in globalThis.process &&
typeof globalThis.process.env === "object" &&
globalThis.process.env !== null &&
"VITEST" in globalThis.process.env
);
}
function installConvexTestTimerPatch() {
if (!isVitestRuntime()) {
return;
}
const setTimeoutWithoutConvexTestTransaction = new AsyncResource(
"quick-convex-test-timer",
);
const nativeSetTimeout = globalThis.setTimeout ?? nodeSetTimeout;
if ((nativeSetTimeout as any)[CONVEX_TEST_TIMER_PATCH]) {
return;
}
const patchedSetTimeout = ((handler: TimerHandler, timeout?: number, ...args: any[]) => {
if (typeof handler !== "function") {
return nativeSetTimeout(handler, timeout, ...args);
}
const stack = new Error().stack ?? "";
if (!/node_modules[/\\]convex-test/.test(stack)) {
return nativeSetTimeout(handler, timeout, ...args);
}
const callback = handler as (...callbackArgs: any[]) => void;
// convex-test can create scheduled-function timers inside a transaction
// AsyncLocalStorage scope. Running the callback from a clean async resource
// prevents scheduled component functions from inheriting stale transaction state.
return nativeSetTimeout(
() =>
setTimeoutWithoutConvexTestTransaction.runInAsyncScope(
callback,
undefined,
...args,
),
timeout,
);
}) as typeof globalThis.setTimeout;
(patchedSetTimeout as any)[CONVEX_TEST_TIMER_PATCH] = true;
globalThis.setTimeout = patchedSetTimeout;
}
I had GPT-5.5 write this summary up after debugging tons of hanging transactions in
quick-convexwhen usingconvex-test.Summary
I’m seeing
convex-testenter an invalid transaction state when a mutation schedules an immediate function and tests drain scheduled functions with real timers.This reproduces on
convex-test@0.0.50and still reproduces on0.0.51. It does not appear to be specific to my component logic. The failure seems related to scheduled-functionsetTimeoutcallbacks inheriting transactionAsyncLocalStoragefrom the scheduling mutation, so later scheduled functions are treated as nested transactions even though no parent transaction is active.Typical errors:
Error when running scheduled function repro:scheduledMutation Error: Transaction already committed or rolled back at DatabaseFake.rollbackWrites (.../node_modules/convex-test/dist/index.js:220:19) at TransactionManager.rollback (.../node_modules/convex-test/dist/index.js:1385:26) Error when running scheduled function repro:scheduledAction Error: Transaction not started at TransactionManager._endTransaction (.../node_modules/convex-test/dist/index.js:1391:19) at TransactionManager.rollback (.../node_modules/convex-test/dist/index.js:1387:14)Minimal Reproduction
convex/schema.tsconvex/repro.tsconvex/repro.test.tsExpected
The scheduled mutation and scheduled action complete, and
eventCountis1.Actual
The scheduled functions log transaction errors, and
eventCountremains0.Notes
Using fake timers and manually advancing them avoids the race in many cases. The failure shows up when real timers fire scheduled callbacks while the test is polling
finishInProgressScheduledFunctions().(human Dan again)
Temp fix
This absolute nightmare: