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
5 changes: 5 additions & 0 deletions .changeset/gc-cleanup-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

fix: Optimized unmount performance by batching cleanup tasks in a central queue.
90 changes: 90 additions & 0 deletions packages/db/src/collection/cleanup-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
type CleanupTask = {
executeAt: number
callback: () => void
}

export class CleanupQueue {
private static instance: CleanupQueue | null = null

private tasks: Map<unknown, CleanupTask> = new Map()

private timeoutId: ReturnType<typeof setTimeout> | null = null
private microtaskScheduled = false

private constructor() {}

public static getInstance(): CleanupQueue {
if (!CleanupQueue.instance) {
CleanupQueue.instance = new CleanupQueue()
}
return CleanupQueue.instance
}

public schedule(key: unknown, gcTime: number, callback: () => void): void {
const executeAt = Date.now() + gcTime
this.tasks.set(key, { executeAt, callback })

if (!this.microtaskScheduled) {
this.microtaskScheduled = true
Promise.resolve().then(() => {
this.microtaskScheduled = false
this.updateTimeout()
})
}
}

public cancel(key: unknown): void {
this.tasks.delete(key)
}

private updateTimeout(): void {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}

if (this.tasks.size === 0) {
return
}

let earliestTime = Infinity
for (const task of this.tasks.values()) {
if (task.executeAt < earliestTime) {
earliestTime = task.executeAt
}
}

const delay = Math.max(0, earliestTime - Date.now())
this.timeoutId = setTimeout(() => this.process(), delay)
}

private process(): void {
this.timeoutId = null
const now = Date.now()

for (const [key, task] of this.tasks.entries()) {
if (now >= task.executeAt) {
this.tasks.delete(key)
try {
task.callback()
} catch (error) {
console.error('Error in CleanupQueue task:', error)
}
}
}

if (this.tasks.size > 0) {
this.updateTimeout()
}
}

// Only used for testing to clean up state
public static resetInstance(): void {
if (CleanupQueue.instance) {
if (CleanupQueue.instance.timeoutId !== null) {
clearTimeout(CleanupQueue.instance.timeoutId)
}
CleanupQueue.instance = null
}
}
}
20 changes: 5 additions & 15 deletions packages/db/src/collection/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
safeCancelIdleCallback,
safeRequestIdleCallback,
} from '../utils/browser-polyfills'
import { CleanupQueue } from './cleanup-queue'
import type { IdleCallbackDeadline } from '../utils/browser-polyfills'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { CollectionConfig, CollectionStatus } from '../types'
Expand Down Expand Up @@ -34,7 +35,6 @@ export class CollectionLifecycleManager<
public hasBeenReady = false
public hasReceivedFirstCommit = false
public onFirstReadyCallbacks: Array<() => void> = []
public gcTimeoutId: ReturnType<typeof setTimeout> | null = null
private idleCallbackId: number | null = null

/**
Expand Down Expand Up @@ -174,10 +174,6 @@ export class CollectionLifecycleManager<
* Called when the collection becomes inactive (no subscribers)
*/
public startGCTimer(): void {
if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
}

const gcTime = this.config.gcTime ?? 300000 // 5 minutes default

// If gcTime is 0, negative, or non-finite (Infinity, -Infinity, NaN), GC is disabled.
Expand All @@ -187,23 +183,20 @@ export class CollectionLifecycleManager<
return
}

this.gcTimeoutId = setTimeout(() => {
CleanupQueue.getInstance().schedule(this, gcTime, () => {
if (this.changes.activeSubscribersCount === 0) {
// Schedule cleanup during idle time to avoid blocking the UI thread
this.scheduleIdleCleanup()
}
}, gcTime)
})
}

/**
* Cancel the garbage collection timer
* Called when the collection becomes active again
*/
public cancelGCTimer(): void {
if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
this.gcTimeoutId = null
}
CleanupQueue.getInstance().cancel(this)
// Also cancel any pending idle cleanup
if (this.idleCallbackId !== null) {
safeCancelIdleCallback(this.idleCallbackId)
Expand Down Expand Up @@ -258,10 +251,7 @@ export class CollectionLifecycleManager<
this.changes.cleanup()
this.indexes.cleanup()

if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
this.gcTimeoutId = null
}
CleanupQueue.getInstance().cancel(this)

this.hasBeenReady = false

Expand Down
131 changes: 131 additions & 0 deletions packages/db/tests/cleanup-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CleanupQueue } from '../src/collection/cleanup-queue'

describe('CleanupQueue', () => {
beforeEach(() => {
vi.useFakeTimers()
CleanupQueue.resetInstance()
})

afterEach(() => {
vi.useRealTimers()
CleanupQueue.resetInstance()
})

it('batches setTimeout creations across multiple synchronous schedules', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()
const cb2 = vi.fn()

const spySetTimeout = vi.spyOn(global, 'setTimeout')

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 1000, cb2)

expect(spySetTimeout).not.toHaveBeenCalled()

// Process microtasks
await Promise.resolve()

// Should only create a single timeout for the earliest scheduled task
expect(spySetTimeout).toHaveBeenCalledTimes(1)
})

it('executes callbacks after delay', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()

queue.schedule('key1', 1000, cb1)

await Promise.resolve()

expect(cb1).not.toHaveBeenCalled()

vi.advanceTimersByTime(500)
expect(cb1).not.toHaveBeenCalled()

vi.advanceTimersByTime(500)
expect(cb1).toHaveBeenCalledTimes(1)
})

it('can cancel tasks before they run', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()

queue.schedule('key1', 1000, cb1)

await Promise.resolve()

queue.cancel('key1')

vi.advanceTimersByTime(1000)
expect(cb1).not.toHaveBeenCalled()
})

it('schedules subsequent tasks properly if earlier tasks are cancelled', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()
const cb2 = vi.fn()

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 2000, cb2)

await Promise.resolve()

queue.cancel('key1')

// At 1000ms, process will be called because of the original timeout, but no callbacks will trigger
vi.advanceTimersByTime(1000)
expect(cb1).not.toHaveBeenCalled()
expect(cb2).not.toHaveBeenCalled()

// It should automatically schedule the next timeout for key2
vi.advanceTimersByTime(1000)
expect(cb2).toHaveBeenCalledTimes(1)
})

it('processes multiple tasks that have expired at the same time', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()
const cb2 = vi.fn()
const cb3 = vi.fn()

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 1500, cb2)
queue.schedule('key3', 1500, cb3)

await Promise.resolve()

vi.advanceTimersByTime(1000)
expect(cb1).toHaveBeenCalledTimes(1)
expect(cb2).not.toHaveBeenCalled()

vi.advanceTimersByTime(500)
expect(cb2).toHaveBeenCalledTimes(1)
expect(cb3).toHaveBeenCalledTimes(1)
})

it('continues processing tasks if one throws an error', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn().mockImplementation(() => {
throw new Error('Test error')
})
const cb2 = vi.fn()

const spyConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {})

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 1000, cb2)

await Promise.resolve()

vi.advanceTimersByTime(1000)

expect(cb1).toHaveBeenCalledTimes(1)
expect(spyConsoleError).toHaveBeenCalledWith('Error in CleanupQueue task:', expect.any(Error))
// cb2 should still be called even though cb1 threw an error
expect(cb2).toHaveBeenCalledTimes(1)

spyConsoleError.mockRestore()
})
})
Loading