fix(convex): reduce hot rate-limit and catalog reads

This commit is contained in:
Vincent Koc
2026-05-02 23:42:15 -07:00
parent cf5a6f6e8b
commit 2d42c3d57a
7 changed files with 45 additions and 20 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ import {
const MAX_TRENDING_LIMIT = 200;
const KEEP_LEADERBOARD_ENTRIES = 3;
const DAILY_STATS_PAGE_SIZE = 5_000;
const DAILY_STATS_PAGE_SIZE = 1_000;
// ---------------------------------------------------------------------------
// Action → Query → Mutation pattern (avoids 32K document-read limit)
+29
View File
@@ -173,6 +173,35 @@ describe("applyRateLimit headers", () => {
expect(headers.get("Retry-After")).toBeNull();
});
it("converts shard write conflicts into a rate-limit response", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_500_000);
const ctx = {
runQuery: vi.fn().mockResolvedValue({
allowed: true,
remaining: 19,
limit: 20,
resetAt: 2_530_000,
}),
runMutation: vi
.fn()
.mockRejectedValue(
new Error(
'Document in table "rateLimitShards" changed while this mutation was being run',
),
),
} as unknown as Parameters<typeof applyRateLimit>[0];
const request = new Request("https://example.com", {
headers: { "cf-connecting-ip": "203.0.113.1" },
});
const result = await applyRateLimit(ctx, request, "download");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.response.status).toBe(429);
expect(result.response.headers.get("Retry-After")).toBe("30");
});
it("allows authenticated users when user bucket is healthy and shared ip bucket is exhausted", async () => {
vi.spyOn(Date, "now").mockReturnValue(3_000_000);
const ctx = makeRateLimitCtx({
+2 -2
View File
@@ -4,7 +4,7 @@ import { getOptionalApiTokenUserId } from "./apiTokenAuth";
import { corsHeaders, mergeHeaders } from "./httpHeaders";
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_SHARDS = 16;
const RATE_LIMIT_SHARDS = 64;
export const RATE_LIMITS = {
read: { ip: 600, key: 2400 },
write: { ip: 45, key: 180 },
@@ -235,7 +235,7 @@ function shouldTrustForwardedIps() {
function isRateLimitWriteConflict(error: unknown) {
if (!(error instanceof Error)) return false;
return (
error.message.includes("rateLimits") &&
(error.message.includes("rateLimits") || error.message.includes("rateLimitShards")) &&
error.message.includes("changed while this mutation was being run")
);
}
+1 -1
View File
@@ -554,7 +554,7 @@ export const backfillLlmEval: ReturnType<typeof internalAction> = internalAction
return { error: "OPENAI_API_KEY not configured" };
}
const requestedBatchSize = Math.max(1, Math.floor(args.batchSize ?? 25));
const requestedBatchSize = Math.max(1, Math.min(Math.floor(args.batchSize ?? 25), 50));
const maxToSchedule =
args.maxToSchedule === undefined ? undefined : Math.max(0, Math.floor(args.maxToSchedule));
const cursor = args.cursor ?? 0;
+7 -7
View File
@@ -22,14 +22,12 @@ const consumeHandler = (
)._handler;
describe("rate limit sharding", () => {
it("sums all shards when checking status", async () => {
it("sums shard rows without reading the legacy rateLimits table", async () => {
const ctx = {
db: {
query: vi.fn((table: string) => ({
query: vi.fn(() => ({
withIndex: vi.fn(() => ({
collect: vi.fn(async () =>
table === "rateLimits" ? [{ count: 3 }] : [{ count: 4 }, { count: 5 }],
),
collect: vi.fn(async () => [{ count: 4 }, { count: 5 }]),
})),
})),
},
@@ -42,7 +40,9 @@ describe("rate limit sharding", () => {
});
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(8);
expect(result.remaining).toBe(11);
expect(ctx.db.query).toHaveBeenCalledTimes(1);
expect(ctx.db.query).toHaveBeenCalledWith("rateLimitShards");
});
it("writes only the selected shard when consuming", async () => {
@@ -55,7 +55,7 @@ describe("rate limit sharding", () => {
})),
})),
});
return { unique: vi.fn(async () => null) };
return { first: vi.fn(async () => null) };
});
const ctx = {
db: {
+2 -6
View File
@@ -19,16 +19,12 @@ export const getRateLimitStatusInternal = internalQuery({
return { allowed: false, remaining: 0, limit: args.limit, resetAt };
}
const legacyRows = await ctx.db
.query("rateLimits")
.withIndex("by_key_window", (q) => q.eq("key", args.key).eq("windowStart", windowStart))
.collect();
const shardRows = await ctx.db
.query("rateLimitShards")
.withIndex("by_key_window", (q) => q.eq("key", args.key).eq("windowStart", windowStart))
.collect();
const count = [...legacyRows, ...shardRows].reduce((sum, row) => sum + row.count, 0);
const count = shardRows.reduce((sum, row) => sum + row.count, 0);
const allowed = count < args.limit;
return {
allowed,
@@ -61,7 +57,7 @@ export const consumeRateLimitInternal = internalMutation({
.withIndex("by_key_window_shard", (q) =>
q.eq("key", args.key).eq("windowStart", windowStart).eq("shard", shard),
)
.unique();
.first();
if (!existing) {
await ctx.db.insert("rateLimitShards", {
+3 -3
View File
@@ -124,8 +124,8 @@ const MAX_LIST_LIMIT = 50;
const MAX_PUBLIC_LIST_LIMIT = 200;
const MAX_LIST_BULK_LIMIT = 200;
const MAX_LIST_TAKE = 1000;
const MAX_SKILL_CATALOG_SCAN_DOCUMENTS = 2_000;
const MAX_SKILL_CATALOG_SCAN_PAGES = 20;
const MAX_SKILL_CATALOG_SCAN_DOCUMENTS = 500;
const MAX_SKILL_CATALOG_SCAN_PAGES = 6;
const MAX_SKILL_CATALOG_SEARCH_PAGE_SIZE = 200;
const HARD_DELETE_BATCH_SIZE = 100;
const HARD_DELETE_VERSION_BATCH_SIZE = 10;
@@ -3906,7 +3906,7 @@ export const getActiveSkillBatchForLlmBackfillInternal = internalQuery({
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 10;
const batchSize = clampInt(args.batchSize ?? 10, 1, 50);
const cursor = args.cursor ?? 0;
// Use built-in by_creation_time index for stable cursor-based pagination