Skip to content

finishInProgressScheduledFunctions with real timers can corrupt transaction state for scheduled functions #112

Description

@danthegoodman1

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;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions