Skip to content

fix(orm): bound each index-union probe by matches, not scanned rows - #455

Open
MikeyZhang75 wants to merge 7 commits into
mainfrom
fix/orm-index-union-limit-read-bound
Open

fix(orm): bound each index-union probe by matches, not scanned rows#455
MikeyZhang75 wants to merge 7 commits into
mainfrom
fix/orm-index-union-limit-read-bound

Conversation

@MikeyZhang75

@MikeyZhang75 MikeyZhang75 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator
  • Auto release

🐛 Fixes #442
🧭 Task plan: docs/plans/442-index-union-findmany-take-bound.md
🟢 95-100% confidence

Phase 🧪 Tests 🌐 Browser
Reproduced 🔴 4 read-bound assertions red against unpatched query.ts — 200 scanned at 200 rows, 500 at 500 ➖ N/A
Verified 🟢 11/11 new · 554 convex/orm · 992 vitest · 1400 bun test · 274 package · 124 CLI ➖ N/A

✅ Outcome

The non-paginated index-union (multiProbe) findMany lane dropped its take() bound entirely whenever RLS was enabled or a post-filter was not Convex-enforceable (contains). Each probe fell back to .collect() and scanned its whole probed range.

findMany({ where: { ownerId: { in: [a, b] } }, limit: 3 }) now scans 6 rows at both 200 and 500 table rows — it was 200 and 500. Both legs the issue reports are fixed, and unions wider than the 64-probe merge cap are newly bounded too. Rows and their order are unchanged everywhere.

🏗️ Design

The issue suggested routing the branch through _buildResidualFilterStream. That was implemented and measured, and it is wrong:

  • The merged probe-union stream commits to one global direction, so orderBy: [asc(type), desc(score)] returns a10,a20,a30,b1 where the fan-out returns the correct a30,a20,a10,b3.
  • MergedStream never dedupes, and tryCompileOrRangeComplement can emit overlapping probes, so OR: [{score: {lt: 8}}, {score: {gt: 3}}] returns the same document twice and short-fills the page.
  • _buildResidualFilterStream discards _buildPlanStream's probeUnion flag, and every withIndex rung there is gated on !hasProbeUnionPlan — so a declined union silently becomes an unanchored full-table scan. tryCompileInArray applies no probe cap, so a bare in with 65+ values reaches exactly that.

So this keeps the per-probe fan-out and replaces only each probe's .collect() with a bounded per-probe stream. Truncating one probe needs order only within that probe, which orderPushdownDirection !== null already proves; merging needs the strictly stronger global order. probeBound (is truncation legal at all) is split from probeBoundedTake (can a plain scanned-row take() carry it), so a residual filter or a membership pass moves the read onto a stream instead of cancelling the bound. The _id dedupe, the JS sort and the offset/limit slice are untouched, and a project without defineSchema() keeps today's .collect().

104 insertions in one branch. No shared helper touched, no signature changed, no path removed.

⚠️ Caveat

  • ne / notIn / isNotNull combined with an orderBy no index can serve still collect their whole complement range. That is the opposite cost regime and needs its own decision; it is stated in the changeset so it is not mistaken for covered.
  • bun check fails only at fixtures:check, on an upstream expo ~55.0.30 → ~55.0.31 bump that is already owned by origin/chore/sync-drifted-scaffold-fixtures. Every other lane is green, including test:verify and test:runtime.
  • A where that filters through a relation is deliberately excluded from the streamed path and keeps today's collect-then-filter behavior. Both guards that bound a relation load are scoped to the batch they are handed: _enforceRelationFanOutKeyCap counts the distinct keys in one call, and _loadOneRelation de-duplicates source keys per call. Streaming that leg hands them one key at a time, which retires a guard that fails fast today. Measured with a cap of 5 and 40 distinct owners: main throws relationFanOutMaxKeys, the streamed version read 80 documents and returned silently. Restoring either for a per-row caller needs an execution-scoped key ledger inside the relation loader, which is not this branch's to own — so this PR fixes exactly the two legs ORM: index-union findMany drops its take() bound entirely under RLS or a residual filter #442 reports.
  • Four adjacent defects were found and deliberately not bundled — each is on a different lane and needs its own PR: _buildResidualFilterStream discarding probeUnion; the non-paginated pipeline lane computing rejectedProbeUnion under isCursorPaginated && while assigning the stream unconditionally; the cursor lane returning duplicate rows for overlapping range probes on main today; and the single-index residual lane skipping the empty-result RLS policy-configuration assertion.

🧪 Verified

npx vitest run convex/orm/index-union-read-bound.test.ts (11 passed) · npx vitest run (991 passed) · bun test (1400 pass / 0 fail) · bun test packages/kitcn/src/orm (274 pass, covers the stub-db lane that has no stream()) · bun --cwd packages/kitcn build · bun typecheck 5/5 · bun lint clean · bun run test:verify and bun run test:runtime green · autoreview --mode local clean.

Red proof: stashing packages/kitcn/src/orm/query.ts back to main turns exactly the four read-bound assertions red while the order, dedupe, offset and no-schema guards stay green — those are behavior-preservation pins, not new behavior.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Non-paginated multiProbe findMany dropped its take() bound whenever RLS was
enabled, a post-filter was not Convex-enforceable, or the where filtered
through a relation. Each probe then collected its whole range: 500 scanned on
a 500-row table for limit 3.

Split the bound decision. probeBound now cancels only on an order no index
serves; probeBoundedTake keeps the plain scanned-row take() for reads where
every filter reached Convex; probeStreamed reads each probe as a stream whose
filterWith runs postFilters + RLS + relation membership as rows are pulled, so
take() counts survivors. Fan-out, _id dedupe, JS sort and slice unchanged.

Per-probe truncation needs order only within a probe, which orderPushdownDirection
already proves; the merged probe-union stream needs a global order it cannot
express when the primary sort field is a probed one pointing the other way, and
it does not dedupe. Hence no merge here.

Fixes #442
@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d2aa220

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

This PR includes changesets to release 2 packages
Name Type
kitcn Patch
@kitcn/resend 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

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
better-convex Ready Ready Preview Sep 6, 2026 4:37am UTC

Request Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9f7b967ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/kitcn/src/orm/query.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37c386f9a4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/kitcn/src/orm/query.ts
…obes

Streaming a relation `where` hands `_loadOneRelation` one key per call, so
`_enforceRelationFanOutKeyCap` always compares keyCount 1 against the cap and a
guard that fails fast today never fires. Measured with a cap of 5 and 40 distinct
owners: main throws, the streamed path read 80 documents and returned silently.
Batch key dedupe goes the same way — a non-_id relation target read 120 instead
of 61 at 60 rows.

Exclude a relation `where` from probeStreamed. That is exactly the two legs #442
reports (RLS, residual filter), and both guards stay batch-scoped where they
work. Restoring them for a per-row caller needs an execution-scoped key ledger
in the relation loader, which is not this branch's to own.

Pin it: 40 distinct keys against a cap of 5 must still throw.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 636f2f85a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .changeset/lucky-plums-cough.md Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ORM: index-union findMany drops its take() bound entirely under RLS or a residual filter

1 participant