Skip to content

Commit 3aabb7b

Browse files
MA2153claudeemdashbot[bot]
authored
fix(core): drive taxonomy term counts from the pivot (#2238)
* fix(core): drive taxonomy term counts from the pivot The consolidated term-count query joined `content_taxonomies` to the content table with an `INNER JOIN`. On stats-blind SQLite/D1 the planner picked `ec_*` as the outer table and re-ran the whole `taxonomy_id IN (SELECT ...)` term list as a pivot-primary-key probe for every visible entry in the collection: SEARCH e USING INDEX idx_ec_<collection>_deleted_status (deleted_at=?) SEARCH ct USING COVERING INDEX sqlite_autoindex_content_taxonomies_1 (collection=? AND entry_id=? AND taxonomy_id=?) LIST SUBQUERY 1 so the cost was `entries x terms`, not a scan — which is why the composite indexes on the pivot never helped: the pivot was never the driving table. On a collection of ~26k entries with a ~1.4k-term taxonomy one call read ~35.6M rows in ~29s, on every render of a term list or taxonomy filter. Switch to `CROSS JOIN` with the join predicate in `WHERE`. In SQLite that is a join-order hint, not a cartesian product: it pins the pivot as outer, so the terms are seeked on a `(taxonomy_id, collection)` index and the content row is touched once per assignment by primary key. Postgres has statistics and treats it as a plain inner join. Measured on a production D1 with the dataset above: 35,627,677 rows / 28,892ms -> 63,854 rows / 120ms. The predicates are untouched, so the counts are identical. Closes #2237 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: update query-count snapshots * fix(core): trim narrative/issue-referencing comments per review Comments should state the invariant, not the PR story or issue number. * fix(core): tighten changeset and comment per review Keep the changeset user-facing (observable slowness, not internal mechanics); drop the justifying "deliberately". --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
1 parent 53dbf22 commit 3aabb7b

5 files changed

Lines changed: 135 additions & 14 deletions

File tree

.changeset/great-cases-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes taxonomy term counts reading a near-quadratic number of rows on sites with many entries and terms, causing multi-second delays on pages that render term counts or taxonomy filters. Counts are unchanged.

packages/core/src/taxonomies/term-counts.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ interface CountRow {
3737
* join on `taxonomies.id` — the anchor row (id == group) can be deleted while
3838
* sibling translations survive, and a plain join on `translation_group` would
3939
* multiply counts by the number of locales.
40+
*
41+
* CROSS JOIN with the join predicate in WHERE keeps stats-blind SQLite/D1 from
42+
* reordering content_taxonomies out of the outer position; it touches ec_* only
43+
* by primary key. Postgres treats this as an ordinary inner join and plans freely.
4044
*/
4145
function collectionBranch(
4246
db: Kysely<Database>,
@@ -46,8 +50,9 @@ function collectionBranch(
4650
return sql`
4751
SELECT ct.taxonomy_id AS taxonomy_id, COUNT(*) AS count
4852
FROM content_taxonomies AS ct
49-
INNER JOIN ${sql.ref(`ec_${collection}`)} AS e ON e.id = ct.entry_id
50-
WHERE ct.collection = ${collection}
53+
CROSS JOIN ${sql.ref(`ec_${collection}`)} AS e
54+
WHERE e.id = ct.entry_id
55+
AND ct.collection = ${collection}
5156
AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ${taxonomyName})
5257
AND ${buildStatusCondition(db, "published", "e")}
5358
AND e.deleted_at IS NULL
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* SQLite query-plan regression guard for the consolidated term-count query.
3+
* Output correctness is covered by unit/taxonomies/term-counts; this asserts
4+
* the planner drives from content_taxonomies, not from ec_*.
5+
*/
6+
7+
import Database from "better-sqlite3";
8+
import { Kysely, SqliteDialect } from "kysely";
9+
import { afterEach, beforeEach, expect, it } from "vitest";
10+
11+
import { runMigrations } from "../../src/database/migrations/runner.js";
12+
import { ContentRepository } from "../../src/database/repositories/content.js";
13+
import { TaxonomyRepository } from "../../src/database/repositories/taxonomy.js";
14+
import type { Database as DatabaseSchema } from "../../src/database/types.js";
15+
import { SchemaRegistry } from "../../src/schema/registry.js";
16+
import { fetchVisibleTermCounts } from "../../src/taxonomies/term-counts.js";
17+
18+
interface CapturedQuery {
19+
sql: string;
20+
parameters: readonly unknown[];
21+
}
22+
23+
let sqlite: Database.Database;
24+
let db: Kysely<DatabaseSchema>;
25+
let captured: CapturedQuery[];
26+
27+
beforeEach(async () => {
28+
captured = [];
29+
sqlite = new Database(":memory:");
30+
db = new Kysely<DatabaseSchema>({
31+
dialect: new SqliteDialect({ database: sqlite }),
32+
log(event) {
33+
if (event.level === "query") {
34+
captured.push({ sql: event.query.sql, parameters: event.query.parameters });
35+
}
36+
},
37+
});
38+
39+
// No ANALYZE: D1 never maintains sqlite_stat1.
40+
await runMigrations(db);
41+
const registry = new SchemaRegistry(db);
42+
await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" });
43+
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
44+
45+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema vs Database type
46+
const anyDb = db as any;
47+
const content = new ContentRepository(anyDb);
48+
const tax = new TaxonomyRepository(anyDb);
49+
50+
// Enough rows to make the two access paths visually distinct.
51+
const terms = [];
52+
for (let i = 0; i < 5; i++) {
53+
terms.push(
54+
await tax.create({ name: "category", slug: `term-${i}`, label: `Term ${i}`, locale: "en" }),
55+
);
56+
}
57+
for (let i = 0; i < 20; i++) {
58+
const post = await content.create({
59+
type: "post",
60+
slug: `post-${i}`,
61+
data: { title: `Post ${i}` },
62+
status: "published",
63+
locale: "en",
64+
});
65+
await tax.attachToEntry("post", post.id, terms[i % terms.length]!.id);
66+
}
67+
});
68+
69+
afterEach(async () => {
70+
await db.destroy();
71+
});
72+
73+
/** better-sqlite3 only binds primitives; coerce the JS values Kysely captured. */
74+
function bindable(p: unknown): unknown {
75+
if (typeof p === "boolean") return p ? 1 : 0;
76+
if (p instanceof Date) return p.toISOString();
77+
if (p === undefined) return null;
78+
return p;
79+
}
80+
81+
function explain(query: CapturedQuery): string {
82+
const rows = sqlite
83+
.prepare(`EXPLAIN QUERY PLAN ${query.sql}`)
84+
.all(...query.parameters.map(bindable)) as { detail: string }[];
85+
return rows.map((r) => r.detail).join("\n");
86+
}
87+
88+
async function countQueryPlan(): Promise<string> {
89+
captured = [];
90+
await fetchVisibleTermCounts(db, "category", ["post"]);
91+
const query = captured.find((q) => q.sql.includes("content_taxonomies"));
92+
expect(query, "expected a term-count query against the pivot").toBeDefined();
93+
return explain(query!);
94+
}
95+
96+
it("seeks the terms on a content_taxonomies index rather than probing the pivot per entry", async () => {
97+
const plan = await countQueryPlan();
98+
99+
// The pivot must be entered on a taxonomy_id-leading index.
100+
expect(plan).toMatch(/SEARCH ct USING (COVERING )?INDEX idx_content_taxonomies/);
101+
expect(plan).not.toContain("sqlite_autoindex_content_taxonomies_1");
102+
expect(plan).not.toContain("SCAN ct");
103+
});
104+
105+
it("touches the content table only by primary key", async () => {
106+
const plan = await countQueryPlan();
107+
108+
expect(plan).toContain("SEARCH e USING");
109+
expect(plan).toMatch(/SEARCH e USING (COVERING )?INDEX sqlite_autoindex_ec_post_1 \(id=\?\)/);
110+
expect(plan).not.toContain("SCAN e");
111+
});

0 commit comments

Comments
 (0)