Paginated live query evaluates materialize includes for rows outside the page
Maintainer thường phản hồi trong vòng 1 ngày
Một pull request liên quan đã được merge.
- #2156 của @KyleAMathews — đã merge
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 35/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- react, typescript
- Lĩnh vực
- databases, performance
Hướng nghiên cứu
The payload names no source files, only the query shape: orderBy().limit().offset() with a materialize() include in select, and the same query with .distinct(). Start by running the provided bench script with bun bench.ts to reproduce the timings and confirm the row ids match. Done means includes are evaluated only for rows inside the limit/offset window, with results matching the subquery workaround.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
- I've validated the bug against the latest version of DB packages
Describe the bug
A live query with orderBy().limit().offset() and a materialize() include in select returns only limit rows, but it evaluates the include for rows that never reach the result:
- Without
.distinct(): the include runs for every row up tooffset + limit, so the cost grows with the page number. - With
.distinct(): the index-backed lazy window is disabled, so the include runs for every row in the source collection, on every page, page 0 included.
Paginating in a subquery and selecting the include on the outer query returns the same rows at roughly the cost of the query without includes. The engine could presumably do the same: apply limit/offset (and distinct over the non-include columns) before evaluating includes.
This is different from #1634, which was closed after no includes-specific amplification was found. Here the amplification is that includes are evaluated for rows that are discarded by limit/offset.
Impact
In a real app (Electric collections, useLiveQuery, ~8k parent rows, six nested includes two levels deep, every join column indexed), each page change blocked the main thread:
| next | next | last | first | |
|---|---|---|---|---|
flat query + .distinct() |
5472ms | 5292ms | 5452ms | 5471ms |
flat query, no .distinct() |
335ms | 401ms | 5050ms | 487ms |
| subquery workaround | 310ms | 475ms | 479ms | 272ms |
To Reproduce
Self-contained, local-only collections, all join columns indexed. bun install && bun bench.ts:
import {
BasicIndex,
createCollection,
createLiveQueryCollection,
eq,
localOnlyCollectionOptions,
materialize,
type InitialQueryBuilder,
} from "@tanstack/db";
const PARENTS = 8_000;
const CHILDREN_PER_PARENT = 3;
const PAGE_SIZE = 10;
const PAGES = [0, 100, 400, 799];
const parents = createCollection(
localOnlyCollectionOptions({
id: "parents",
getKey: (row: { id: number }) => row.id,
initialData: Array.from({ length: PARENTS }, (_, i) => ({ id: i + 1 })),
defaultIndexType: BasicIndex,
}),
);
parents.createIndex((row) => row.id);
const children = createCollection(
localOnlyCollectionOptions({
id: "children",
getKey: (row: { id: number; parentId: number }) => row.id,
initialData: Array.from({ length: PARENTS * CHILDREN_PER_PARENT }, (_, i) => ({
id: i + 1,
parentId: 1 + Math.floor(i / CHILDREN_PER_PARENT),
})),
defaultIndexType: BasicIndex,
}),
);
children.createIndex((row) => row.parentId);
const pageOfParents = (q: InitialQueryBuilder, page: number) =>
q
.from({ parent: parents })
.orderBy(({ parent }) => parent.id, "desc")
.limit(PAGE_SIZE)
.offset(page * PAGE_SIZE);
const withChildren = (q: InitialQueryBuilder) => (refs: { parent: { id: number } }) => ({
...refs.parent,
children: materialize(
q.from({ child: children }).where(({ child }) => eq(child.parentId, refs.parent.id)),
),
});
const queries = {
"no include": (q: InitialQueryBuilder, page: number) => pageOfParents(q, page),
include: (q: InitialQueryBuilder, page: number) => pageOfParents(q, page).select(withChildren(q)),
"include + distinct": (q: InitialQueryBuilder, page: number) =>
pageOfParents(q, page).select(withChildren(q)).distinct(),
workaround: (q: InitialQueryBuilder, page: number) =>
q
.from({ parent: pageOfParents(q, page) })
.orderBy(({ parent }) => parent.id, "desc")
.select(withChildren(q)),
};
async function median(query: (q: InitialQueryBuilder, page: number) => any, page: number) {
const runs: Array<{ ms: number; ids: string }> = [];
for (let i = 0; i < 3; i++) {
const start = performance.now();
const live = createLiveQueryCollection({ query: (q) => query(q, page), startSync: true });
const rows = await live.toArrayWhenReady();
runs.push({ ms: performance.now() - start, ids: rows.map((row: any) => row.id).join() });
await live.cleanup();
}
return runs.sort((a, b) => a.ms - b.ms)[1];
}
const table: Record<string, Record<string, string>> = {};
for (const [name, query] of Object.entries(queries)) {
table[name] = {};
for (const page of PAGES) {
const { ms, ids } = await median(query, page);
const { ids: expected } = await median(queries["no include"], page);
table[name][`page ${page}`] = `${Math.round(ms)}ms${ids === expected ? "" : " WRONG ROWS"}`;
}
}
console.log(`${PARENTS} parents × ${CHILDREN_PER_PARENT} children, ${PAGE_SIZE} rows per page`);
console.table(table);
All four queries return the same 10 rows (checked in the script):
8000 parents × 3 children, 10 rows per page
┌────────────────────┬────────┬──────────┬──────────┬──────────┐
│ │ page 0 │ page 100 │ page 400 │ page 799 │
├────────────────────┼────────┼──────────┼──────────┼──────────┤
│ no include │ 2ms │ 12ms │ 34ms │ 42ms │
│ include │ 6ms │ 98ms │ 317ms │ 642ms │
│ include + distinct │ 814ms │ 936ms │ 866ms │ 800ms │
│ workaround │ 4ms │ 11ms │ 40ms │ 62ms │
└────────────────────┴────────┴──────────┴──────────┴──────────┘
With one include the gap is ~10–20x. With the six nested includes from the app it becomes seconds per page change.
Expected behavior
include and include + distinct cost about the same as workaround on every page: includes are evaluated only for the rows inside the limit/offset window.
Versions
@tanstack/db0.13.0,@tanstack/react-db0.5.8- Bun 1.4.0, Windows 11 (the app numbers are from Chrome)
- Ngôn ngữ chính
- TypeScript
- Star
- 3.9k
- Fork
- 272
- Merge trung bình
- 1 ngày 21 phút
- Pull request đã merge (30 ngày)
- 241
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của TanStack/db
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 8/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 25/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 5/100
TanStack/db#2186 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
query-db: a Query refetch that joins a loader's refetch acquisition does not report shifted pagesĐang mở
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 8/100
TanStack/db#2185 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
A live query's preload on an on-demand collection waits for any persisting optimistic transactionĐang mở
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 12/100
Maintainer thường phản hồi trong vòng 1 ngày
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
QuantumNous/new-api#7839 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
Maintainer thường phản hồi trong vòng 1 ngày
-
bug p2 triaged
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
getpaseo/paseo#7071 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
test: make the #3408 Claude OAuth token fixture obviously fake so secret scanners stop flagging itĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
code-yeongyu/senpi#3436 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 78/100
lingdojo/kana-dojo#32505 · 1 bình luận · 5 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày