Skip to content
Merged
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/stale-reacquired-query-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-db-collection': patch
---

Keep a reacquired Query's cached rows when an earlier release of the same Query publishes later. Previously, reacquiring a stale on-demand Query while its release waited behind another sync transaction could drop its rows, and they stayed missing if the refetch failed.
48 changes: 47 additions & 1 deletion docs/contributing/oracle-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -1064,7 +1064,53 @@ refetch owner's held-delete replay is the killing witness for trusting stale
published owners over accepted ownership. A release/reacquisition history also
holds publication, reacquires the same cached Query, then proves the older
release cannot erase the new claim by releasing it and observing an empty
public row set. Larger overlap schedules, window ownership outside the ordered
public row set. That history uses infinite `staleTime`, so reacquisition starts
no fetch. A sibling table crosses a stale reacquisition whose refetch fails,
returns the same rows, or returns no rows; the refetch settles while publication
is held or after the older release publishes; zero to two peer leases own the
first row until after the refetch; and the reacquired result has one or two
rows. Its independent model keeps each active observer lease's latest
successful result and compares public rows while held, after publication,
after the refetch, after every peer's release, and after the reacquired
release. On `main` at `31dad2dd3` 20 of its 42 cells fail: the older release's
empty ownership entry made the cached result look already applied, so the
reacquired lease owned nothing. A row only it owned disappeared at
publication; a row a peer also owned disappeared when the last peer left. The repair marks a released lease's entry and
lets only an applied entry satisfy the already-applied guard and the on-demand
direct-write skip. The empty-result cells reject keeping the row for every
reacquired demand. Removing the guard fails the persisted direct-write
histories that keep an accepted write through a refetch start, and their
sibling with an empty current result and an inserted row rejects treating
every empty entry as unapplied: in on-demand mode the insert disappears at its
receipt, and in eager mode Manual survives the Newer result. A second table
rejects deleting the entry at release: lease `b` claims the row while `a`'s
release is held or after it publishes, with or without a peer `c`, and a
server DbClient's dehydrated owners are hydrated into a fresh client. Deleting
the entry copies `a` back from unpublished metadata, which adds an owner edge
and, without a peer, leaks the row after the hydrated client releases `b`. Its `reacquired` histories reacquire `a` without
cached data and fail its fetch before `b` claims the row; deleting the entry
at reacquisition copies `a` back the same way. A
reacquisition behind a refresh barrier (`deferDataRefresh`) followed by a
direct upsert requires the reacquired Query's cache to show the upsert; reading
the released entry as applied leaves the cache stale, and only the Query cache
shows it. A release whose durable commit fails leaves the row public and
stored, puts the Collection in `error`, rejects a later acquisition with the
persistence error, and a `cleanup()` and `preload()` restart starts from clean
ownership; a host that swallows the failure fails it. A sibling holds the release's
durable commit, reacquires, and fails the reacquired application's commit: the
row leaves with the durable release, the Collection enters `error`, and a
restart serves and releases the row. Eager mode cannot reach
the reacquisition premise: it has no subset release, and ending an eager sync
run removes its Query from the cache. A cleanup that skips entries without an
observer passes this owner but fails the explicit-cleanup retention test in
`tests/query.test.ts`. A receiving probe on the Node SQLite adapter does not
match the controlled failed-release premise: when a SQLite trigger aborts
the release's delete, the Collection enters `error` and storage keeps the row,
but the public Collection drops it. The SQLite persistence owner has no
witness for that divergence ([#2210](https://github.com/TanStack/db/issues/2210)). Persisted retention (`persistedGcTime`) is
outside these histories. The
[review record](oracle-reviews/2026-10-20-stale-reacquired-ownership.md)
lists the mutant outcomes and open cells. Larger overlap schedules, window ownership outside the ordered
replay, and durable-adapter ordering remain open under the owners named above.

A controlled cold persisted scan gives one Query two rows in both scan orders,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Stale reacquired Query ownership oracle review

Reviewed source: branch `investigate-finite-stale-reacquisition`, based on
`origin/main` at `31dad2dd3`. The first runs used `acaaf4c0a`. The counts,
RED/GREEN table, and mutant table were last rerun on the commit that follows
`67987ce10`, which extends the reacquired lease table and records the open
cells; that tree was measured before it was committed. The independent
reviews read the `acaaf4c0a` tree.

## Contract and defect

PR #2198 (`db0ac815e`) made a released Query keep an empty `queryToRows` entry
until its release publishes, so metadata that has not published the release
cannot restore it as an owner. The result handler's already-applied guard and
the on-demand direct-write skip also read entry presence as "the active
observer lease has applied a result". When a stale Query was reacquired while
its release was held, the refetch-start notification repeated the cached
result, the guard read the release's entry as applied, and the reacquired
lease owned nothing. The release then removed the row, and a failed refetch
never restored it.

Laws checked, each stated beside its model in
`packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts`:

1. A reacquired observer lease owns the Query's latest successful data from
reacquisition until its own release. A successful refetch replaces that
result; a failed one keeps it. `staleTime` decides only whether a refetch
starts.
2. Published owners never name a released lease, including when another lease
claims the row while the release is held.
3. An accepted on-demand direct write patches the cache of an active Query
whose lease has not applied a result.
4. An accepted direct write survives a refetch start of an empty current
result.
5. A release whose durable commit fails is terminal for its sync run, and a
restart begins from clean ownership.

Authority: the file's opening ownership and direct-write laws, the existing
reacquisition history, TanStack Query's stale-mount and failed-refetch rules,
the DbClient hydration history, the glossary's cleanup definition, and the
AGENTS.md fail-fast rules.

## Repair

`hasAppliedOwnership` reports an entry only when it is not a release's entry.
`removeQueryOwnership` marks its empty entry in a `WeakSet`. The guard and the
on-demand direct-write skip use the helper. The result-application rollback
snapshot keeps the entry by reference (CR-1 below). The production diff is 15
lines added and 6 removed in `src/query.ts`.

## RED and GREEN

| Witness | `main` `31dad2dd3` | Repair |
| --- | --- | --- |
| Reacquired lease table (42 cells: 7 refetch histories Γ— 0–2 peers Γ— 1–2 rows) | 20 fail. Example: `published: []` and `settled: []` where the model gives `["shared"]`. With peers, `shared` is lost only at `peerReleased`, while a two-row result loses `solo` at publication | 42 pass |
| Published owners table (8 cells) | 8 pass | 8 pass |
| Reacquired cache patch behind `deferDataRefresh` | Fails: cache `["Shared"]`, model `["Manual"]` | Passes |
| Empty-result direct insert (eager, on-demand) | 2 pass | 2 pass |
| Failed release commit (3 cells) | 3 pass | 3 pass |
| Reacquired commit fails after a held release | Passes | Passes |

The oracle file passes 585 of 585 tests with the repair and 564 on `main`. The
Query Collection package passes 1460 tests with 2 skipped, and the type check reports no errors.
Three repeated runs of the new tables gave identical results.

## Mutant results

Each mutant was applied to the repaired source, run against the whole oracle
file, and reverted. Counts are for the final oracle file.

| Mutant | Outcome | Killing witness |
| --- | --- | --- |
| Guard reads entry presence (the defect) | Assertion failure, 20 tests | Reacquired lease table |
| Release entry not marked | Assertion failure, 21 tests | Reacquired lease table, cache patch |
| Direct-write skip reads entry presence | Assertion failure, 1 test | Cache patch (Query cache only; public rows agree) |
| Applied means non-empty entry | Assertion failure, 2 tests | Empty-result direct insert: on-demand loses the insert at its receipt; eager keeps Manual after Newer |
| Delete entry at release | Assertion failure, 4 tests | Published owners `while-held`: owner count 2 instead of 1; without a peer the hydrated row leaks |
| Delete the released entry when the Query is reacquired | Assertion failure, 2 tests | Published owners `while-held, reacquired`: the reacquired lease has no cached result and its fetch fails, so `b` copies `a` back from unpublished metadata |
| Replace the released entry with an unmarked empty Set at reacquisition | Assertion failure, 21 tests | Reacquired lease table, cache patch |
| Mark the reacquired lease's own application entry as released | Assertion failure, 5 tests | Persisted direct-write refetch-start histories and the direct cache patch bound |
| Reuse the released Set object for the next application | Assertion failure, 25 tests | Reacquisition history and reacquired lease table: the reused entry keeps its mark |
| Guard removed (`false &&`) | Assertion failure, 4 tests | Persisted direct-write refetch-start histories |
| Hostile storage that swallows a failed commit | Assertion failure, 2 cells and 1 test | Both failed-commit witnesses |
| Cleanup skips entries without an observer | Survives the oracle file; assertion failure, 1 test, in the package suite | `tests/query.test.ts` "should clear retained ownership during explicit collection cleanup": a retained Query's retention marker survives cleanup |

The last four rows came from a later independent review. It reported two
survivors without a distinguishing history; the concrete versions above were
written from its descriptions. Deleting the released entry at reacquisition
survived every test until the `reacquired` published-owner histories were
added; it fails them and passes nothing else differently.

## CodeRabbit review of `8ff1db49f`

Review 5600814169 had one inline finding and a summary. Ledger:

| ID | Claim | Verdict | Action |
| --- | --- | --- | --- |
| CR-1 | The rollback snapshot in result application clones the entry, so a failed application that restores it drops a release's mark | True at the code level. No public consequence reached: in persisted histories the release retires its entry before the reacquired application snapshots (instrumented with the release commit gated and ungated), and in non-persisted histories the held commit rejects only by cancellation, which the restore guards skip | Fixed: the snapshot keeps the entry by reference, because application replaces the entry rather than mutating it. No witness distinguishes this; reverting it passes all 557 tests |
| CR-2 | Add a history with reacquisition during a held release, a commit failure before publication, and a failed refetch; the row must remain until the reacquired lease is released | The history runs; its expected result conflicts with the fail-loud law. The release is durable and the reacquired result is not, so the row leaves and the Collection enters `error` | Added "fails loudly when a reacquired lease's commit fails after a held release"; a storage adapter that swallows the failure fails it |
| CR-3 | Docstring coverage 0% for touched functions | True; the touched closure helpers use line comments like their neighbours | No change, per AGENTS.md's rule to match surrounding comment density |
| CR-4 | CodeRabbit's ESLint tool timed out | A tool note, not a code claim; the repository Lint check passed on `8ff1db49f` | No change |
| CR-5 | Title, description, and issue checks passed | Informational | No change |

## ORC outcomes

- ORC-001: Each table states its law, authority, and limits before mechanics.
- ORC-002: The models keep lease results and owner sets. They do not import
`queryToRows`, the guard, or sync transactions.
- ORC-003: Prose beside each model, grammar, driver, and comparison. The
prose was updated with each grammar change.
- ORC-004: Fixed enumerations, not generated properties. Each axis has a
stated contribution: `staleTime` starts the refetch; outcome and placement
separate keeping, replacing, and losing the row; `peer` hides a lost result
until `peerReleased`; `claims` reaches the unpublished-metadata premise.
Excluded: eager reacquisition, whose premise is unreachable (probed:
cleanup removes the Query from the cache, so a restart fetches); persisted
retention, which keeps released rows by design.
- ORC-005: Public `loadSubset`, `unloadSubset`, direct writes, `DbClient`
dehydrate and hydrate, `cleanup`, and `preload`. Observations are public rows,
stored rows, Collection status, rejection messages, dehydrated owner counts,
and `QueryClient.getQueriesData`. No missing-information hypothesis was
identified.
- ORC-006: The mutant table above. Every kill is an assertion failure at the
named comparison.
- ORC-007: Not applicable; no generated property.
- ORC-008: The reacquisition model keeps one result per active lease; the peer
axis needs the union, shown by the peer cells that differ only at
`peerReleased`. The owner model keeps a set; the owner count distinguishes
`peer` histories that public rows do not.
- ORC-009: Glossary terms observer lease, acquisition release, publication,
accepted sync transaction, and cleanup. `a`, `b`, `c`, and blocker are
declared local names.
- ORC-010: Each history registers cleanup that releases its holds and Query
clients; comparisons are single structured assertions.
- ORC-011: Not applied. The named shared-fault risk (an entry read as applied)
is separated by the empty-result and cache-patch witnesses, which observe
different boundaries; no further formulation was identified.
- ORC-012: This record.
- ORC-013: The reusable law "an empty entry is applied unless it belongs to a
release" has a premise witness (reacquired lease table) and a nearby
boundary witness (empty-result direct insert) that rejects the non-empty
rule.
- ORC-014: The controlled storage adapter supplies the commit failure. A
receiving probe on Node SQLite aborted the release's delete with a SQLite
trigger. The Collection entered `error` and storage kept the row, but the
public Collection dropped it, while a peer's row stayed. The controlled
adapter keeps the row public, whether it rejects at once or after a delay.
The real SQLite adapter drops it on every sync-present configuration tried:
managed, resolved for `sync-present`, and adapter-only without a
coordinator. The controlled premise does not hold on the real host, so the
cross-boundary claim is contradicted, not established. The divergence
belongs to the SQLite persistence owner; see Open cells.

## Bug-class boundary

Contract: Query ownership after release and reacquisition. Histories: an
on-demand lease with a one- or two-row result and a held release, a stale or
fresh reacquisition, zero to two peers, an optional refresh barrier, a direct write, and a failed release commit.
Path: Query Collection over a real QueryClient and a controlled persistence
adapter. Observations: public rows, stored rows, owners, status, and Query
cache at the named checkpoints.

Open cells:

- CR-1, unreached. Core rejects a committed, held sync transaction only in
`state.cleanup()`, which runs after sync cleanup has removed the observers,
so the restore guard returns. The remaining route is a duplicate-key replay
inside the reacquired application's own open transaction, which no
history here constructs.
- SQLite durability failure. On the Node SQLite adapter, a sync
transaction (here, a release) whose durable step fails still removes the row from the public
Collection, while storage keeps it. This contradicts the glossary's
accepted-sync-transaction rule, which accepts a wrapping sync's
transaction only after its durable step. It belongs to the SQLite
persistence owner and is outside this Query DB change; tracked in [#2210](https://github.com/TanStack/db/issues/2210).
21 changes: 15 additions & 6 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,14 @@ export function queryCollectionOptions(
// queryKey β†’ Set<RowKey>. Entry presence means ownership is resolved;
// an empty set represents a resolved query that currently owns no rows.
const queryToRows = new Map<string, Set<string | number>>()
// A released query keeps an empty entry until its release publishes, so
// unpublished metadata cannot restore it as an owner. That entry is not a
// result applied by a later acquisition of the same query.
const releasedOwnership = new WeakSet<Set<string | number>>()
const hasAppliedOwnership = (hashedQueryKey: string) => {
const ownedRows = queryToRows.get(hashedQueryKey)
return ownedRows !== undefined && !releasedOwnership.has(ownedRows)
}

// RowKey β†’ Set<queryKey>
const rowToQueries = new Map<string | number, Set<string>>()
Expand Down Expand Up @@ -1118,7 +1126,9 @@ export function queryCollectionOptions(
else rowToQueries.set(rowKey, nextOwners)
})

queryToRows.set(hashedQueryKey, new Set())
const released = new Set<string | number>()
releasedOwnership.add(released)
queryToRows.set(hashedQueryKey, released)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return nextOwnersByRow
}

Expand Down Expand Up @@ -2569,9 +2579,8 @@ export function queryCollectionOptions(
const settledDirectPositions = new Map<string | number, number>()
const removedRows = new Set<string | number>()

const previousOwnedRows = queryToRows.has(hashedQueryKey)
? new Set(queryToRows.get(hashedQueryKey))
: undefined
// Application replaces this entry rather than mutating it.
const previousOwnedRows = queryToRows.get(hashedQueryKey)
const affectedRowKeys = new Set([
...previouslyOwnedRows,
...newItemsMap.keys(),
Expand Down Expand Up @@ -3003,7 +3012,7 @@ export function queryCollectionOptions(
if (
result.isFetching &&
!collection.deferDataRefresh &&
queryToRows.has(hashedQueryKey) &&
hasAppliedOwnership(hashedQueryKey) &&
queryCollectionSuccessfulFetchData.get(observedQuery) ===
observedQuery.state.data
) {
Expand Down Expand Up @@ -4271,7 +4280,7 @@ export function queryCollectionOptions(
// cached rows, so inspect those entries before their owner is known.
if (
!affectedOwners.has(hashedQueryKey) &&
queryToRows.has(hashedQueryKey) &&
hasAppliedOwnership(hashedQueryKey) &&
query.state.fetchStatus !== `fetching` &&
!pendingResultApplications.has(hashedQueryKey)
)
Expand Down
Loading
Loading