Skip to content

perf(core): auto-plan taxonomy-filtered listings from cached term counts (#1834) - #1852

Closed
MA2153 wants to merge 10 commits into
emdash-cms:mainfrom
MA2153:fix/1834-taxonomy-filter-seek-strategy
Closed

MA2153 wants to merge 10 commits into
emdash-cms:mainfrom
MA2153:fix/1834-taxonomy-filter-seek-strategy

Conversation

@MA2153

@MA2153 MA2153 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

On SQLite/D1, a taxonomy-filtered collection listing (getEmDashCollection("posts", { where: { category }, orderBy, limit })) applies the taxonomy filter as a correlated EXISTS on a SELECT * FROM ec_<collection> ... ORDER BY ... LIMIT ?. A stats-blind D1 planner drives from the collection's order index and probes the taxonomy per row. For a selective term the LIMIT never fills, so the query walks the whole collection — tens of thousands of D1 rows read for a page returning a handful (#1834). rows-read is the billed/throttled unit on D1, and the planner can't self-correct because choosing the fast plan needs the term's selectivity, which D1 doesn't track (sqlite_stat1 is never maintained).

This gives the loader that one missing fact — cached per-term entry counts — and lets it plan automatically:

  • Single term: seek a selective term (drive from content_taxonomies, seek the composite pivot index, join the collection by PK, sort the small candidate set) or keep the EXISTS scan for a non-selective one (the LIMIT fills fast). The choice is a √(pageRows · total) cost-model crossover.
  • Multi-term AND: drive the seek from the smallest-count term and probe the rest as index-nested-loop EXISTS — the O(|small|·log|big|) plan.

The counts inherit the query's own collection + status + locale, so a term's count equals the rows that plan would actually fetch (a draft query counts drafts, a French query counts French, a single collection counts only its slice) — no fixed "published" semantics imposed. They're advisory: they change how rows are fetched, never which rows come back (seek and scan return identical results, tested). The map lives in the object-cache under [content:<collection>, taxonomies], so the existing epoch graph invalidates it for free on the writes that move a count (content create/delete/status-change/attach → content:X; term rename/delete → taxonomies) — no new table, no write-path hooks. It's keyed by translation_group (carrying name/slug/count + a collection total) so a future public term-count API can reuse the same load.

Migration 051 adds the composite index content_taxonomies(taxonomy_id, collection, entry_id); the seek forces it (INDEXED BY) with a plain ct.collection = ? equality so it seeks on both leading columns, reading only the queried collection's slice even for a term shared across collections. Postgres is unchanged (keeps EXISTS; it has statistics).

Verified against a real D1 dataset (~24k published rows in the collection, ~76k content_taxonomies rows), rows-read:

Case seek scan
Selective term (2 matches) 10 50,609
Broad term (~50% of collection) 51,099 68
Multi-term AND, drive smallest vs largest 14 51,103

The cost-model threshold picks correctly in both real single-term cases with margin (√(21·24383) ≈ 716, between 2 and ~12,305).

Out of scope (fast follow): the admin term-cloud / archive-nav counts still use the older status-agnostic, cross-collection aggregate that is only invalidated by taxonomy writes (not content writes). Rewiring those to this published, correctly-invalidated per-collection map is a separate change — it's a visible behavior change (public counts would drop drafts) and needs cross-collection aggregation.

Discussion: #1851

Closes #1834

Type of change

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (0 diagnostics)
  • pnpm test passes (targeted: loader / query / taxonomy suites — 99 loader/query + 151 taxonomy tests green, both dialects)
  • pnpm format has been run
  • I have added/updated tests for my changes
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — n/a, server-side query API only, no admin UI strings
  • I have added a changeset
  • New features link to an approved Discussion: Auto-plan taxonomy-filtered listings from cached per-term counts #1851 (awaiting maintainer sign-off)

AI-generated code disclosure

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

Screenshots / test output

  • tests/integration/loader-taxonomy-filter-plan.test.ts — EXPLAIN QUERY PLAN on stats-blind SQLite: a selective term auto-seeks the composite index on both leading columns; a non-selective term keeps the EXISTS scan; a multi-term AND drives the seek from the smallest-count term.
  • tests/unit/loader-term-counts.test.ts (new) — count correctness: status-follows-query (drafts vs published), per-locale, per-collection scoping.
  • tests/unit/loader-taxonomy-filter.test.ts — OR-within dedupe, AND-across, localized-slug, shared-collection scoping, and a forced scan-path case, all via the auto-planner.

Note: scripts/query-counts.snapshot.* shows a pre-existing unrelated drift on this branch (CROSS JOIN → JOIN in folded hydration, on non-taxonomy routes too); this change's real delta is the +1 count GROUP BY on taxonomy-filtered routes. CI regenerates the snapshot.

🤖 Generated with Claude Code

MA2153 and others added 8 commits July 6, 2026 16:12
…d listings (emdash-cms#1834)

On SQLite/D1, a taxonomy-filtered listing ordered + paginated walks the whole
collection in ORDER BY order applying the taxonomy filter as a correlated
EXISTS per row. For a selective term (few matches) the LIMIT never fills early,
so it scans the entire table — ~75k D1 rows read for a page returning one row.

Adds an opt-in `taxonomyStrategy: "seek"` filter option that drives the query
from content_taxonomies (materialized _matched CTE + CROSS JOIN, seeking by
taxonomy_id and joining the collection by primary key). Default "scan" is
unchanged and remains best for non-selective terms. SQLite-only; Postgres has
statistics and is untouched.

Also adds composite index content_taxonomies(taxonomy_id, collection, entry_id)
(migration 051), which the seek plan relies on and which supersedes the
single-column idx_content_taxonomies_term.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…onomy

The seek-strategy `_matched` CTE selected pivot entry ids without
DISTINCT. For a single taxonomy filtered by multiple slugs
(`{ category: ['news', 'sports'] }`), an entry tagged with more than one
slug had a pivot row per slug and no INTERSECT to dedupe it, so the outer
CROSS JOIN emitted the content row multiple times. The default EXISTS
scan is a semi-join and never duplicated.

Add DISTINCT to the per-taxonomy select and add seek-path correctness
tests covering OR-within-one-taxonomy (alone and combined with a second
taxonomy), cross-checked against the scan path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a seek-path test covering a term shared across two collections,
asserting the "post" listing stays scoped to posts and matches the scan
path. Verifies the reviewer's cross-collection concern on emdash-cms#1843 does not
manifest: the `+ct.collection` predicate keeps TEXT affinity (unary `+`
is a value/type no-op in SQLite), so `_matched` never admits other
collections' rows.

Also switch three seek-strategy assertions from `Array#sort()` to
`toSorted()` to clear the lint warnings introduced in 5840cf8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The seek-path `_matched` CTE used `+ct.collection = ${type}` to disqualify
the pivot PK auto-index. The unary `+` forces only a single-column
`(taxonomy_id=?)` seek and applies `collection` as a residual filter, so a
term shared across collections reads that term's rows in *every*
collection, then discards the non-matching ones — a page returning one row
can read hundreds.

Replace it with an explicit `INDEXED BY idx_content_taxonomies_term_lookup`
plus a plain `ct.collection = ${type}` equality. This keeps the composite
index but seeks on both leading columns `(taxonomy_id=? AND collection=?)`,
reading only the queried collection's slice of the term. Measured against a
real D1 dataset, a shared selective term dropped from 704 rows read to 2.
Plain equality also removes the confusing unary `+`.

Tighten the plan test to assert the two-column seek so the residual-filter
form can't silently return.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the manual `taxonomyStrategy: "scan" | "seek"` knob (never released
publicly) with an automatic decision. The loader reads cached per-term entry
counts that mirror the query's own collection/status/locale, then:

- single term: seeks a selective term (drives from content_taxonomies via the
  composite index) or scans a non-selective one (LIMIT fills fast), chosen by a
  sqrt cost-model crossover;
- multi-term AND: drives the seek from the smallest-count term and probes the
  rest as index-nested-loop EXISTS, retiring the INTERSECT (which could not
  reach the optimal plan).

Counts are advisory — they change how rows are fetched, never which rows come
back — and are cached under [content:<collection>, taxonomies] for free, correct
invalidation. Postgres is unchanged (keeps EXISTS; it has statistics).

Verified on real D1 (~24k published, ~76k pivot rows): a selective term reads 10
rows seeking vs 50,609 scanning; a ~50% term reads 68 scanning vs 51,099
seeking; multi-term smallest-first reads 14 vs 51,103.

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

changeset-bot Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2b0d29f

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

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd 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 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 821 lines across 9 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 7, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 2b0d29f

@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.

I reviewed the auto-plan taxonomy-filtered listing PR. The approach is sound and well-scoped for #1834: it gives the SQLite/D1 loader an advisory selectivity signal, reuses the existing content:<collection> / taxonomies object-cache namespaces for invalidation, adds the composite pivot index via a forward-only migration, and leaves Postgres alone. It aligns with EmDash’s architecture.

One concrete bug needs fixing before merge: the cached term-counts shape uses a Map, but the object-cache codec does not preserve Map instances. Once an object-cache backend is configured, a cache hit deserializes terms to {}, so the planner silently skips the seek path and falls back to the slow scan, which is exactly the behavior this PR is trying to fix. I’ve inlined a minimal fix below.

I also flagged two smaller advisory issues: counts for status: "published" are time-sensitive via scheduled_at but the cache only invalidates on writes (not when a scheduled post becomes due), and the in-memory terms map is keyed by translation_group, which can overwrite per-slug counts for unlocalized queries with translated terms.

The migration, tests, changeset, SQL-safety, and locale/status scoping all look good otherwise. Note that the author’s checklist says the linked Discussion is still awaiting maintainer sign-off.

Comment thread packages/core/src/loader.ts Resolved
Comment thread packages/core/src/loader.ts Resolved
Comment thread packages/core/src/loader.ts Resolved
@MA2153
MA2153 marked this pull request as draft July 7, 2026 05:22
The cached CollectionTermCounts carried a Map, but the object-cache codec
is JSON-based and only preserves Date, so a cache hit deserialized `terms`
to `{}`. The seek planner gates on `terms.size > 0` (undefined on a plain
object), so a hit silently reverted to the slow scan the feature exists to
avoid. Cache a serializable Record and rebuild the Map at the boundary.

Add a regression test that forces the codec round-trip (memory backend +
two reads) — the existing taxonomy tests ran with no backend, so cachedQuery
short-circuited to load() and never crossed the codec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot removed the review/needs-review No maintainer or bot review yet label Jul 7, 2026
@MA2153
MA2153 marked this pull request as ready for review July 7, 2026 05:49
@github-actions github-actions Bot added the review/needs-rereview New commits since the last review label Jul 7, 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.

This is a sound, well-scoped change for #1834. The author has fixed my earlier blocking issue: term counts now survive an object-cache round-trip by storing a plain Record and rebuilding the Map at the cache boundary, with a dedicated regression test. The composite pivot index in migration 051, the EXPLAIN QUERY PLAN integration tests, and the locale/status-scoped count tests all look correct, and SQL construction uses sql.ref / parameters.

One concrete invalidation gap remains: term assignment / detachment in TaxonomyRepository only bumps the taxonomies object-cache namespace, but the new per-collection count cache lives under content:<collection>. After attaching or detaching a term, the cached selectivity counts stay stale until the next content write or the cache TTL, which directly undermines the plan-quality goal of this PR. The PR description claims attach → content:X, but the code path doesn't do that yet.

I also still see the two advisory items from my prior review:

  1. status: "published" counts include scheduled_at <= now() but only invalidate on writes, so the plan can drift as scheduled posts go live.
  2. The in-memory/in-cache terms map is keyed by translation_group, which overwrites per-slug variants for unlocalized queries with translated terms.

Please close the attach/detach invalidation gap before merge. The Discussion sign-off is also still pending, which the maintainer should confirm separately.


Findings

  • [needs fixing] packages/core/src/database/repositories/taxonomy.ts:358

    Term assignment changes per-collection term counts, but setTermsForEntry only invalidates the taxonomies namespace. The new count cache (getTermCountsForCollection) stores values under content:<collection>, so attaching/detaching terms leaves that cache stale until a content write or the TTL expires. The same applies to attachToEntry, detachFromEntry, and clearEntryTerms.

    Add invalidateCollectionCache(collection) alongside the taxonomy invalidation:

    import { invalidateCollectionCache, invalidateTaxonomyObjectCache } from "../../object-cache/index.js";
    ...
    if (toRemove.length > 0 || toAdd.length > 0) {
    	invalidateCollectionCache(collection);
    	invalidateTaxonomyObjectCache();
    }
  • [suggestion] packages/core/src/loader.ts:431

    The serializable terms map is keyed by translation_group. For a locale-less query, a term that has translations in multiple locales will emit multiple GROUP BY rows with the same translation_group but different name/slug; the last row wins, so one slug/count overwrites the other. That makes the per-slug bySlug lookup incomplete for unlocalized queries. Consider keying by a composite such as name\0slug (mirroring the planner lookup) or storing an array of variants per group.

  • [suggestion] packages/core/src/loader.ts:403

    buildStatusCondition(db, status, "e") for status === "published" includes scheduled_at <= now(). Because the count cache is invalidated only on writes, counts drift as scheduled posts cross their publish time. Since counts are only advisory this is a plan-quality issue, not a correctness issue, but it's worth documenting or capping the TTL for published-status counts.

@MA2153
MA2153 marked this pull request as draft July 7, 2026 05:59
Adds a regression test proving the term-count cache (keyed
[content:<collection>, taxonomies]) is invalidated when a term is
attached: the taxonomies epoch bump alone suffices because a cache hit
requires every namespace epoch to match. Locks in the invariant so a
future change can't leave selectivity counts stale after a term write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot removed the review/needs-rereview New commits since the last review label Jul 7, 2026
@MA2153

MA2153 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review. Responses to the three items:

Attach/detach invalidation gap [needs fixing] — this is a false positive; no change needed.

The count cache is keyed under two namespaces: [content:<collection>, taxonomies]. A cached value is a HIT only when every stored namespace epoch still matches the current epoch — epochsMatch is all-must-match (object-cache/index.ts:281). So bumping either namespace invalidates it; it's OR-to-invalidate, not AND.

attachToEntry, detachFromEntry, setTermsForEntry, and clearEntryTerms all call invalidateTaxonomyObjectCache() (taxonomy.ts:264, 277, 358, 368), which bumps the taxonomies epoch — which alone invalidates the counts. Adding invalidateCollectionCache(collection) would be redundant.

Verified empirically and pinned it as a regression test in 2b0d29f3: populate the count cache with no term attached (count 0), attachToEntry (bumps only taxonomies), read again in a fresh request — the count is now 1, i.e. the cache was invalidated. If the finding were correct, that second read would still report 0.

Published-status scheduled_at <= now() drift [suggestion] — this is inherited, not introduced here. buildStatusCondition("published") is the platform-wide visibility model and is the same condition the planned listing query uses, so the count still mirrors its query. The counts feature only latches onto the existing content:<collection> / taxonomies namespaces and their write-driven invalidation; the scheduled-publish sweep flips scheduled→published via a write that bumps content:<collection>, invalidating these counts too. Staleness is advisory (plan quality only), bounded by min(sweep cadence, TTL). Detailed reasoning on the prior review's thread.

translation_group keying overwrites slug variants [suggestion] — valid and narrow (unlocalized query against a term that has translations). Correctness is unaffected; it only degrades plan choice in that intersection. Tracking as a follow-up rather than reshaping the documented keying contract + tests + future-API note inside this PR. Detailed reasoning on the prior review's thread.

@MA2153
MA2153 marked this pull request as ready for review July 7, 2026 06:04
@github-actions github-actions Bot added the review/needs-rereview New commits since the last review label Jul 7, 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 blocking invalidation gap from my prior review is closed: the count cache is stamped under both content:<collection> and taxonomies, and the new regression test shows that a taxonomy write (which bumps the taxonomies namespace) correctly evicts it. The composite pivot index, the SQLite-only seek rewrite, the EXPLAIN-plan integration tests, and the locale/status/object-cache test coverage are all sound.

No correctness bugs remain. Three plan-quality advisory items survive this revision:

  1. loadTermCountsForCollection groups by translation_group, name, slug but stores the result in a Record keyed only by translation_group, so an unlocalized query overwrites per-slug variants. The bySlug lookup used by the planner can then miss a slug or use the wrong variant's count, making the seek/scan decision less accurate for multilingual sites queried without a locale.
  2. Published counts include scheduled_at <= now(), but the cache only invalidates on writes; as scheduled posts go live the count drifts from the query it is supposed to mirror. Since counts are advisory this only affects plan choice.
  3. For a multi-slug filter within one taxonomy, the planner sums per-slug counts. If slugs overlap on the same entries, the summed count overstates the filter's true selectivity and can keep the EXISTS scan when driving from the smallest distinct set would be cheaper.

Also note: the PR description says term attach/detach invalidates content:<collection>, but the code path relies on the folded taxonomies namespace instead. The practical effect is the same, so this is a documentation/implementation detail rather than a bug.

Finally, the PR checklist notes the linked Discussion is still awaiting maintainer sign-off; that should be confirmed before merge.

Comment thread packages/core/src/loader.ts Resolved
Comment thread packages/core/src/loader.ts Resolved
Comment thread packages/core/src/loader.ts Resolved
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview New commits since the last review labels Jul 7, 2026
@ascorbic ascorbic added this to the 1.0 milestone Jul 8, 2026
@MA2153

MA2153 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of a new approach

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core review/awaiting-author Reviewed; waiting on the author to respond size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Taxonomy-filtered collection queries do a full table scan (75k+ D1 row reads) on selective terms

2 participants