On-demand `orderBy` + `limit`: a sort-key change reloads the entire source and releases the window
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 65/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- typescript
調査の方向性
Start in packages/db/src/query/live/ordered-source-loader.ts and trace onSourceChanges through invalidateSourceOrdering and loadFullSource. Run the framework-free reproduction from the issue to observe the request and unload sequence. Done means sort-key changes preserve a bounded window recovery, or expose the described recovery distinction without expanding the adapter subscription to the full matching source.
索引モデルが issue の本文から書いたものです。
説明
- I've validated the bug against the latest version of DB packages (
@tanstack/db0.9.2; the same path is onmaininpackages/db/src/query/live/ordered-source-loader.ts)
Describe the bug
A live query with orderBy + limit over a syncMode: "on-demand" collection loads its window correctly with loadSubset({ where, orderBy, limit }). But as soon as a visible row's sort key changes (or a visible row is deleted), OrderedSourceLoader.onSourceChanges calls invalidateSourceOrdering(), and the next load is loadFullSource(): a requestSnapshot carrying only the query's where, with no orderBy, limit or cursor. Once that settles, the loader retires the finite acquisitions, so the adapter gets unloadSubset for the window.
So a top-10 window turns into "every row matching where" after one sort-key change, and stays that way. For feeds ordered by updatedAt, that happens on basically every write.
Why it matters for server-backed adapters
To the adapter, the full-source request is indistinguishable from a genuine "give me everything matching where" request, so it must fetch (and, for a realtime backend, subscribe to) the whole matching set. In our app (InstantDB behind an on-demand collection) this turned a take: 12 "recent items" window into a live subscription to the entire table, 6,311 rows / 4 MB, re-run server-side on every write for every connected client. We're working around it in the adapter by recognising the request and answering it from the still-open window subscription. That depends on internals, though, and every on-demand adapter pays the same cost.
Reproduction
@tanstack/db 0.9.2, no framework. The fake adapter serves each request honestly: top-limit by updatedAt desc when a limit is given, every matching row otherwise.
import {
BTreeIndex,
createCollection,
createLiveQueryCollection,
eq,
type LoadSubsetOptions,
} from "@tanstack/db"
type Row = { id: string; userId: string; updatedAt: number }
// Server: 1,000 rows for one user.
const server: Row[] = Array.from({ length: 1000 }, (_, i) => ({
id: `r${i}`,
userId: "u1",
updatedAt: 1_000_000 - i,
}))
const show = (e: any): string =>
e == null
? "none"
: e.type === "ref"
? e.path.join(".")
: e.type === "val"
? JSON.stringify(e.value)
: `${e.name}(${e.args.map(show).join(", ")})`
const describe = (o: LoadSubsetOptions) => ({
where: show(o.where),
orderBy: o.orderBy?.length ?? 0,
limit: o.limit,
cursor: o.cursor ? "yes" : "none",
})
let syncApi: any
const items = createCollection<Row, string>({
id: "items",
getKey: (r) => r.id,
syncMode: "on-demand",
autoIndex: "eager",
defaultIndexType: BTreeIndex,
sync: {
sync: (api) => {
syncApi = api
api.markReady()
return {
loadSubset: (opts: LoadSubsetOptions) => {
console.log("loadSubset", JSON.stringify(describe(opts)))
const rows = [...server].sort((a, b) => b.updatedAt - a.updatedAt)
// Honour the boundary-tie load's extra `eq(updatedAt, v)` conjunct.
const w: any = opts.where
const tie =
w?.name === "and"
? w.args.find((a: any) => a.args?.[0]?.path?.at(-1) === "updatedAt")
: undefined
const filtered = tie
? rows.filter((r) => r.updatedAt === tie.args[1].value)
: rows
const out =
opts.limit !== undefined ? filtered.slice(0, opts.limit) : filtered
api.begin()
for (const r of out)
api.write({
type: api.collection.has(r.id) ? "update" : "insert",
value: { ...r },
})
api.commit()
return true
},
unloadSubset: (opts: LoadSubsetOptions) =>
console.log("unloadSubset", JSON.stringify(describe(opts))),
}
},
},
})
const top = createLiveQueryCollection((q) =>
q
.from({ i: items })
.where(({ i }) => eq(i.userId, "u1"))
.orderBy(({ i }) => i.updatedAt, "desc")
.limit(10),
)
await top.preload()
console.log(`window ready: ${top.size} rows, local collection holds ${items.size}`)
console.log("\n--- server bumps r5 (a row inside the window) to the top")
const r5 = server.find((r) => r.id === "r5")!
r5.updatedAt = 2_000_000
syncApi.begin()
syncApi.write({ type: "update", value: { ...r5 } })
syncApi.commit()
await new Promise((r) => setTimeout(r, 50))
console.log(`after bump: window ${top.size} rows, local collection holds ${items.size}`)
Output:
loadSubset {"where":"eq(userId, \"u1\")","orderBy":1,"limit":10,"cursor":"none"}
loadSubset {"where":"and(eq(userId, \"u1\"), eq(updatedAt, 999991))","orderBy":0,"cursor":"none"}
window ready: 10 rows, local collection holds 10
--- server bumps r5 (a row inside the window) to the top
loadSubset {"where":"eq(userId, \"u1\")","orderBy":0,"cursor":"none"}
unloadSubset {"where":"eq(userId, \"u1\")","orderBy":1,"limit":10,"cursor":"none"}
unloadSubset {"where":"and(eq(userId, \"u1\"), eq(updatedAt, 999991))","orderBy":0,"cursor":"none"}
after bump: window 10 rows, local collection holds 1000
Expected behavior
After a sort-key change, re-establish the window with a bounded request rather than the full source: for example, re-issue the prefix request (where + orderBy + limit: offset + limit) when the ordering is expressible, as loadPrefix already does. The adapter already has to answer exactly that request correctly for the initial load, so it's no less safe as a recovery. The full-source request would stay the fallback when the ordering can't be expressed.
If the full-source recovery has to stay, it would help adapters to be able to tell it apart, e.g. a reason: "reorder-recovery" on LoadSubsetOptions, so an adapter that can prove it still holds the true top-N (a live, server-ordered, limited subscription) can answer from that instead of fetching everything.
Environment
@tanstack/db0.9.2 (@tanstack/react-db0.4.1 in the app)- Bun 1.x, macOS; the repro is framework-free
- 主要言語
- TypeScript
- スター
- 3.9k
- フォーク
- 267
- 平均マージ
- 1日 19時間
- マージ済み PR(30日)
- 67
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
TanStack/db のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
TanStack/db#1711 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Index suggestion for collection size is gated on autoIndex, so it only fires where it is redundantオープン
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信
-
難易度 5/5 1週間以上 初心者へのやさしさ 35/100
メンテナーはふだん 1 日以内に返信
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
opengovsg/FormSG#10118 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
ai-driven-qa bug claude
難易度 1/5 1時間未満 初心者へのやさしさ 75/100
linagora/twake-calendar-frontend#1434 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
check:passed streams:add
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
iptv-org/iptv#52824 · コメント 2 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 半日 初心者へのやさしさ 78/100
jaegertracing/jaeger-ui#4512 ·
メンテナーはふだん 1 日以内に返信
-
area:ide documentation enhancement platform:macos platform:vscode
難易度 1/5 1〜3時間 初心者へのやさしさ 88/100
anthropics/claude-code#97389 ·
メンテナーはふだん 1 日以内に返信