Skip to content

fix(core): drive taxonomy term counts from the pivot#2238

Open
MA2153 wants to merge 4 commits into
emdash-cms:mainfrom
MA2153:fix/2237-term-count-join-order
Open

fix(core): drive taxonomy term counts from the pivot#2238
MA2153 wants to merge 4 commits into
emdash-cms:mainfrom
MA2153:fix/2237-term-count-join-order

Conversation

@MA2153

@MA2153 MA2153 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the taxonomy term-count query reading entries × terms rows instead of seeking the pivot.

collectionBranch in packages/core/src/taxonomies/term-counts.ts joined content_taxonomies to the content table with an INNER JOIN. On stats-blind SQLite/D1 (no ANALYZE, no sqlite_stat1) the planner picked ec_* as the outer table and re-ran the whole taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) 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          ← re-probed once per outer row
SEARCH taxonomies USING INDEX idx_taxonomies_name_locale (name=?)

That is why the composite indexes added alongside #1892 / migration 051 never helped: they are all taxonomy_id-leading, and the pivot was never the driving table, so nothing could seek them. The cost is a nested loop, not a scan — it scales as visible entries × terms in the taxonomy.

The fix is CROSS JOIN with the join predicate moved into WHERE. In SQLite that is a join-order hint, not a cartesian product: it pins the pivot as the outer table. The terms are then seeked on a (taxonomy_id, collection) index and the content row is touched exactly once per assignment, by primary key:

SEARCH ct USING COVERING INDEX idx_content_taxonomies_loc_crt (taxonomy_id=? AND collection=?)
SEARCH e USING INDEX sqlite_autoindex_ec_<collection>_1 (id=?)

Postgres has statistics and reorders freely, so CROSS JOIN there is a plain inner join.

No semantic change. The predicates are untouched — this only changes join order. Counts are byte-identical (verified independently against the same dataset).

Measured on a production D1

One call, one taxonomy, UNION ALL across 3 collections, ~26k entries in the dominant collection and ~1.4k terms:

rows_read sql_duration_ms
before 35,627,677 28,892
after 63,854 120

558× fewer rows read, 241× faster.

Rejected alternative

Counting the denormalized content_taxonomies.status / deleted_at columns (migration 051) and dropping the content-table join entirely. Those columns are advisory — 051's contract is that the read path re-checks the authoritative ec_* row — and measuring both on the same data showed the trusting version actually reads more (54,610 vs 52,282 rows), because no pivot index covers status/scheduled_at, so every candidate costs a table lookup anyway. Staying authoritative was both more correct and cheaper.

Unrelated observation, not addressed here

On the same request, TaxonomyRepository.findByName (select * from taxonomies where name = ? and locale = ?) read 2,899 rows to return 1,449 terms — one index entry plus one table row each, since idx_taxonomies_name_locale does not cover select *. That is correct behaviour and simply the cost of a taxonomy that large; flagging it because it shows up in the same trace and could be mistaken for part of this bug. Out of scope for this PR.

Closes #2237

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (pnpm lint:json → 0 diagnostics)
  • pnpm test passes (full packages/core suite: 5020 passed on SQLite; 5394 passed with EMDASH_TEST_PG against Postgres 17, so the new join shape is exercised on both dialects)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. — n/a, no admin UI strings
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a, bug fix

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

New regression test packages/core/tests/integration/taxonomy-term-counts-plan.test.ts, modelled on the existing loader-taxonomy-pivot-plan test. Written before the fix and confirmed failing against the unfixed source — the local stats-blind plan reproduced the production plan exactly:

 FAIL  tests/integration/taxonomy-term-counts-plan.test.ts > touches the content table only by primary key

- Expected:
/SEARCH e USING (COVERING )?INDEX sqlite_autoindex_ec_post_1 \(id=\?\)/

+ Received:
"CO-ROUTINE per_collection
SEARCH e USING INDEX idx_ec_post_loc_crt (deleted_at=?)
SEARCH ct USING COVERING INDEX sqlite_autoindex_content_taxonomies_1 (collection=? AND entry_id=? AND taxonomy_id=?)
LIST SUBQUERY 1
SEARCH taxonomies USING INDEX idx_taxonomies_name_locale (name=?)
..."

 Test Files  1 failed (1)
      Tests  2 failed (2)

With the fix:

 Test Files  1 passed (1)
      Tests  2 passed (2)

Full suite, both dialects:

 Test Files  388 passed | 1 skipped (389)
      Tests  5020 passed | 3 skipped (5023)      # SQLite

 Test Files  389 passed (389)
      Tests  5394 passed (5394)                  # + Postgres 17 parity

🤖 Generated with Claude Code

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 emdash-cms#2237

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: baafe3c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added area/core size/M review/needs-review No maintainer or bot review yet labels Jul 25, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2238

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2238

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2238

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2238

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2238

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2238

emdash

npm i https://pkg.pr.new/emdash@2238

create-emdash

npm i https://pkg.pr.new/create-emdash@2238

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2238

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2238

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2238

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2238

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2238

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2238

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2238

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2238

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2238

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2238

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2238

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2238

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2238

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2238

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2238

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2238

commit: baafe3c

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change is the right fix for the right problem. The issue described in #2237 is a real SQLite/D1 query-planning failure mode: without statistics, a stats-blind planner can choose to drive from ec_* and use the pivot only as a per-row probe, turning the cost into entries × terms. Replacing the INNER JOIN ... ON with CROSS JOIN ... WHERE is the accepted SQLite syntax for pinning the left-hand table as the outer input, and the author’s production measurements (~560× fewer rows, ~240× faster) are strong evidence it works. The SQL remains semantically identical, sql.ref() is used safely for the dynamic table name, validation already happens upstream, and the existing term-count output tests pass on both dialects.

The only blockers are AGENTS.md convention violations: the new comments read like a PR description. They reference #2237, explain the bug in narrative form, and justify the choice — exactly the kind of comment discipline AGENTS.md forbids. A short note about why CROSS JOIN is needed is appropriate (the trick is genuinely non-obvious), but it should not walk through the original bad plan or cite the issue. The new test file has the same problem in its header and several inline comments.

The plan-shape test itself is acceptable as a regression guard for this optimizer-specific issue, though it is inherently tied to SQLite internals; the repo already relies on dialect-specific correctness tests, so this is consistent.

Summary: fix is correct, but comments need cleanup before merge.

Comment thread packages/core/src/taxonomies/term-counts.ts Outdated
Comment thread packages/core/tests/integration/taxonomy-term-counts-plan.test.ts
Comment thread packages/core/tests/integration/taxonomy-term-counts-plan.test.ts Outdated
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review cla: signed overlap and removed review/needs-review No maintainer or bot review yet labels Jul 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Comments should state the invariant, not the PR story or issue number.
@MA2153 MA2153 added the bot:review Trigger an emdashbot code review on this PR label Jul 26, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change is the right fix for the right problem. Swapping the INNER JOIN ... ON for CROSS JOIN ... WHERE in collectionBranch pins content_taxonomies as the outer input under SQLite/D1's stats-blind planner, turning an entries × terms nested-loop into a pivot-index seek plus content primary-key lookup. The predicates are unchanged, so counts stay identical while row reads and latency drop by orders of magnitude. The author also addressed the prior review's comment-discipline concerns: the source docstring now just explains the non-obvious CROSS JOIN trick without narrating the original bug or citing the issue, and the new test header is similarly focused.

I checked SQL safety (sql.ref() for the dynamic table name, validated upstream), the translation-group-level counting semantics (no locale filter needed because the pivot stores translation groups, which the existing tests verify), and the query-count snapshots (they only update the SQL text to match the new join). The plan-shape regression test is consistent with the repo's existing SQLite-specific plan tests.

The remaining blocker is the changeset, which violates AGENTS.md's changeset discipline: it pastes PR prose, walks through internal mechanics (drove from the content table, seeks the terms on the content_taxonomies index), and includes production row counts/timings. A changeset is user-facing release notes, not a diff summary. One or two sentences describing the observable effect are enough.

Comment thread .changeset/great-cases-smile.md Outdated
Comment thread packages/core/tests/integration/taxonomy-term-counts-plan.test.ts Outdated
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 26, 2026
Keep the changeset user-facing (observable slowness, not internal
mechanics); drop the justifying "deliberately".
@MA2153 MA2153 added the bot:review Trigger an emdashbot code review on this PR label Jul 26, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prior review's remaining blocker — an overly verbose, internals-heavy changeset — has been fixed. The new changeset is now user-facing: one sentence describing the observable symptom (multi-second delays on pages that render term counts or taxonomy filters) and a brief reassurance that counts are unchanged. No internal mechanics, row counts, or PR prose remain.

Otherwise, the approach is still sound and this is the right fix for the right problem. Swapping INNER JOIN ... ON for CROSS JOIN ... WHERE in collectionBranch forces stats-blind SQLite/D1 to drive from content_taxonomies, turning an entries × terms nested loop into a pivot-index seek plus a single content-table primary-key lookup per assignment. The predicates are unchanged, so counts stay semantically identical.

I re-checked:

  • SQL safety: ec_${collection} is referenced via sql.ref() after validateIdentifier(); the dynamic identifier path is unchanged and safe.
  • Counting semantics: still keyed by translation_group, so per-locale variants share a single locale-independent count (covered by the existing term-counts.test.ts unit suite on both SQLite and Postgres).
  • Query-count snapshots: only the SQL text changed from INNER JOIN to CROSS JOIN; per-route query counts are unchanged.
  • The new plan test mirrors the existing loader-taxonomy-pivot-plan.test.ts style, exercises the stats-blind SQLite planner, and asserts the pivot is seeked via a taxonomy_id-leading index while the content table is reached only by primary key.
  • Comments in the source and test explain the non-obvious CROSS JOIN optimizer hint without narrating the bug or addressing the reviewer.

Clean re-review: the changeset issue is resolved and the rest of the diff is unchanged and correct.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 26, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Taxonomy term-count query (#1892) reads orders of magnitude more D1 rows than expected despite covering indexes

1 participant