Inside TanStack Query - garbage collection

6 min read

紀錄 TanStack Query 如何實作 garbage collection 機制,確保未使用的快取不會無限增長。

程式碼參考 v5.45.0

Query

從測試開始找線索,搜尋與 gcTime 相關測試,在 query.test.tsx 中發現:

query.test.tsx
test('should use the longest garbage collection time it has seen', async () => {
  // ...
})
 
test('should be garbage collected when unsubscribed to', async () => {
  // ...
})
 
test('should be garbage collected later when unsubscribed and query is fetching', async () => {
  // ...
})
 
test('should not be garbage collected unless there are no subscribers', async () => {
  // ...
})
 
test('queries should be garbage collected even if they never fetched', async () => {
  // ...
})

知道與 Query 相關,所以先看到 Query 實作。QueryRemovable 的子類別 (L6, L9)

query.ts
export class Query<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Removable {
  // ...
  constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
    super()
    // ...
  }
}

Removable 為一個抽象類別,可以看到 garbage collection 相關實作:

updateGcTime (L23-L26) 對應官方文件對 gcTime 的說明:

  • Defaults to 5 * 60 * 1000 (5 minutes) or Infinity during SSR
  • When different garbage collection times are specified, the longest one will be used.
removable.ts
import { isServer, isValidTimeout } from './utils'
 
export abstract class Removable {
  gcTime!: number
  #gcTimeout?: ReturnType<typeof setTimeout>
 
  destroy(): void {
    this.clearGcTimeout()
  }
 
  protected scheduleGc(): void {
    this.clearGcTimeout()
 
    if (isValidTimeout(this.gcTime)) {
      this.#gcTimeout = setTimeout(() => {
        this.optionalRemove()
      }, this.gcTime)
    }
  }
 
  protected updateGcTime(newGcTime: number | undefined): void {
    // Default to 5 minutes (Infinity for server-side) if no gcTime is set
    this.gcTime = Math.max(
      this.gcTime || 0,
      newGcTime ?? (isServer ? Infinity : 5 * 60 * 1000),
    )
  }
 
  protected clearGcTimeout() {
    if (this.#gcTimeout) {
      clearTimeout(this.#gcTimeout)
      this.#gcTimeout = undefined
    }
  }
 
  protected abstract optionalRemove(): void
}

Query.optionalRemove() 得知:

query.ts
export class Query<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Removable {
  // ...
  cache: QueryCache
  state: QueryState<TData, TError>
  observers: Array<QueryObserver<any, any, any, any, any>>
  // ...
  protected optionalRemove() {
    if (!this.observers.length && this.state.fetchStatus === 'idle') {
      this.#cache.remove(this)
    }
  }
  // ...
}

接著看到 Query 設置 garbage collection 定時器的時機:

query.ts
export class Query<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Removable {
  // ...
  constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
    super()
    // ...
    this.setOptions(config.options)
    //...
    this.scheduleGc()
  }
 
  setOptions(
    options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
  ): void {
    this.options = { ...this.#defaultOptions, ...options }
 
    this.updateGcTime(this.options.gcTime)
  }
  // ...
  removeObserver(observer: QueryObserver<any, any, any, any, any>): void {
    if (this.observers.includes(observer)) {
      this.observers = this.observers.filter((x) => x !== observer)
 
      if (!this.observers.length) {
        // ...
        this.scheduleGc()
      }
      // ...
    }
  }
 
  fetch(
    options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    fetchOptions?: FetchOptions<TQueryFnData>,
  ): Promise<TData> {
    // ...
    const onError = (error: TError | { silent?: boolean }) => {
      // ...
      if (!this.isFetchingOptimistic) {
        // Schedule query gc after fetching
        this.scheduleGc()
      }
      this.isFetchingOptimistic = false
    }
 
    // Try to fetch the data
    this.#retryer = createRetryer({
      // ...
      onError,
      onSuccess: (data) => {
        // ...
        if (!this.isFetchingOptimistic) {
          // Schedule query gc after fetching
          this.scheduleGc()
        }
        this.isFetchingOptimistic = false
      },
      // ...
    })
 
    return this.#retryer.start()
  }
  // ...
}

Mutation

在 query-core 中,Mutation 也是 Removable 的子類別,Mutation.optionalRemove() 則是向 MutationCahce 清除未使用的 Mutation

mutation.ts
export class Mutation<
  TData = unknown,
  TError = DefaultError,
  TVariables = unknown,
  TContext = unknown,
> extends Removable {
  // ...
  constructor(config: MutationConfig<TData, TError, TVariables, TContext>) {
    super()
    // ...
    this.setOptions(config.options)
    this.scheduleGc()
  }
 
  setOptions(
    options: MutationOptions<TData, TError, TVariables, TContext>,
  ): void {
    this.options = options
 
    this.updateGcTime(this.options.gcTime)
  }
 
  protected optionalRemove() {
    if (!this.#observers.length) {
      if (this.state.status === 'pending') {
        this.scheduleGc()
      } else {
        this.#mutationCache.remove(this)
      }
    }
  }
  // ...
}

Mutation 設置 garbage collection 定時器的時機:

mutation.ts
export class Mutation<
  TData = unknown,
  TError = DefaultError,
  TVariables = unknown,
  TContext = unknown,
> extends Removable {
  // ...
  constructor(config: MutationConfig<TData, TError, TVariables, TContext>) {
    super()
    // ...
    this.setOptions(config.options)
    this.scheduleGc()
  }
 
  setOptions(
    options: MutationOptions<TData, TError, TVariables, TContext>,
  ): void {
    this.options = options
 
    this.updateGcTime(this.options.gcTime)
  }
 
  removeObserver(observer: MutationObserver<any, any, any, any>): void {
    this.#observers = this.#observers.filter((x) => x !== observer)
 
    this.scheduleGc()
    // ...
  }
 
  protected optionalRemove() {
    if (!this.#observers.length) {
      if (this.state.status === 'pending') {
        this.scheduleGc()
      } else {
        this.#mutationCache.remove(this)
      }
    }
  }
  // ...
}

Summary

Reference