diff --git a/convex/leaderboards.ts b/convex/leaderboards.ts index 561fa13d..64bd49e9 100644 --- a/convex/leaderboards.ts +++ b/convex/leaderboards.ts @@ -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) diff --git a/convex/lib/httpRateLimit.test.ts b/convex/lib/httpRateLimit.test.ts index 2b6c0141..edb85131 100644 --- a/convex/lib/httpRateLimit.test.ts +++ b/convex/lib/httpRateLimit.test.ts @@ -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[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({ diff --git a/convex/lib/httpRateLimit.ts b/convex/lib/httpRateLimit.ts index 5bc0a277..bf2cbe54 100644 --- a/convex/lib/httpRateLimit.ts +++ b/convex/lib/httpRateLimit.ts @@ -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") ); } diff --git a/convex/llmEval.ts b/convex/llmEval.ts index a6f0202c..637ec8e1 100644 --- a/convex/llmEval.ts +++ b/convex/llmEval.ts @@ -554,7 +554,7 @@ export const backfillLlmEval: ReturnType = 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; diff --git a/convex/rateLimits.test.ts b/convex/rateLimits.test.ts index d7d566b7..2a3aae24 100644 --- a/convex/rateLimits.test.ts +++ b/convex/rateLimits.test.ts @@ -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: { diff --git a/convex/rateLimits.ts b/convex/rateLimits.ts index 0af13bd4..dbd63629 100644 --- a/convex/rateLimits.ts +++ b/convex/rateLimits.ts @@ -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", { diff --git a/convex/skills.ts b/convex/skills.ts index a7605a2a..465f193c 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -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