Skip to content

CleanupQueue schedules GC deadlines on wall-clock time but services them with an elapsed-time timer #1796

Description

@benjosity

CleanupQueue schedules GC deadlines on wall-clock time but services them with an elapsed-time timer

Package: @tanstack/db 0.8.7 — packages/db/src/collection/cleanup-queue.ts (unchanged on main at the time of writing).

What happens

CleanupQueue.schedule records executeAt = Date.now() + gcTime. updateTimeout arms setTimeout(process, executeAt - Date.now()), and process runs a task only when Date.now() >= task.executeAt, otherwise re-arming for the remainder.

setTimeout measures elapsed time; Date.now() is the realtime clock. When realtime moves backwards between schedule and process — an NTP step, a VM or container guest resynchronising with its host, a manual clock change — process finds now < executeAt and re-arms for executeAt - now, which is roughly the size of the step. Every pending cleanup is delayed by that amount regardless of its gcTime.

For a live query collection this delays the release of its subscriptions to its source collections: a component unmounts, the live query's gcTime (1 ms under @tanstack/react-db's useLiveQuery) elapses, and the source still counts it as a subscriber seconds later.

How it was observed

In a Vitest lane that waits for source collections to reach zero subscribers before disposing them, the wait timed out intermittently with the queue holding one task whose executeAt was 1.7–2.7 s ahead of Date.now() and its timer armed for that difference. A sampler on the same machine (a WSL2 guest with an in-guest time-sync service) recorded the realtime clock stepping back ~3.7 s periodically while performance.now() advanced steadily. The environment is only how it was noticed; any realtime step reproduces it.

Deterministic reproduction

import { createCollection, createLiveQueryCollection } from '@tanstack/db'

const source = createCollection<{ id: string }>({
  id: 'source',
  getKey: (row) => row.id,
  startSync: true,
  sync: { sync: ({ markReady }) => { markReady(); return () => {} } },
})
const live = createLiveQueryCollection({
  query: (q) => q.from({ s: source }),
  gcTime: 1,
  startSync: true,
})
const subscription = live.subscribeChanges(() => {})
console.log(source.subscriberCount) // 1

subscription.unsubscribe() // live query's GC is scheduled: executeAt = Date.now() + 1
const realNow = Date.now
Date.now = () => realNow() - 3000 // realtime steps back 3 s before the timer fires

setTimeout(() => console.log(source.subscriberCount), 100) // still 1
setTimeout(() => console.log(source.subscriberCount), 3500) // 0 — released ~3 s late

With a monotonic deadline the second log would already read 0 at 100 ms.

Suggested property

Deadlines and the timer that services them should share a clock: compute executeAt from a monotonic elapsed-time source (for example performance.now(), with Date.now() only as a fallback where it is unavailable) so that a realtime step cannot move a scheduled cleanup. The specific implementation is up to you; the property is that process runs a task once the elapsed time since schedule reaches gcTime.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions