Skip to content

Commit 243ccaf

Browse files
reeceyangclaude
andauthored
Let the batch worker's idle behavior be tuned per aggregate (#268)
Every queued write pings the batch worker, which reads the one record holding the worker's run state. Nothing writes that record while the worker keeps running, but parking it once the queue drains and waking it back up both do, and each of those writes makes the enqueues in flight retry. Waking is worse still: it's written from inside the enqueuing mutations, so a burst arriving at a parked worker has all of them fighting over it. The worker parks a couple of seconds after the queue drains, so a workload that writes in bursts pays for a park and a wake on every burst. Polling an empty queue for longer rides out the gaps instead: bursts closer together than the cooldown never park the worker, and never contend. The right trade depends on the write pattern, so expose it as two optional env vars on the component rather than picking a default. Left unset, the batch worker's own defaults apply and nothing changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 131e0bc commit 243ccaf

5 files changed

Lines changed: 112 additions & 2 deletions

File tree

‎src/component/_generated/server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import type { DataModel } from "./dataModel.js";
3939
type Env = {
4040
readonly CONVEX_CLOUD_URL: string;
4141
readonly CONVEX_SITE_URL: string;
42+
readonly WORKER_IDLE_COOLDOWN_MS: string | undefined;
43+
readonly WORKER_POLL_INTERVAL_MS: string | undefined;
4244
};
4345

