Skip to content

Commit a937f4c

Browse files
authored
Fix infinite loop in orderBy (#450)
1 parent 48d0889 commit a937f4c

File tree

4 files changed

+181
-114
lines changed

4 files changed

+181
-114
lines changed

.changeset/shaggy-worlds-kick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Fix infinite loop bug with queries that use orderBy clause with a limit

packages/db/src/query/live/collection-config-builder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export class CollectionConfigBuilder<
5959
OrderByOptimizationInfo
6060
> = {}
6161

62+
private collectionReady = false
63+
6264
constructor(
6365
private readonly config: LiveQueryCollectionConfig<TContext, TResult>
6466
) {
@@ -78,6 +80,10 @@ export class CollectionConfigBuilder<
7880
this.compileBasePipeline()
7981
}
8082

83+
isCollectionReady() {
84+
return this.collectionReady
85+
}
86+
8187
getConfig(): CollectionConfig<TResult> {
8288
return {
8389
id: this.id,
@@ -126,6 +132,8 @@ export class CollectionConfigBuilder<
126132
// Mark the collection as ready after the first successful run
127133
if (ready && this.allCollectionsReady()) {
128134
markReady()
135+
// Remember that we marked the collection as ready
136+
this.collectionReady = true
129137
}
130138
}
131139
}

packages/db/src/query/live/collection-subscriber.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,25 @@ export class CollectionSubscriber<
8080
callback?: () => boolean
8181
) {
8282
const input = this.syncState.inputs[this.collectionId]!
83-
sendChangesToInput(input, changes, this.collection.config.getKey)
84-
this.collectionConfigBuilder.maybeRunGraph(
85-
this.config,
86-
this.syncState,
87-
callback
83+
const sentChanges = sendChangesToInput(
84+
input,
85+
changes,
86+
this.collection.config.getKey
8887
)
88+
if (sentChanges > 0 || !this.collectionConfigBuilder.isCollectionReady()) {
89+
// Only run the graph if we sent any changes
90+
// otherwise we may get into an infinite loop
91+
// trying to load more data for the orderBy query
92+
// when there's no more data in the collection
93+
// EXCEPTION: if the collection is not yet ready
94+
// we need to run it even if there are no changes
95+
// in order for the collection to be marked as ready
96+
this.collectionConfigBuilder.maybeRunGraph(
97+
this.config,
98+
this.syncState,
99+
callback
100+
)
101+
}
89102
}
90103

91104
// Wraps the sendChangesToPipeline function
@@ -377,7 +390,7 @@ function sendChangesToInput(
377390
input: RootStreamBuilder<unknown>,
378391
changes: Iterable<ChangeMessage>,
379392
getKey: (item: ChangeMessage[`value`]) => any
380-
) {
393+
): number {
381394
const multiSetArray: MultiSetArray<unknown> = []
382395
for (const change of changes) {
383396
const key = getKey(change.value)
@@ -392,6 +405,7 @@ function sendChangesToInput(
392405
}
393406
}
394407
input.sendData(new MultiSet(multiSetArray))
408+
return multiSetArray.length
395409
}
396410

397411
/** Splits updates into a delete of the old value and an insert of the new value */

packages/db/tests/query/indexes.test.ts

Lines changed: 148 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { beforeEach, describe, expect, it } from "vitest"
2-
import mitt from "mitt"
32
import { createCollection } from "../../src/collection"
43

54
import { createLiveQueryCollection } from "../../src/query/live-query-collection"
@@ -13,8 +12,7 @@ import {
1312
length,
1413
or,
1514
} from "../../src/query/builder/functions"
16-
import type { Collection } from "../../src/collection"
17-
import type { PendingMutation } from "../../src/types"
15+
import { mockSyncCollectionOptions } from "../utls"
1816

1917
interface TestItem {
2018
id: string
@@ -186,88 +184,65 @@ function withIndexTracking(
186184
}
187185
}
188186

187+
const testData: Array<TestItem> = [
188+
{
189+
id: `1`,
190+
name: `Alice`,
191+
age: 25,
192+
status: `active`,
193+
score: 95,
194+
createdAt: new Date(`2023-01-01`),
195+
},
196+
{
197+
id: `2`,
198+
name: `Bob`,
199+
age: 30,
200+
status: `inactive`,
201+
score: 80,
202+
createdAt: new Date(`2023-01-02`),
203+
},
204+
{
205+
id: `3`,
206+
name: `Charlie`,
207+
age: 35,
208+
status: `active`,
209+
score: 90,
210+
createdAt: new Date(`2023-01-03`),
211+
},
212+
{
213+
id: `4`,
214+
name: `Diana`,
215+
age: 28,
216+
status: `pending`,
217+
score: 85,
218+
createdAt: new Date(`2023-01-04`),
219+
},
220+
{
221+
id: `5`,
222+
name: `Eve`,
223+
age: 22,
224+
status: `active`,
225+
score: undefined,
226+
createdAt: new Date(`2023-01-05`),
227+
},
228+
]
229+
230+
function createTestItemCollection(autoIndex: `off` | `eager` = `off`) {
231+
return createCollection(
232+
mockSyncCollectionOptions<TestItem>({
233+
id: `test-collection`,
234+
getKey: (item) => item.id,
235+
initialData: testData,
236+
autoIndex,
237+
})
238+
)
239+
}
240+
189241
describe(`Query Index Optimization`, () => {
190-
let collection: Collection<TestItem, string>
191-
let testData: Array<TestItem>
192-
let emitter: any
242+
let collection: ReturnType<typeof createTestItemCollection>
193243

194244
beforeEach(async () => {
195-
testData = [
196-
{
197-
id: `1`,
198-
name: `Alice`,
199-
age: 25,
200-
status: `active`,
201-
score: 95,
202-
createdAt: new Date(`2023-01-01`),
203-
},
204-
{
205-
id: `2`,
206-
name: `Bob`,
207-
age: 30,
208-
status: `inactive`,
209-
score: 80,
210-
createdAt: new Date(`2023-01-02`),
211-
},
212-
{
213-
id: `3`,
214-
name: `Charlie`,
215-
age: 35,
216-
status: `active`,
217-
score: 90,
218-
createdAt: new Date(`2023-01-03`),
219-
},
220-
{
221-
id: `4`,
222-
name: `Diana`,
223-
age: 28,
224-
status: `pending`,
225-
score: 85,
226-
createdAt: new Date(`2023-01-04`),
227-
},
228-
{
229-
id: `5`,
230-
name: `Eve`,
231-
age: 22,
232-
status: `active`,
233-
score: undefined,
234-
createdAt: new Date(`2023-01-05`),
235-
},
236-
]
237-
238-
emitter = mitt()
239-
240-
collection = createCollection<TestItem, string>({
241-
getKey: (item) => item.id,
242-
startSync: true,
243-
sync: {
244-
sync: ({ begin, write, commit }) => {
245-
// Provide initial data through sync
246-
begin()
247-
for (const item of testData) {
248-
write({
249-
type: `insert`,
250-
value: item,
251-
})
252-
}
253-
commit()
254-
255-
// Listen for mutations and sync them back (only register once)
256-
if (!emitter.all.has(`sync`)) {
257-
emitter.on(`sync`, (changes: Array<PendingMutation>) => {
258-
begin()
259-
changes.forEach((change) => {
260-
write({
261-
type: change.type,
262-
value: change.modified as unknown as TestItem,
263-
})
264-
})
265-
commit()
266-
})
267-
}
268-
},
269-
},
270-
})
245+
collection = createTestItemCollection()
271246

272247
// Wait for sync to complete
273248
await collection.stateWhenReady()
@@ -1315,36 +1290,101 @@ describe(`Query Index Optimization`, () => {
13151290
})
13161291

13171292
it(`should optimize live queries with ORDER BY and LIMIT`, async () => {
1318-
await withIndexTracking(collection, async (tracker) => {
1319-
const liveQuery = createLiveQueryCollection({
1320-
query: (q: any) =>
1321-
q
1322-
.from({ item: collection })
1323-
.where(({ item }: any) => eq(item.status, `active`))
1324-
.orderBy(({ item }: any) => [item.age])
1325-
.limit(2)
1326-
.select(({ item }: any) => ({
1327-
id: item.id,
1328-
name: item.name,
1329-
age: item.age,
1330-
})),
1331-
startSync: true,
1332-
})
1293+
collection.createIndex((row) => row.age)
13331294

1334-
await liveQuery.stateWhenReady()
1295+
const liveQuery = createLiveQueryCollection({
1296+
query: (q: any) =>
1297+
q
1298+
.from({ item: collection })
1299+
.where(({ item }: any) => eq(item.status, `active`))
1300+
.orderBy(({ item }: any) => item.age)
1301+
.limit(2)
1302+
.select(({ item }: any) => ({
1303+
id: item.id,
1304+
name: item.name,
1305+
age: item.age,
1306+
})),
1307+
startSync: true,
1308+
})
13351309

1336-
// Should have found limited results
1337-
expect(liveQuery.size).toBeLessThanOrEqual(2)
1310+
await liveQuery.stateWhenReady()
1311+
1312+
// Should have found limited results
1313+
expect(liveQuery.size).toBe(2)
1314+
1315+
expect(liveQuery.toArray).toEqual([
1316+
{ id: `5`, name: `Eve`, age: 22 },
1317+
{ id: `1`, name: `Alice`, age: 25 },
1318+
])
1319+
1320+
collection.utils.begin()
1321+
collection.utils.write({
1322+
type: `insert`,
1323+
value: {
1324+
id: `6`,
1325+
name: `Dave`,
1326+
age: 20,
1327+
status: `active`,
1328+
score: undefined,
1329+
createdAt: new Date(`2023-01-09`),
1330+
},
1331+
})
1332+
collection.utils.commit()
13381333

1339-
// Should use index optimization for the WHERE clause even with ORDER BY + LIMIT
1340-
// The WHERE clause can be optimized independently of the ORDER BY + LIMIT operations
1341-
expectIndexUsage(tracker.stats, {
1342-
shouldUseIndex: true,
1343-
shouldUseFullScan: false,
1344-
indexCallCount: 1, // The status='active' condition can use index
1345-
fullScanCallCount: 0,
1346-
})
1334+
expect(liveQuery.size).toBe(2)
1335+
1336+
expect(liveQuery.toArray).toEqual([
1337+
{ id: `6`, name: `Dave`, age: 20 },
1338+
{ id: `5`, name: `Eve`, age: 22 },
1339+
])
1340+
})
1341+
1342+
it(`should stop loading data for live queries with ORDER BY and LIMIT if no more data available`, async () => {
1343+
collection.createIndex((row) => row.age)
1344+
1345+
const liveQuery = createLiveQueryCollection({
1346+
query: (q: any) =>
1347+
q
1348+
.from({ item: collection })
1349+
.orderBy(({ item }: any) => item.age)
1350+
.limit(10) // limit > than total number of items in the collection (5)
1351+
.select(({ item }: any) => ({
1352+
id: item.id,
1353+
name: item.name,
1354+
age: item.age,
1355+
})),
1356+
startSync: true,
1357+
})
1358+
1359+
await liveQuery.stateWhenReady()
1360+
1361+
expect(liveQuery.size).toBe(5)
1362+
1363+
// Insert a new item
1364+
collection.utils.begin()
1365+
collection.utils.write({
1366+
type: `insert`,
1367+
value: {
1368+
id: `6`,
1369+
name: `Dave`,
1370+
age: 29,
1371+
status: `active`,
1372+
score: undefined,
1373+
createdAt: new Date(`2023-01-09`),
1374+
},
13471375
})
1376+
collection.utils.commit()
1377+
1378+
expect(liveQuery.size).toBe(6)
1379+
1380+
expect(liveQuery.toArray).toEqual([
1381+
{ id: `5`, name: `Eve`, age: 22 },
1382+
{ id: `1`, name: `Alice`, age: 25 },
1383+
{ id: `4`, name: `Diana`, age: 28 },
1384+
{ id: `6`, name: `Dave`, age: 29 },
1385+
{ id: `2`, name: `Bob`, age: 30 },
1386+
{ id: `3`, name: `Charlie`, age: 35 },
1387+
])
13481388
})
13491389

13501390
it(`should handle live queries without WHERE clauses`, async () => {

0 commit comments

Comments
 (0)