Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/small-mice-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/query-db-collection': patch
---

fix: default persisted query retention to gcTime when omitted

When `persistedGcTime` is not provided, query collections now use the query's effective `gcTime` as the persisted retention TTL. This prevents unexpectedly early cleanup of persisted rows.
27 changes: 20 additions & 7 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ export function queryCollectionOptions(
let syncStarted = false
let startupRetentionSettled = false
const retainedQueriesPendingRevalidation = new Set<string>()
const effectivePersistedGcTimes = new Map<string, number>()
const persistedRetentionTimers = new Map<
string,
ReturnType<typeof setTimeout>
Expand Down Expand Up @@ -1163,17 +1164,26 @@ export function queryCollectionOptions(
...(retryDelay !== undefined && { retryDelay }),
...(staleTime !== undefined && { staleTime }),
}

const localObserver = new QueryObserver<
Array<any>,
any,
Array<any>,
Array<any>,
any
>(queryClient, observerOptions)
const resolvedQueryGcTime = queryClient.getQueryCache().find({
queryKey: key,
exact: true,
})?.gcTime
const effectivePersistedGcTime = persistedGcTime ?? resolvedQueryGcTime

hashToQueryKey.set(hashedQueryKey, key)
state.observers.set(hashedQueryKey, localObserver)
if (effectivePersistedGcTime !== undefined) {
effectivePersistedGcTimes.set(hashedQueryKey, effectivePersistedGcTime)
} else {
effectivePersistedGcTimes.delete(hashedQueryKey)
}

// Increment reference count for this query
queryRefCounts.set(
Expand Down Expand Up @@ -1526,6 +1536,7 @@ export function queryCollectionOptions(
queryToRows.delete(hashedQueryKey)
hashToQueryKey.delete(hashedQueryKey)
queryRefCounts.delete(hashedQueryKey)
effectivePersistedGcTimes.delete(hashedQueryKey)
}

/**
Expand All @@ -1535,6 +1546,8 @@ export function queryCollectionOptions(
const cleanupQueryIfIdle = (hashedQueryKey: string) => {
const refcount = queryRefCounts.get(hashedQueryKey) || 0
const observer = state.observers.get(hashedQueryKey)
const effectivePersistedGcTime =
effectivePersistedGcTimes.get(hashedQueryKey)

if (refcount <= 0) {
// Drop our subscription so hasListeners reflects only active consumers
Expand All @@ -1561,28 +1574,28 @@ export function queryCollectionOptions(
)
}

if (persistedGcTime !== undefined) {
if (effectivePersistedGcTime !== undefined) {
if (metadata) {
begin()
metadata.collection.set(
`${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`,
{
queryHash: hashedQueryKey,
mode:
persistedGcTime === Number.POSITIVE_INFINITY
effectivePersistedGcTime === Number.POSITIVE_INFINITY
? `until-revalidated`
: `ttl`,
...(persistedGcTime === Number.POSITIVE_INFINITY
...(effectivePersistedGcTime === Number.POSITIVE_INFINITY
? {}
: { expiresAt: Date.now() + persistedGcTime }),
: { expiresAt: Date.now() + effectivePersistedGcTime }),
},
)
commit()
if (persistedGcTime !== Number.POSITIVE_INFINITY) {
if (effectivePersistedGcTime !== Number.POSITIVE_INFINITY) {
schedulePersistedRetentionExpiry({
queryHash: hashedQueryKey,
mode: `ttl`,
expiresAt: Date.now() + persistedGcTime,
expiresAt: Date.now() + effectivePersistedGcTime,
})
}
}
Expand Down
211 changes: 211 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4863,6 +4863,217 @@ describe(`QueryCollection`, () => {
}
})

it(`should default persisted retention ttl to query gcTime when persistedGcTime is undefined`, async () => {
vi.useFakeTimers()
const gcTime = 120
const gcTimeFallbackQueryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime,
staleTime: 0,
retry: false,
},
},
})

try {
const baseQueryKey = [`runtime-ttl-retention-default-gctime-test`]
const retainedQueryHash = hashKey(baseQueryKey)
const items: Array<CategorisedItem> = [
{ id: `1`, name: `Retained`, category: `A` },
]
const queryFn = vi.fn().mockResolvedValue(items)

const config: QueryCollectionConfig<CategorisedItem> = {
id: `runtime-ttl-retention-default-gctime-test`,
queryClient: gcTimeFallbackQueryClient,
queryKey: () => baseQueryKey,
queryFn,
getKey: (item) => item.id,
syncMode: `on-demand`,
startSync: true,
}

const baseOptions = queryCollectionOptions(config)
const originalSync = baseOptions.sync
const metadataHarness = createInMemorySyncMetadataApi<
string | number,
CategorisedItem
>({
persistedRows: new Map(items.map((item) => [item.id, item])),
})

const collection = createCollection({
...baseOptions,
sync: {
sync: (params: Parameters<typeof originalSync.sync>[0]) =>
originalSync.sync({
...params,
metadata: metadataHarness.api,
}),
},
})

const liveQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ item: collection })
.where(({ item }) => eq(item.category, `A`)),
})

await liveQuery.preload()
await vi.waitFor(() => {
expect(collection.size).toBe(1)
})

await liveQuery.cleanup()

const retentionEntry = metadataHarness.collectionMetadata.get(
`queryCollection:gc:${retainedQueryHash}`,
) as
| {
queryHash: string
mode: `ttl` | `until-revalidated`
expiresAt?: number
}
| undefined

expect(retentionEntry).toEqual({
queryHash: retainedQueryHash,
mode: `ttl`,
expiresAt: expect.any(Number),
})
expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now())
expect(retentionEntry?.expiresAt).toBeLessThanOrEqual(
Date.now() + gcTime,
)

await vi.advanceTimersByTimeAsync(gcTime + 25)
await vi.runOnlyPendingTimersAsync()

expect(
metadataHarness.collectionMetadata.get(
`queryCollection:gc:${retainedQueryHash}`,
),
).toBeUndefined()
expect(collection.has(`1`)).toBe(false)
} finally {
gcTimeFallbackQueryClient.clear()
vi.useRealTimers()
}
})

it(`should default persisted retention to resolved query gcTime when queryClient gcTime is implicit`, async () => {
vi.useFakeTimers()
const implicitGcTimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
retry: false,
},
},
})

try {
const baseQueryKey = [`runtime-ttl-retention-implicit-gctime-test`]
const retainedQueryHash = hashKey(baseQueryKey)
const items: Array<CategorisedItem> = [
{ id: `1`, name: `Retained`, category: `A` },
]
const queryFn = vi.fn().mockResolvedValue(items)

const config: QueryCollectionConfig<CategorisedItem> = {
id: `runtime-ttl-retention-implicit-gctime-test`,
queryClient: implicitGcTimeQueryClient,
queryKey: () => baseQueryKey,
queryFn,
getKey: (item) => item.id,
syncMode: `on-demand`,
startSync: true,
}

const baseOptions = queryCollectionOptions(config)
const originalSync = baseOptions.sync
const metadataHarness = createInMemorySyncMetadataApi<
string | number,
CategorisedItem
>({
persistedRows: new Map(items.map((item) => [item.id, item])),
})

const collection = createCollection({
...baseOptions,
sync: {
sync: (params: Parameters<typeof originalSync.sync>[0]) =>
originalSync.sync({
...params,
metadata: metadataHarness.api,
}),
},
})

const liveQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ item: collection })
.where(({ item }) => eq(item.category, `A`)),
})

await liveQuery.preload()
await vi.waitFor(() => {
expect(collection.size).toBe(1)
})

const resolvedQueryGcTime = implicitGcTimeQueryClient
.getQueryCache()
.find({ queryKey: baseQueryKey, exact: true })?.gcTime

expect(resolvedQueryGcTime).toBeDefined()

await liveQuery.cleanup()

const retentionEntry = metadataHarness.collectionMetadata.get(
`queryCollection:gc:${retainedQueryHash}`,
) as
| {
queryHash: string
mode: `ttl` | `until-revalidated`
expiresAt?: number
}
| undefined

if (resolvedQueryGcTime === Number.POSITIVE_INFINITY) {
expect(retentionEntry).toEqual({
queryHash: retainedQueryHash,
mode: `until-revalidated`,
})
} else {
expect(retentionEntry).toEqual({
queryHash: retainedQueryHash,
mode: `ttl`,
expiresAt: expect.any(Number),
})
expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now())
expect(retentionEntry?.expiresAt).toBeLessThanOrEqual(
Date.now() + resolvedQueryGcTime!,
)

await vi.advanceTimersByTimeAsync(resolvedQueryGcTime! + 25)
await vi.runOnlyPendingTimersAsync()

expect(
metadataHarness.collectionMetadata.get(
`queryCollection:gc:${retainedQueryHash}`,
),
).toBeUndefined()
expect(collection.has(`1`)).toBe(false)
}
} finally {
implicitGcTimeQueryClient.clear()
vi.useRealTimers()
}
})

it(`should reset refcount after query GC and reload (stale refcount bug)`, async () => {
// This test catches Bug 2: stale refcounts after GC/remove
// When TanStack Query GCs a query, the refcount should be cleaned up
Expand Down
Loading