From fb2515649a60ef1e7db214928779147b46f1b5c3 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 11 Aug 2026 11:30:43 -0700 Subject: [PATCH] fix(search): bound rolling usage query batches (#3456) --- convex/lib/canonicalSkillSearchBounds.ts | 2 +- convex/search.test.ts | 54 +++++++++++++++++++++++- convex/search.ts | 4 +- e2e/clawhub.e2e.test.ts | 5 ++- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/convex/lib/canonicalSkillSearchBounds.ts b/convex/lib/canonicalSkillSearchBounds.ts index dea490c6..95c35e5c 100644 --- a/convex/lib/canonicalSkillSearchBounds.ts +++ b/convex/lib/canonicalSkillSearchBounds.ts @@ -5,5 +5,5 @@ export const CANONICAL_SKILL_SEARCH_BOUNDS = { externalCandidateLimitPerIndex: 50, externalIndexedReadCount: 6, rollingAdoptionDays: 60, - rollingUsageBatchSize: 40, + rollingUsageBatchSize: 20, } as const; diff --git a/convex/search.test.ts b/convex/search.test.ts index 126d21b8..5e0959b6 100644 --- a/convex/search.test.ts +++ b/convex/search.test.ts @@ -1295,12 +1295,12 @@ describe("search helpers", () => { getRollingSkillSearchUsageHandler( { db: { query: vi.fn() } }, { - skillIds: Array.from({ length: 41 }, (_, index) => `skills:${index}`), + skillIds: Array.from({ length: 21 }, (_, index) => `skills:${index}`), startDay: 100, endDay: 159, }, ), - ).rejects.toThrow("skillIds exceeds 40"); + ).rejects.toThrow("skillIds exceeds 20"); }); it("returns one ordered native and external contract with canonical routes and install refs", async () => { @@ -1370,6 +1370,56 @@ describe("search helpers", () => { }); }); + it("preserves rolling adoption ranking when usage reads require smaller transactions", async () => { + generateEmbeddingMock.mockRejectedValueOnce(new Error("embedding unavailable")); + const native = Array.from({ length: 100 }, (_, index) => ({ + skill: makePublicSkill({ + id: `skills:calendar-${index}`, + slug: `calendar-${index}`, + displayName: `Calendar ${index}`, + }), + version: null, + ownerHandle: "openclaw", + owner: null, + })); + const runQuery = vi.fn( + async (ref: Parameters[0], args?: { skillIds?: string[] }) => { + switch (getFunctionName(ref)) { + case "search:getExactSkillSlugMatch": + case "search:lexicalFallbackSkills": + case "search:getExternalSkillSearchCandidates": + return []; + case "search:directPrefixSkillMatches": + return native; + case "search:getRollingSkillSearchUsage": { + const skillIds = args?.skillIds ?? []; + if (skillIds.length > 20) { + throw new Error("Function execution timed out (maximum duration: 1s)"); + } + return skillIds.map((skillId) => ({ + skillId, + installs: skillId === "skills:calendar-99" ? 10_000 : 1, + bookmarks: 0, + })); + } + default: + throw new Error(`Unexpected query ${getFunctionName(ref)}`); + } + }, + ); + + const result = await canonicalSearchSkillsHandler( + { runQuery, vectorSearch: vi.fn() }, + { query: "calendar", limit: 100 }, + ); + + expect(result).toHaveLength(100); + expect(result[0]).toMatchObject({ + id: "clawhub:skills:calendar-99", + metrics: { rolling60DayInstalls: 10_000 }, + }); + }); + it("keeps exact mode deterministic across canonical sources", async () => { const native = { skill: makePublicSkill({ diff --git a/convex/search.ts b/convex/search.ts index 2d4fbc63..150cc936 100644 --- a/convex/search.ts +++ b/convex/search.ts @@ -664,8 +664,8 @@ type CanonicalSkillSearchResult = { const CANONICAL_NATIVE_CANDIDATE_LIMIT = CANONICAL_SKILL_SEARCH_BOUNDS.nativeCandidateLimit; const CANONICAL_RESULT_LIMIT_MAX = CANONICAL_SKILL_SEARCH_BOUNDS.resultLimit; const ROLLING_ADOPTION_DAYS = CANONICAL_SKILL_SEARCH_BOUNDS.rollingAdoptionDays; -// Forty candidates can read at most 2,400 daily rows, leaving headroom below -// Convex's per-transaction document/byte limits for imported production-shaped data. +// Twenty candidates read at most 1,200 daily rows. Keep this well below the +// query CPU ceiling: production-shaped 40-skill batches had intermittent 1s timeouts. const ROLLING_USAGE_QUERY_BATCH_SIZE = CANONICAL_SKILL_SEARCH_BOUNDS.rollingUsageBatchSize; function chunkValues(values: T[], size: number) { diff --git a/e2e/clawhub.e2e.test.ts b/e2e/clawhub.e2e.test.ts index 6670b4df..3948f443 100644 --- a/e2e/clawhub.e2e.test.ts +++ b/e2e/clawhub.e2e.test.ts @@ -342,8 +342,9 @@ describe("clawhub e2e", () => { const response = await fetchWithTimeout(url.toString(), { headers: { Accept: "application/json" }, }); - expect(response.ok).toBe(true); - const json = (await response.json()) as unknown; + const body = await response.text(); + expect(response.ok, `search failed with ${response.status}: ${body}`).toBe(true); + const json = JSON.parse(body) as unknown; const parsed = parseArk(ApiV1SearchResponseSchema, json, "API response"); expect(Array.isArray(parsed.results)).toBe(true); });