Skip to content

Fix ~5s query when a page is bounded by both cursors (after + before) - #2815

Open
vierja wants to merge 1 commit into
instantdb:mainfrom
vierja:fix/two-cursor-pagination-plan
Open

Fix ~5s query when a page is bounded by both cursors (after + before)#2815
vierja wants to merge 1 commit into
instantdb:mainfrom
vierja:fix/two-cursor-pagination-plan

Conversation

@vierja

@vierja vierja commented Aug 3, 2026

Copy link
Copy Markdown

A query carrying both after and before costs ~5s where either cursor alone costs ~250ms, on any namespace ordered by a user attribute. Since the server's own handle-receive timeout is 5000ms, it fails more often than it succeeds.

This is not an exotic shape: db.useInfiniteQuery issues it on every loadNextPage(), to re-subscribe to ("freeze") the page just scrolled past. And because the SDK's error handler is global — one failing page subscription emits {data: undefined, error} — the whole list disappears. That is how we found it: a WhatsApp-style chat in our app loaded fine, the user scrolled up, page 2 arrived, and ~5s later the entire conversation blanked to an empty state.

Reproduces on Instant Cloud and self-hosted alike, with or without a where clause, and even when the result is zero rows. Ordering by serverCreatedAt is unaffected.

Where the time goes

Not the network (4.47s measured on the box itself) and not permissions (identical with an admin token, perms bypassed). It is the Postgres plan.

joining-with emits the where-clause CTEs as not materialized whenever a query is paginated:

(if (or (always-materialize? named-p)
        ;; only use `not materialized` when we're in the middle of an ordered
        ;; query
        (not page-info)
        ...)
  :materialized
  :not-materialized)

so Postgres inlines them and is free to reorder the join. With one cursor that is exactly right — walk the ordered index, stop after limit.

With both cursors the ordered scan becomes a closed range, which makes triples_date_type_idx usable as a range scan. The planner then prefers to drive from the range:

CTE m_2_with_next
  -> Unique  (actual rows=50)
       -> Incremental Sort  (actual rows=50)
            -> Nested Loop  (cost=1.69..2038.05 rows=1) (actual rows=50)
                 Join Filter: (t0.entity_id = ((t1.value ->> 0))::uuid)
                 Rows Removed by Join Filter: 758505
                 -> Nested Loop  (rows=5) (actual rows=758555)
                      -> Index Scan Backward using triples_date_type_idx on triples t2
                           (cost=0.56..1682.63 rows=123) (actual rows=758555)
                           Index Cond: (app_id = ... AND attr_id = ...
                                        AND triples_extract_date_value(value) <= ...
                                        AND triples_extract_date_value(value) >= ...)
                      -> Index Scan using triples_pkey on triples t1
                           (actual rows=1 loops=758555)
                 -> Materialize  (actual rows=1)
                      -> Index Scan using av_index on triples t0  (actual rows=1)
  Buffers: shared hit=4555876

rows=123 estimated, actual rows=758555 — off by ~6,000x. Statistics for triples_extract_date_value(value) are pooled across every date attribute of every app in the shared triples table, so a three-month window on one attribute looks tiny. The result is that it scans every conversation_messages row in the app whose createdAt falls in the range, joins each one to its conversation, and throws away 758,505 of them — 4.5M shared buffer hits — instead of driving from the av_index lookup that selects 95 rows.

The change

A closed range has nothing to stop early for. The ordered CTE is materialized regardless, so inlining the join CTEs buys the planner only the freedom to pick that plan. So: treat "both cursors set" like the unpaginated case and materialize.

It is inert everywhere else. Single-cursor pagination is untouched, and a two-cursor query with no where has no join CTEs to materialize in the first place.

Numbers

Against a production dataset (100M triples, one app), same query, byte-identical results:

stock patched
after only (control) 250ms 250ms
before only (control) 249ms 249ms
after + before 4412ms 11ms

Measured two ways. First directly in psql, changing only m_1 AS NOT MATERIALIZED to MATERIALIZED in the generated SQL — 4412ms → 11ms, cmp-identical output. Then end-to-end, by building the patched server and running it against a pg_basebackup clone of the same database (physical, so pg_statistic is identical — a logical restore would need a fresh ANALYZE and might not reproduce the misestimate at all):

                                  stock:8888   patched:8889
after only (control) run1    0.017363s   0.028477s
after only (control) run2    0.018371s   0.028188s
after only (control) run3    0.018730s   0.028368s
AFTER+BEFORE         run1    4.522677s   0.019450s
AFTER+BEFORE         run2    4.500170s   0.020515s
AFTER+BEFORE         run3    4.508509s   0.019344s

results IDENTICAL (87389 bytes)

The control shape is slower on the patched instance (it has a smaller shared_buffers), so the win is not an artifact of the test rig.

Finally, through the actual JS client: every freeze query now completes in ~250ms and the SDK's chunks reach frozen status, where before they errored with Operation timed out: handle-receive and blanked the list.

Test

