Skip to content
Merged
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
249 changes: 139 additions & 110 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ All three drain their queue by returning a `v.commitTs()` cursor from the work
query. The component commits it with the batch and hands it back on the next
call, so each scan resumes where the last one stopped.

The work query may read an older snapshot than the worker mutation, but each
round sees this worker's committed writes from previous rounds, including
deletes and claims. The queue and LLM examples return IDs and re-fetch current
rows to handle edits, deletes, or claims by other mutations between snapshots.
If only this worker can change or remove queued work, values can be passed
directly through the batch. The scoreboard does this because its score events
are immutable and its exclusive cursor tracks processed events.

Run all commands from the **root of the repo**.

## Develop
Expand Down
5 changes: 5 additions & 0 deletions example/convex/aggregates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
// batches of them into per-team totals. Because exactly one loop runs at a
// time, those hot aggregate rows have a single writer — so they never OCC
// against each other or against incoming scores, no matter the rate.
//
// This example treats scoreEvents as immutable, append-only events. Passing
// their values through the batch is safe under that assumption; an exclusive
// cursor skips events already folded into the totals. If events can be edited
// or deleted, return IDs and re-check their current state in the mutation.

const WORKER = "aggregates";

Expand Down
21 changes: 11 additions & 10 deletions example/convex/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ export const enqueue = mutation({
const { vQueryArgs, vQueryReturns, vMutationArgs } =
defineBatchWorkerValidators({
batch: {
items: v.array(
v.object({ id: v.id("e2eEvents"), creationTime: v.number() }),
),
ids: v.array(v.id("e2eEvents")),
},
});

Expand All @@ -76,10 +74,7 @@ export const getBatch = internalQuery({
return {
kind: "work" as const,
batch: {
items: events.map((e) => ({
id: e._id,
creationTime: e._creationTime,
})),
ids: events.map((e) => e._id),
},
cursor: events.at(-1)!.updatedAt,
};
Expand All @@ -88,17 +83,23 @@ export const getBatch = internalQuery({

export const processBatch = internalMutation({
args: vMutationArgs,
handler: async (ctx, { items }) => {
handler: async (ctx, { ids }) => {
// Match the queue example: measure only rows still present in this snapshot.
const candidates = await Promise.all(
ids.map((id) => ctx.db.get("e2eEvents", id)),
);
const items = candidates.filter((item) => item !== null);
if (items.length === 0) return;
const now = Date.now();
const latencies = items.map((b) => now - b.creationTime);
const latencies = items.map((b) => now - b._creationTime);
await ctx.db.insert("e2eSamples", {
processedAt: now,
batchSize: items.length,
oldestLatencyMs: Math.max(...latencies),
newestLatencyMs: Math.min(...latencies),
});
for (const b of items) {
await ctx.db.delete("e2eEvents", b.id);
await ctx.db.delete("e2eEvents", b._id);
}
},
});
Expand Down
31 changes: 30 additions & 1 deletion example/convex/example.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { initConvexTest } from "./setup.test";
import { api, components } from "./_generated/api";
import { api, components, internal } from "./_generated/api";

describe("example worker", () => {
beforeEach(() => {
Expand All @@ -21,6 +21,35 @@ describe("example worker", () => {
expect(totals).toEqual({ total: 5, count: 1, pending: 0 });
});

test("re-fetches changed events and skips deleted or already processed candidates", async () => {
const t = initConvexTest();
const ids = await t.run(async (ctx) => {
const first = await ctx.db.insert("events", {
value: 1,
insertedAt: ctx.db.vars.commitTs,
});
const second = await ctx.db.insert("events", {
value: 2,
insertedAt: ctx.db.vars.commitTs,
});
return [first, second];
});
const batch = await t.query(internal.example.getBatch, { name: "events" });
if (!batch.batch) throw new Error("Expected work");
// Explicitly create the gap between the query and mutation snapshots.
await t.run(async (ctx) => {
await ctx.db.patch("events", ids[0], { value: 10 });
await ctx.db.delete("events", ids[1]);
});
await t.mutation(internal.example.processBatch, batch.batch);
await t.mutation(internal.example.processBatch, batch.batch);
expect(await t.query(api.example.getTotals, {})).toEqual({
total: 10,
count: 1,
pending: 0,
});
});

test("batches many events across iterations", async () => {
const t = initConvexTest();
// 25 events => 3 batches of 10/10/5.
Expand Down
19 changes: 12 additions & 7 deletions example/convex/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,8 @@ export const addEvent = mutation({
},
});

const vEvent = v.object({ id: v.id("events"), value: v.number() });

const { vQueryArgs, vQueryReturns, vMutationArgs, vMutationReturns } =
defineBatchWorkerValidators({ batch: { events: v.array(vEvent) } });
defineBatchWorkerValidators({ batch: { ids: v.array(v.id("events")) } });

/**
* The work query: returns the next batch of work, or `idle` when the queue is
Expand All @@ -59,7 +57,7 @@ export const getBatch = internalQuery({
}
return {
kind: "work" as const,
batch: { events: events.map((e) => ({ id: e._id, value: e.value })) },
batch: { ids: events.map((e) => e._id) },
// Rows come back in commit order, so the last one is how far we got.
// The component commits this with the batch and hands it back above.
cursor: events.at(-1)!.insertedAt,
Expand All @@ -75,7 +73,14 @@ export const getBatch = internalQuery({
export const processBatch = internalMutation({
args: vMutationArgs,
returns: vMutationReturns,
handler: async (ctx, { events }) => {
handler: async (ctx, { ids }) => {
// Re-fetch to handle edits or deletes by other mutations between snapshots.
// The query already sees this worker's deletes from previous rounds.
const candidates = await Promise.all(
ids.map((id) => ctx.db.get("events", id)),
);
const events = candidates.filter((event) => event !== null);
if (events.length === 0) return null;
const sum = events.reduce((a, e) => a + e.value, 0);
const totals = await ctx.db
.query("totals")
Expand All @@ -93,8 +98,8 @@ export const processBatch = internalMutation({
count: events.length,
});
}
for (const { id } of events) {
await ctx.db.delete("events", id);
for (const { _id } of events) {
await ctx.db.delete("events", _id);
}
},
});
Expand Down
51 changes: 50 additions & 1 deletion example/convex/rateLimited.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { initConvexTest } from "./setup.test";
import { api } from "./_generated/api";
import { api, internal } from "./_generated/api";

describe("rate-limited LLM batches", () => {
beforeEach(() => {
Expand All @@ -27,6 +27,55 @@ describe("rate-limited LLM batches", () => {
expect(requests.every((r) => r.response?.startsWith("Echo:"))).toBe(true);
});

test("re-checks stale candidates before reserving tokens and scheduling work", async () => {
const t = initConvexTest();
const ids = await t.run(async (ctx) => {
const ids = [];
for (let i = 0; i < 3; i++) {
ids.push(
await ctx.db.insert("llmRequests", {
prompt: `old ${i}`,
inputTokens: 1,
state: "pending",
updatedAt: ctx.db.vars.commitTs,
}),
);
}
return ids;
});
const batch = await t.query(internal.rateLimited.getBatch, { name: "llm" });
if (!batch.batch) throw new Error("Expected work");
await t.run(async (ctx) => {
await ctx.db.patch("llmRequests", ids[0], {
prompt: "current prompt",
inputTokens: 2,
});
await ctx.db.patch("llmRequests", ids[1], {
state: "finished",
response: "already done",
});
await ctx.db.delete("llmRequests", ids[2]);
});
await t.mutation(internal.rateLimited.startBatch, batch.batch);
const scheduled = await t.run((ctx) =>
ctx.db.system.query("_scheduled_functions").collect(),
);
await t.mutation(internal.rateLimited.startBatch, batch.batch);
expect(
await t.run((ctx) =>
ctx.db.system.query("_scheduled_functions").collect(),
),
).toEqual(scheduled);
expect(scheduled).toHaveLength(1);
expect(scheduled[0].args).toEqual([
{ prompts: [{ id: ids[0], prompt: "current prompt" }] },
]);
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(
await t.run((ctx) => ctx.db.get("llmRequests", ids[1])),
).toMatchObject({ response: "already done" });
});

test("claims every request once when one transaction's requests span batches", async () => {
const t = initConvexTest();
// 11 requests in one transaction — one commit timestamp, split by
Expand Down
39 changes: 19 additions & 20 deletions example/convex/rateLimited.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,10 @@ export const submitRequest = mutation({
},
});

// Carry everything the batch needs — including the prompt — so nothing has to
// go back to the database to re-read it.
const vLlmRequest = v.object({
id: v.id("llmRequests"),
prompt: v.string(),
inputTokens: v.number(),
});

// Return IDs so the mutation can check for changes by other mutations before
// claiming each request, using its current prompt and token estimate.
const { vQueryArgs, vQueryReturns, vMutationArgs, vMutationReturns } =
defineBatchWorkerValidators({ batch: { requests: v.array(vLlmRequest) } });
defineBatchWorkerValidators({ batch: { ids: v.array(v.id("llmRequests")) } });

export const getBatch = internalQuery({
args: vQueryArgs,
Expand All @@ -103,11 +97,7 @@ export const getBatch = internalQuery({
return {
kind: "work" as const,
batch: {
requests: pending.map((r) => ({
id: r._id,
prompt: r.prompt,
inputTokens: r.inputTokens,
})),
ids: pending.map((r) => r._id),
},
// Rows come back in commit order, so the last one is how far we got.
cursor: pending.at(-1)!.updatedAt,
Expand All @@ -122,7 +112,16 @@ export const getBatch = internalQuery({
export const startBatch = internalMutation({
args: vMutationArgs,
returns: vMutationReturns,
handler: async (ctx, { requests }) => {
handler: async (ctx, { ids }) => {
// Other mutations may have changed, claimed, or deleted these requests
// since the query's snapshot. This worker's prior claims are already visible.
const candidates = await Promise.all(
ids.map((id) => ctx.db.get("llmRequests", id)),
);
const requests = candidates.filter(
(r): r is NonNullable<typeof r> => r !== null && r.state === "pending",
);
if (requests.length === 0) return null;
const totalInputTokens = requests.reduce((a, r) => a + r.inputTokens, 0);

// Reserve the whole batch's input tokens up front. `reserve: true` never
Expand All @@ -133,13 +132,13 @@ export const startBatch = internalMutation({
reserve: true,
});

// Mark them started so getBatch won't hand them out again. Every request
// in the batch gets claimed, so it's safe to move the cursor past them.
// Claim only requests that are still pending in this mutation's snapshot.
// The next round's query sees these claims and excludes them from pending.
// Patches refresh `updatedAt` too, so the "started" range stays in claim
// order for the recovery scan sketched in getBatch's TODO.
const startedAt = Date.now();
for (const { id } of requests) {
await ctx.db.patch("llmRequests", id, {
for (const { _id } of requests) {
await ctx.db.patch("llmRequests", _id, {
state: "started",
startedAt,
updatedAt: ctx.db.vars.commitTs,
Expand All @@ -152,7 +151,7 @@ export const startBatch = internalMutation({
retryAfter ?? 0,
internal.rateLimited.runBatch,
{
prompts: requests.map((r) => ({ id: r.id, prompt: r.prompt })),
prompts: requests.map((r) => ({ id: r._id, prompt: r.prompt })),
},
);

Expand Down
6 changes: 6 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export type {
* - a **worker mutation** that processes a batch and owns its cleanup. It may
* return `{ debounceMs }` to throttle the loop.
*
* The work query may read an older snapshot than the worker mutation, but each
* round sees this worker's committed writes from previous rounds. Return IDs
* and re-fetch them when processing depends on changes by other writers;
* otherwise, values can be passed through the batch. A write by ID does not
* validate the query's values.
*
* Validate both with {@link defineBatchWorkerValidators}. The cursor type is
* taken from the work query's `cursor` arg, and both return types are checked
* against it.
Expand Down
8 changes: 5 additions & 3 deletions src/component/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ export const loop = internalMutation({
>;

// Stale snapshot read: no OCC dependency, so concurrent inserts while we
// drain don't force this loop to retry. If the query or worker mutation
// throws, this loop fails (and doesn't reschedule) — the monitor restarts
// it.
// drain don't force this loop to retry. The query's snapshot may be older
// than the worker mutation's, but includes this worker's committed writes
// from previous rounds. Revalidate rows when processing depends on changes
// by other writers. If the query or worker mutation throws, this loop fails
// (and doesn't reschedule) — the monitor restarts it.
const result = await ctx.runQuery(queryRef, queryArgs, {
useStaleSnapshot: true,
});
Expand Down
5 changes: 5 additions & 0 deletions src/component/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export type BatchQueryArgs<Cursor = DefaultCursor> = {
* explicit `idle` (optionally with a `timeoutMs` hint for when to check again
* — e.g. when the next item is scheduled).
*
* The query may read an older snapshot than the worker mutation, but each round
* sees this worker's committed writes from previous rounds. Re-fetch by ID when
* processing depends on changes by other writers. Values can be passed through
* when they cannot change before processing or older values are acceptable.
*
* Alongside a batch, return a `cursor` saying how far this batch got. The
* component commits it with the batch, and hands it back as `args.cursor` on
* the next call so the scan resumes there. It is committed only if the worker
Expand Down
Loading