4446
/**

‎src/component/convex.config.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { defineComponent } from "convex/server";
2+
import { v } from "convex/values";
23
import batchWorker from "@convex-dev/batch-worker/convex.config.js";
34

4-
const component = defineComponent("aggregate");
5+
const component = defineComponent("aggregate", {
6+
env: {
7+
WORKER_IDLE_COOLDOWN_MS: v.optional(v.string()),
8+
WORKER_POLL_INTERVAL_MS: v.optional(v.string()),
9+
},
10+
});
511
component.use(batchWorker);
612

713
export default component;

‎src/component/worker.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from "./btree.js";
3333
import {
3434
BATCH_MAX_OPERATIONS,
35+
parseDuration,
3536
MAX_BYTES_PER_ENTRY,
3637
enqueueOperations,
3738
hasHeadroomToFinish,
@@ -104,6 +105,13 @@ async function drainViaWorker(t: TestConvex<typeof schema>): Promise<void> {
104105
await t.finishAllScheduledFunctions(vi.runAllTimers);
105106
}
106107

108+
// The batch worker's run state for our queue, or null before it is registered.
109+
async function workerStatus(t: TestConvex<typeof schema>) {
110+
return await t.run(async (ctx) =>
111+
ctx.runQuery(components.batchWorker.lib.status, { name: OPS_WORKER_NAME }),
112+
);
113+
}
114+
107115
// The cursor the batch worker has committed for our queue, or null before it
108116
// has drained anything.
109117
async function workerCursor(
@@ -901,6 +909,87 @@ describe("guards", () => {
901909
});
902910
});
903911

912+
describe("staying awake between bursts", () => {
913+
// Like `drainViaWorker`, but without running the clock past the worker's
914+
// next poll, so its run state stays observable mid-gap.
915+
async function advance(t: TestConvex<typeof schema>, ms: number) {
916+
for (let elapsed = 0; elapsed < ms; elapsed += 200) {
917+
vi.advanceTimersByTime(200);
918+
await t.finishInProgressScheduledFunctions();
919+
}
920+
}
921+
922+
afterEach(() => {
923+
vi.unstubAllEnvs();
924+
});
925+
926+
const COOLDOWN_MS = 60_000;
927+
928+
function configureWorker() {
929+
vi.stubEnv("WORKER_IDLE_COOLDOWN_MS", String(COOLDOWN_MS));
930+
vi.stubEnv("WORKER_POLL_INTERVAL_MS", "1000");
931+
}
932+
933+
test("an unusable env var is left unset", () => {
934+
expect(parseDuration("5000")).toBe(5_000);
935+
expect(parseDuration(undefined)).toBeUndefined();
936+
expect(parseDuration("")).toBeUndefined();
937+
expect(parseDuration("soon")).toBeUndefined();
938+
expect(parseDuration("0")).toBeUndefined();
939+
expect(parseDuration("-1000")).toBeUndefined();
940+
});
941+
942+
test("an empty queue defers to the batch worker unless configured", async () => {
943+
const t = initConvexTest();
944+
expect(
945+
await t.query(internal.worker.getBatch, { name: OPS_WORKER_NAME }),
946+
).toEqual({ kind: "idle" });
947+
948+
configureWorker();
949+
expect(
950+
await t.query(internal.worker.getBatch, { name: OPS_WORKER_NAME }),
951+
).toEqual({ kind: "idle", cooldownMs: COOLDOWN_MS, pollIntervalMs: 1_000 });
952+
});
953+
954+
test("the worker stays running across a gap shorter than the cooldown", async () => {
955+
configureWorker();
956+
const t = initConvexTest();
957+
await enqueue(t, { type: "insert", key: 1, value: "a" });
958+
await advance(t, COOLDOWN_MS / 2);
959+
expect(await workerStatus(t)).toEqual({ kind: "running" });
960+
961+
await enqueue(t, { type: "insert", key: 2, value: "b" });
962+
await advance(t, 2_000);
963+
await t.run(async (ctx) => {
964+
expect(await getHandler(ctx, { key: 2 })).toEqual({
965+
k: 2,
966+
v: "b",
967+
s: 0,
968+
});
969+
});
970+
});
971+
972+
test("it still parks once the queue has been empty for the cooldown", async () => {
973+
configureWorker();
974+
const t = initConvexTest();
975+
await enqueue(t, { type: "insert", key: 1, value: "a" });
976+
await advance(t, COOLDOWN_MS + 2_000);
977+
expect(await workerStatus(t)).toEqual({ kind: "idle" });
978+
979+
await enqueue(t, { type: "insert", key: 2, value: "b" });
980+
expect(await workerStatus(t)).toEqual({ kind: "running" });
981+
await drainViaWorker(t);
982+
await t.run(async (ctx) => {
983+
expect(await getHandler(ctx, { key: 2 })).toEqual({
984+
k: 2,
985+
v: "b",
986+
s: 0,
987+
});
988+
expect(await ctx.db.query("pendingOperations").collect()).toEqual([]);
989+
});
990+
});
991+
});
992+
904993
describe("pinging the worker", () => {
905994
beforeEach(() => {
906995
vi.mocked(ping).mockClear();

‎src/component/worker.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import type { TransactionMetrics, WithoutSystemFields } from "convex/server";
1010
import {
1111
type DatabaseWriter,
12+
env,
1213
internalMutation,
1314
internalQuery,
1415
type MutationCtx,
@@ -35,6 +36,13 @@ export const MAX_BYTES_PER_ENTRY = 100 * 1024;
3536

3637
export const OPS_WORKER_NAME = "ops";
3738

39+
export function parseDuration(value: string | undefined): number | undefined {
40+
const parsed = Number(value);
41+
return value !== undefined && Number.isFinite(parsed) && parsed > 0
42+
? parsed
43+
: undefined;
44+
}
45+
3846
export async function enqueueOperations(
3947
ctx: MutationCtx,
4048
operations: Operation[],
@@ -144,7 +152,11 @@ export const getBatch = internalQuery({
144152
operations += row.operations.length;
145153
}
146154
if (entries.length === 0) {
147-
return { kind: "idle" as const };
155+
return {
156+
kind: "idle" as const,
157+
cooldownMs: parseDuration(env.WORKER_IDLE_COOLDOWN_MS),
158+
pollIntervalMs: parseDuration(env.WORKER_POLL_INTERVAL_MS),
159+
};
148160
}
149161
return { kind: "work" as const, batch: { entries } };
150162
},

‎tsconfig.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
"target": "ESNext",
88
"lib": ["ES2021", "dom", "DOM.Iterable"],
9+
"types": ["node"],
910
"jsx": "react-jsx",
1011
"forceConsistentCasingInFileNames": true,
1112
"allowSyntheticDefaultImports": true,

0 commit comments

Comments
 (0)