Added pagination-with-both-cursors-and-a-where-clause to instaql_test.clj. The existing pagination-with-same-values does exercise both cursors, but with no where clause — so it has no join CTEs and never touches the branch this changes. The new test covers inclusive and exclusive bounds, both directions, and a limit, and pins that the where still constrains the range (the fixture interleaves two groups, so a range that ignored the where would return the other group's rows too).

It is a semantics guard rather than a demonstration of the bug — this change is meant to be behaviour-preserving, so the test passes with and without it. clojure -M:test -n instant.db.instaql-test against ghcr.io/instantdb/postgresql:postgresql-17-pg-hint-plan:

with the fix:     Ran 62 tests containing 538 assertions.  0 failures, 0 errors.
test only, no fix: Ran 62 tests containing 538 assertions.  0 failures, 0 errors.

I could not get your CI to run on the fork (workflows are on: push and forks do not register them without the Actions tab click), so this was run locally against the same Postgres image and migrations your workflow uses.

Notes for review

  • The condition is on (:before page-info) and (:after page-info) in joining-with; add-page-info already destructures both from the same map.
  • The underlying misestimate is worth a separate look — extended statistics on (triples_extract_date_value(value), attr_id) might repair it more generally — but that is a bigger change with wider blast radius, and this one is provably inert outside the shape it fixes.
  • Happy to adjust the shape of the fix if you would rather solve it another way; the diagnosis is the part I am most confident about.

A query carrying both `after` and `before` costs ~5s where either cursor
alone costs ~250ms, on any namespace ordered by a user attribute. Since
the server's own handle-receive timeout is 5000ms, it fails more often
than it succeeds.

`joining-with` emits the where-clause ctes as `not materialized` whenever
a query is paginated, so Postgres inlines them and is free to reorder the
join. With one cursor that is what you want: walk the ordered index and
stop after `limit`.

With both cursors the ordered scan becomes a closed range, which makes
triples_date_type_idx usable as a range scan. The planner estimates that
range at 123 rows; the actual is 758,555, because statistics for
triples_extract_date_value(value) are pooled across every date attribute
of every app in the shared triples table. So it drives from the date
range across the whole app and discards 758,505 rows in the join filter
(4.5M shared buffer hits) instead of driving from the av_index lookup
that selects 95.

A closed range has nothing to stop early for -- the ordered cte is
materialized regardless -- so inlining only buys the planner the freedom
to pick that plan. Materializing takes the same query from 4412ms to
11ms, with byte-identical results.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The query planner now avoids CTE materialization for pagination queries with both cursors. Tests cover filtered and unfiltered ranges, inclusive and exclusive bounds, ascending and descending order, and result limits.

Changes

Cursor pagination

Layer / File(s) Summary
Two-sided pagination materialization
server/src/instant/db/datalog.clj
joining-with selects :not-materialized when both before and after cursors are present.
Filtered cursor pagination coverage
server/test/instant/db/instaql_test.clj
Tests cover interleaved data, where filters, cursor bounds, ascending and descending order, unfiltered ranges, and limits.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: dwwoelfel

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the performance fix for queries bounded by both cursors.
Description check ✅ Passed The description directly explains the query performance issue, the materialization change, measured results, and test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/src/instant/db/datalog.clj (1)

1911-1913: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Use the cached cms/lookup call.

Line 1912 passes (:conn-pool (:db ctx)) to cms/lookup. This bypasses the internal cache for every hint annotation. Remove the connection argument unless this call requires uncached data.

Proposed fix
-          sketches (cms/lookup (:conn-pool (:db ctx)) sketch-keys)]
+          sketches (cms/lookup sketch-keys)]

Based on learnings: do not pass a database connection argument to cms/lookup, because it bypasses the internal cache.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/instant/db/datalog.clj` around lines 1911 - 1913, Update the
cms/lookup call in annotate-with-hints-impl to omit the (:conn-pool (:db ctx))
connection argument and use the cached lookup form with sketch-keys.

Source: Learnings

🧹 Nitpick comments (1)
server/test/instant/db/instaql_test.clj (1)

1263-1369: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Assert the CTE materialization contract.

These assertions verify result rows. They do not verify that the two-cursor where CTE is :materialized. A change back to :not-materialized can pass this test and restore the slow query plan.

Add an assertion against the generated CTE metadata for this query shape. The PR objective identifies materialization as the required performance behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/test/instant/db/instaql_test.clj` around lines 1263 - 1369, Extend
pagination-with-both-cursors-and-a-where-clause to inspect the generated query
or CTE metadata for the two-cursor where shape and assert that the relevant join
CTE is marked :materialized. Keep the existing result assertions, and ensure the
new check would fail if that CTE regresses to :not-materialized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/src/instant/db/datalog.clj`:
- Around line 1911-1913: Update the cms/lookup call in annotate-with-hints-impl
to omit the (:conn-pool (:db ctx)) connection argument and use the cached lookup
form with sketch-keys.

---

Nitpick comments:
In `@server/test/instant/db/instaql_test.clj`:
- Around line 1263-1369: Extend pagination-with-both-cursors-and-a-where-clause
to inspect the generated query or CTE metadata for the two-cursor where shape
and assert that the relevant join CTE is marked :materialized. Keep the existing
result assertions, and ensure the new check would fail if that CTE regresses to
:not-materialized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bc6736a-0091-4c78-b7ec-76cd54db704e

📥 Commits

Reviewing files that changed from the base of the PR and between 9cfde4e and fe4ab63.

📒 Files selected for processing (2)
  • server/src/instant/db/datalog.clj
  • server/test/instant/db/instaql_test.clj

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.

1 participant