Skip to content

fix(core): drive the taxonomy pivot query from the most selective taxonomy, not the first where key - #3266

Open
eisenbruch wants to merge 6 commits into
emdash-cms:mainfrom
eisenbruch:fix/pivot-drive-from-selective
Open

eisenbruch wants to merge 6 commits into
emdash-cms:mainfrom
eisenbruch:fix/pivot-drive-from-selective

Conversation

@eisenbruch

Copy link
Copy Markdown
Contributor

What does this PR do?

buildTaxonomyPivotQuery walks groupSets[0] through content_taxonomies and checks every other set with a correlated EXISTS per candidate row. groupSets followed the caller's where key order, so the rows read depended on how the object was written: the same listings query read 3,131 rows as { business_type, places } and 539 as { places, business_type } (D1 meta.rows_read, #3259).

With two or more taxonomy filters, the resolved term groups are now counted in content_taxonomies (collection, taxonomy_id) (an index-only read per set) and sorted ascending, so the most selective taxonomy drives. One taxonomy is untouched. Results are identical; only the plan changes.

The second half of #3259 (any field filter abandons the pivot path) is the documented limitation in the comment above this branch and is not changed here.

Tests: a new key-order test (many posts in the category, one with the tag) asserts both orders return the same ids; the existing two-taxonomy tests still pass.

Refs #3259.

Type of change

  • Bug fix

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (core)
  • pnpm lint passes
  • pnpm test passes (loader-taxonomy-filter.test.ts and loader-field-filters.test.ts, 23 of 23)
  • pnpm format has been run
  • I have added/updated tests for my changes
  • I have added and reviewed the user-facing changeset

AI-generated code disclosure

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

Screenshots / test output

 Test Files  2 passed (2)
      Tests  23 passed (23)

@changeset-bot

changeset-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7db3e96

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

This PR includes changesets to release 12 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 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

@pkg-pr-new

pkg-pr-new Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@3266

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@3266

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3266

@emdash-cms/registry-verification

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

upgrade-emdash

npm i https://pkg.pr.new/upgrade-emdash@3266

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 7db3e96

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 23, 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 approach is sound: reordering the taxonomy sets so the most selective one drives the pivot seek fixes the key-order dependency described in #3259 and keeps results identical. The code is otherwise correct — the count query is parameterized through Kysely and the locale-agnostic pivot rows mean omitting a locale filter is appropriate.

The main concern is that the "optimization" introduces one extra round-trip per additional taxonomy filter on the public-collection hot path, which is exactly what EmDash’s performance conventions warn against ("one query beats two" and "the logged-out hot path only ratchets down"). The selectivity probe should be batched into a single GROUP BY taxonomy_id count instead of Promise.all per set. The new regression test also only asserts result equality, which was already true before the fix; it does not protect against the reported rows-read regression. The changeset and the new inline comment are usable but drift toward describing internal query mechanics rather than the user-facing behavior.

I did not run the test suite, builds, or query-count tooling; those claims are unverified.

Comment on lines +1334 to +1346
if (groupSets.length > 1) {
const sizes = await Promise.all(
groupSets.map((groups) =>
db
.selectFrom("content_taxonomies")
.select((eb) => eb.fn.countAll<number>().as("n"))
.where("collection", "=", type)
.where("taxonomy_id", "in", groups)
.executeTakeFirst()
.then((r) => Number(r?.n ?? 0)),
),
);
const ordered = groupSets

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.

[needs fixing] The new selectivity probe issues one query per taxonomy set in Promise.all. On a logged-out collection route filtered by two or more taxonomies, this adds N extra round-trips to D1 for every request that takes this path, partially undermining a PR whose goal is to reduce query cost. EmDash’s conventions prefer batching over multiple similar queries.

Use a single GROUP BY taxonomy_id count over the union of all sets, then sum per set locally:

Suggested change
if (groupSets.length > 1) {
const sizes = await Promise.all(
groupSets.map((groups) =>
db
.selectFrom("content_taxonomies")
.select((eb) => eb.fn.countAll<number>().as("n"))
.where("collection", "=", type)
.where("taxonomy_id", "in", groups)
.executeTakeFirst()
.then((r) => Number(r?.n ?? 0)),
),
);
const ordered = groupSets
if (groupSets.length > 1) {
const allGroups = groupSets.flat();
const rows = await db
.selectFrom("content_taxonomies")
.select(["taxonomy_id", (eb) => eb.fn.countAll<number>().as("n")])
.where("collection", "=", type)
.where("taxonomy_id", "in", allGroups)
.groupBy("taxonomy_id")
.execute();
const countById = new Map(rows.map((r) => [r.taxonomy_id, Number(r.n ?? 0)]));
const sizes = groupSets.map((groups) =>
groups.reduce((sum, g) => sum + (countById.get(g) ?? 0), 0),
);
const ordered = groupSets
.map((groups, i) => ({ groups, n: sizes[i] }))
.toSorted((a, b) => a.n - b.n)
.map((x) => x.groups);
groupSets.splice(0, groupSets.length, ...ordered);
}

Comment thread packages/core/src/loader.ts Outdated
Comment on lines +1330 to +1333
// The pivot query walks the FIRST set through `content_taxonomies` and checks the rest with a
// correlated EXISTS per candidate row, so the rows read depend on which set drives. Until now that
// was the caller's first `where` key: the same query read 3,131 rows one way round and 539 the
// other. Drive from the set with the fewest pivot rows; the counts are an index-only read.

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.

[suggestion] This comment reads like the PR description (specific observed row counts, historical ordering behavior) rather than a concise invariant for future readers. AGENTS.md asks comments to explain why the code must do something, not to summarize the change or PR.

Suggested change
// The pivot query walks the FIRST set through `content_taxonomies` and checks the rest with a
// correlated EXISTS per candidate row, so the rows read depend on which set drives. Until now that
// was the caller's first `where` key: the same query read 3,131 rows one way round and 539 the
// other. Drive from the set with the fewest pivot rows; the counts are an index-only read.
// The pivot query walks the first set through content_taxonomies and
// correlates the rest with an EXISTS per candidate row, so choose the
// smallest set to minimize rows read.

expect(titles).toContain("Sports + Featured");
});

