Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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