fix(stats): avoid scan fallback for public skill count

This commit is contained in:
Vincent Koc
2026-04-29 23:29:17 -07:00
parent 477aae7c95
commit 33334c5afa
5 changed files with 13 additions and 73 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ bunx convex run --no-push devSeed:seedNixSkills
bunx convex run --no-push devSeedExtra:seedExtraSkillsInternal
# Refresh the cached skills count (required after seeding)
bunx convex run --no-push statsMaintenance:updateGlobalStatsInternal
bunx convex run --no-push statsMaintenance:updateGlobalStatsAction
```
To reset and re-seed:
+2 -15
View File
@@ -52,18 +52,6 @@ export function isGlobalStatsStorageNotReadyError(error: unknown) {
);
}
export async function countPublicSkillsForGlobalStats(ctx: GlobalStatsReadCtx) {
const digests = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.collect();
let count = 0;
for (const digest of digests) {
if (isPublicSkillDoc(digest)) count += 1;
}
return count;
}
export async function setGlobalPublicSkillsCount(
ctx: GlobalStatsWriteCtx,
count: number,
@@ -117,9 +105,8 @@ export async function adjustGlobalPublicSkillsCount(
}
if (!existing) {
// No baseline yet (e.g. fresh deploy). Initialize via full recount once.
const count = await countPublicSkillsForGlobalStats(ctx);
await setGlobalPublicSkillsCount(ctx, count, now);
// No baseline yet. The paginated stats maintenance action reconciles the full count.
await setGlobalPublicSkillsCount(ctx, Math.max(0, normalizedDelta), now);
return;
}
+4 -33
View File
@@ -15,19 +15,6 @@ const countPublicSkillsHandler = (
countPublicSkills as unknown as WrappedHandler<Record<string, never>, number>
)._handler;
function makeSkillsQuery(
skills: Array<{ softDeletedAt?: number; moderationStatus?: string | null }>,
) {
return {
withIndex: (name: string) => {
if (name !== "by_active_updated") throw new Error(`unexpected skills index ${name}`);
return {
collect: async () => skills,
};
},
};
}
describe("skills.countPublicSkills", () => {
it("returns precomputed global stats count when available", async () => {
const ctx = {
@@ -40,9 +27,6 @@ describe("skills.countPublicSkills", () => {
}),
};
}
if (table === "skillSearchDigest") {
return makeSkillsQuery([]);
}
throw new Error(`unexpected table ${table}`);
}),
},
@@ -52,7 +36,7 @@ describe("skills.countPublicSkills", () => {
expect(result).toBe(123);
});
it("falls back to live count when global stats row is missing", async () => {
it("returns zero when the global stats row is missing", async () => {
const ctx = {
db: {
query: vi.fn((table: string) => {
@@ -63,41 +47,28 @@ describe("skills.countPublicSkills", () => {
}),
};
}
if (table === "skillSearchDigest") {
return makeSkillsQuery([
{ softDeletedAt: undefined, moderationStatus: "active" },
{ softDeletedAt: undefined, moderationStatus: "hidden" },
{ softDeletedAt: undefined, moderationStatus: "active" },
]);
}
throw new Error(`unexpected table ${table}`);
}),
},
};
const result = await countPublicSkillsHandler(ctx, {});
expect(result).toBe(2);
expect(result).toBe(0);
});
it("falls back to live count when globalStats table is unavailable", async () => {
it("returns zero when globalStats table is unavailable", async () => {
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === "globalStats") {
throw new Error("unexpected table globalStats");
}
if (table === "skillSearchDigest") {
return makeSkillsQuery([
{ softDeletedAt: undefined, moderationStatus: "active" },
{ softDeletedAt: undefined, moderationStatus: "active" },
]);
}
throw new Error(`unexpected table ${table}`);
}),
},
};
const result = await countPublicSkillsHandler(ctx, {});
expect(result).toBe(2);
expect(result).toBe(0);
});
});
+1 -4
View File
@@ -34,7 +34,6 @@ import {
} from "./lib/githubIdentity";
import {
adjustGlobalPublicSkillsCount,
countPublicSkillsForGlobalStats,
getPublicSkillVisibilityDelta,
isPublicSkillDoc,
readGlobalPublicSkillsCount,
@@ -3423,9 +3422,7 @@ export const countPublicSkills = query({
args: {},
handler: async (ctx) => {
const statsCount = await readGlobalPublicSkillsCount(ctx);
if (typeof statsCount === "number") return statsCount;
// Fallback for uninitialized/missing globalStats storage.
return countPublicSkillsForGlobalStats(ctx);
return statsCount ?? 0;
},
});
+5 -20
View File
@@ -3,11 +3,7 @@ import { internal } from "./_generated/api";
import type { Doc } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import {
countPublicSkillsForGlobalStats,
isPublicSkillDoc,
setGlobalPublicSkillsCount,
} from "./lib/globalStats";
import { isPublicSkillDoc, setGlobalPublicSkillsCount } from "./lib/globalStats";
const DEFAULT_BATCH_SIZE = 200;
const MAX_BATCH_SIZE = 1000;
@@ -189,8 +185,7 @@ function buildSkillStatPatch(skill: Doc<"skills">) {
// nested `stats` object only for documents that pre-date the migration.
const nextDownloads =
typeof skill.statsDownloads === "number" ? skill.statsDownloads : stats.downloads;
const nextStars =
typeof skill.statsStars === "number" ? skill.statsStars : stats.stars;
const nextStars = typeof skill.statsStars === "number" ? skill.statsStars : stats.stars;
const nextInstallsCurrent =
typeof skill.statsInstallsCurrent === "number"
? skill.statsInstallsCurrent
@@ -279,7 +274,9 @@ export async function reconcileSkillStarCountsHandler(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.withIndex("by_skill", (q: any) => q.eq("skillId", skill._id))
.collect();
const actualComments = commentRecords.filter((c: { softDeletedAt?: unknown }) => !c.softDeletedAt).length;
const actualComments = commentRecords.filter(
(c: { softDeletedAt?: unknown }) => !c.softDeletedAt,
).length;
// Check if stats are out of sync (compare against the canonical value
// used by toPublicSkill: prefer top-level field, fall back to nested).
@@ -415,15 +412,3 @@ export const updateGlobalStatsAction = internalAction({
return { count: total };
},
});
/**
* @deprecated Use updateGlobalStatsAction instead.
* Kept as a manual emergency fallback only do not re-add to crons.
*/
export const updateGlobalStatsInternal = internalMutation({
args: {},
handler: async (ctx) => {
const count = await countPublicSkillsForGlobalStats(ctx);
await setGlobalPublicSkillsCount(ctx, count);
},
});