it("gives the same result whichever taxonomy is written first (#3259)", async () => {

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.

[suggestion] This test asserts that both key orders return the same IDs, but #3259 reports a rows-read asymmetry, not a correctness bug — the result set was already identical before the fix. A test that cannot fail on the reported regression gives false confidence. Consider either instrumenting rows_read via the DB (e.g., D1 meta.rows_read, or an EXPLAIN/plan-cost assertion on SQLite) or removing this case if measuring the plan is impractical in unit tests.

"emdash": patch
---

A collection query filtered by two or more taxonomies now drives the pivot query from the taxonomy with the fewest tagged entries instead of whichever key the caller wrote first. Results are unchanged; on a large collection the rows read no longer depend on key order (3,131 versus 539 for the same query in one measurement).

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.

[suggestion] The changeset describes the internal “pivot query” mechanics rather than leading with the user-facing behavior. Per .changeset/README.md, readers should be able to tell whether the release affects them without knowing the loader’s internal query shape.

Suggested change
A collection query filtered by two or more taxonomies now drives the pivot query from the taxonomy with the fewest tagged entries instead of whichever key the caller wrote first. Results are unchanged; on a large collection the rows read no longer depend on key order (3,131 versus 539 for the same query in one measurement).
A collection query filtered by two or more taxonomies now evaluates those filters in the order that minimizes rows read, instead of the order they were written in `where`. Results are unchanged; on large collections the rows read no longer depend on key order (3,131 versus 539 for the same query in one measurement).

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Agreed on "one query beats two", and done in 050cbb9d: the probe is now a single SELECT taxonomy_id, COUNT(*) ... WHERE taxonomy_id IN (every set's ids) GROUP BY taxonomy_id, summed per set in memory, so a query with N taxonomy filters costs one extra round trip rather than N. The inline comment is cut to what the code needs (the smallest set drives; one grouped count covers every set); the numbers stay in the changeset and the issue.

On the test: it asserts result equality across key orders, which is the contract, and I have not added a rows-read assertion because the unit harness has no statement or row counter to assert against (the figures in #3259 are D1's own rows_read from production). If there is a preferred way to count statements in these tests, point me at it and I will add one. Typecheck, type-aware lint and format are clean; the taxonomy-filter suite passes.

@eisenbruch
eisenbruch force-pushed the fix/pivot-drive-from-selective branch from 050cbb9 to d7afc6c Compare September 24, 2026 15:54
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #3300 (d7afc6cc), no conflict. #3300 changed the pivot join this PR's ordering feeds, so I re-measured whether the ordering still matters under both joins. It does.

The statement buildTaxonomyPivotQuery emits, on D1 with bound parameters, meta.rows_read. Each figure was run twice with the same result, and both orders returned the same ids:

query order CROSS JOIN JOIN (#3300)
listings, business type + place (CA) business type first 4,692 13,218
same place first (what this PR picks) 2,540 11,784
listings, business type + place (TX) business type first 4,337 13,051
same place first 2,694 11,957
articles, topic + place, published_at topic first 1,054 1,054
same place first 470 470

The JOIN column for listings is a separate regression from #3300 in the temp-sort branch, filed as #3408 with a fix in #3409. With both merged, listings run as the CROSS JOIN column and articles as the JOIN column, and this PR's ordering still takes each about half.

eisenbruch and others added 3 commits September 24, 2026 14:04
The selectivity probe ran one COUNT per taxonomy set; a GROUP BY over all the sets' ids is one
round trip on the public render path.
@eisenbruch
eisenbruch force-pushed the fix/pivot-drive-from-selective branch from d7afc6c to df2f562 Compare September 24, 2026 18:04
@eisenbruch
eisenbruch marked this pull request as ready for review October 7, 2026 20:08
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Brought up to date with main by merge (no force-push, no conflicts) and marked ready. Locally on the merged tree: tsgo typecheck, oxfmt --check and oxlint --type-aware --deny-warnings clean on the changed files; the 18 loader test files pass (141), and the full core suite passes (9,557 passed, 11 skipped).

@github-actions github-actions Bot added review/needs-rereview New commits since the last review cla: signed review/needs-review No maintainer or bot review yet and removed review/needs-rereview New commits since the last review labels Oct 7, 2026
@danielmlr danielmlr added the ci:run Approve and re-run CI on the PR's latest commit label Oct 30, 2026
@github-actions github-actions Bot removed the ci:run Approve and re-run CI on the PR's latest commit label Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants