Skip to content

A second live query over a refetched on-demand source keeps a short joined window #2155

Open

Description

@KyleAMathews

Problem

Two live-query Collections can read the same on-demand source. After a refetch, the second Collection can show a short ordered window. It stays short until a different change starts a load.

The ordered-work oracle in #2132 found this with a generated history. The failure also occurs with the production code on main, so #2132 does not cause it.

Minimal trace

  • On-demand source with a refetching provider. The provider sends no change until a refetch, and each refetch keeps every served ordered range whole.
  • Query: rows LEFT JOIN markers on the row's join key (link), WHERE the marker exists, ORDER BY rank ASC with an index on rank.
  • Collection A has limit 2. Collection B (the second live-query Collection) has limit 3.
  • Rows 9, 0 and 1 have rank 0 and a marker. Row 2 has rank 1 and no marker. 200 filler rows have rank 100 and up, with markers.

Steps:

  1. The backend inserts row 10, with rank 1 and no marker. The provider does not send it yet.
  2. Row 0 changes its join key, so it no longer matches a marker. The provider does not send this yet.
  3. The provider refetches.

Expected window of Collection B: ranks [0, 0, 100] (rows 9, 1 and the first filler row). Actual window: [0, 0]. Collection A shows the expected window.

Ingredients

The failure needs all of these:

  • A refetching provider. A forwarding provider passes.
  • A second live-query Collection on the same source. One Collection with limit 3 passes.
  • A join on the row's join key. The same history with marker changes passes.
  • A tie at the window boundary with a row that the provider never sent. Without the insert of row 10, the history passes.

A source filter instead of a joined filter also passes.

Hypothesis

After the refetch, row 0 leaves both windows in one commit. Collection B must load past the rank-1 tie group, where every row fails the join. Two causes are possible:

  • Collection B's ordered loader does not request again, because its last page has the same count and boundary as before.
  • The rows that Collection A already loaded answer the request of Collection B.

Then nothing proves the window, and no request follows.

Evidence

This issue was written by Isaac.

Activity

  1. KyleAMathews commented on Oct 15, 2026

    @KyleAMathews
    CollaboratorAuthor

    Handoff: reproduction, request trace, and a located dead end

    This adds what the next agent needs. Line numbers refer to PR #2154 at abc08f1c8 (branch fix-query-db-refetch-ordered-gap). The relevant loader code is unchanged from main.

    Reproduce

    The pinned replay in packages/db/tests/query/ordered-work-oracle.property.test.ts:

    cd packages/db
    npx vitest run tests/query/ordered-work-oracle.property.test.ts \
      -t "shows the known short peer joined window after a refetch removes a tied row" \
      --typecheck.enabled=false

    The replay passes today because it expects the symptom: it requires checkEagerWindow to reject with exactly
    peer rows after step 2: expected [ +0, +0 ] to deeply equal [ +0, +0, 100 ].
    Once this is fixed, replace await expect(checkEagerWindow(...)).rejects.toThrow(...) with a plain await checkEagerWindow(...).

    The case (as pinned):

    {
      path: `on-demand`, provider: `refetch`, latency: `immediate`, peer: true,
      filter: `joined`, via: `link`, indexed: true, direction: `asc`, nulls: `first`,
      terms: 1, limit: 2, offset: 0,          // the peer Collection uses limit 3
      initial: [
        { id: 9, rank: 0, eligible: true }, { id: 0, rank: 0, eligible: true },
        { id: 1, rank: 0, eligible: true }, { id: 2, rank: 1, eligible: false },
      ],
      history: [
        { type: `upsert`, id: 10, rank: 1, eligible: false },
        { type: `flip`, id: 0 },
        // the step loop then runs the generated `refetch`
      ],
    }

    The random grammar excludes this pairing: withoutPeerRefetch (around line 4680) drops peer: true with provider: 'refetch' and with the new provider: 'refetch-signal'. Remove that filter after the fix, and run the campaigns with more runs (TANSTACK_DB_ORACLE_RUNS_MULTIPLIER=10); replay any counterexample with TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH and TANSTACK_DB_ORACLE_PROPERTY (see packages/db/tests/oracle-config.ts).

    Provider request trace

    I logged each answered request (temporary logging, since removed). sub0/sub1 are Collection A (limit 2) and its Effect; sub2 is the peer Collection B (limit 3):

    REQ sub0 {limit:2, offset:0}                       -> 0@0 1@0
    REQ sub1 {limit:2, offset:0}                       -> 0@0 1@0
    REQ sub0 {where: rank = 0}                         -> 9@0 0@0 1@0
    REQ sub1 {where: rank = 0}                         -> 9@0 0@0 1@0
    REQ sub2 {limit:3, offset:0}                       -> 0@0 1@0 9@0
    REQ sub2 {where: rank = 0}                         -> 9@0 0@0 1@0
    STEP 0 upsert 10@1 (no marker; provider lags)
    STEP 1 flip 0 (join key no longer matches; provider lags)
    STEP 2 refetch
    REQ sub2 {limit:1, offset:3, cursor: rank > 0}     -> 2@1
    REQ sub2 {where: rank = 1}                         -> 2@1 10@1
    (no further request)
    

    Every row at rank 1 fails the join, so B still needs one row. It should continue with cursor: rank > 1, which reaches the rank-100 fillers. It never sends that request.

    Where the loader stops

    Instrumenting OrderedSourceLoader (packages/db/src/query/live/ordered-source-loader.ts) for B's loader shows this sequence for the final tie request:

    continueOrStage boundary defer false
    loadMore enter requesting=true  ...   (×3, during the request)
    settle boundary sync=true inputRevSame=false
    continueOrStage more defer=true
    (no later loadMore call)
    

    What happens:

    1. The tie request (loadBoundary, ~l.925) settles synchronously (canSettleSynchronously, ~l.648).
    2. Its rows change the graph input: row 10 is new to the source. getGraphInputRevision() !== requestGraphInputRevision, so the sync branch (~l.836–845) skips drainNoInputContinuations. complete(undefined, true) stages the more continuation (continueOrStage, ~l.670) for the next graph pass to consume in loadMore (~l.175).
    3. The graph passes that process those rows run inside the request. loadMoreIfNeeded → loadMore returns at the this.requesting guard (l.157).
    4. After the request returns, nothing schedules another pass that calls loadMore. Rows 2 and 10 fail the join and change no window row, so no publication or size change follows. The staged more continuation is never consumed. Nothing proves B's window, and no request follows.

    During the initial load, the same staging path ran with inputRevSame=true. drainNoInputContinuations then consumed the continuation immediately, so the initial load completed.

    This refines the issue's first hypothesis. The loader does decide to continue, but the continuation it stages has no consumer. I did not confirm why a single Collection with limit 3 passes. A likely cause is that its rows reach the graph through a different request, so the tie settles with the input revision unchanged. Check this first: the fix should cover every staged continuation whose graph pass ran under the requesting guard, not just this order of events.

    Fix directions (unverified)

    • After a synchronous settlement that stages a continuation, schedule a graph pass, or call loadMore once requesting clears, when the in-request passes were swallowed by the guard.
    • Or, in the sync branch, consume the staged continuation after the request returns when the input revision changed but the window did not.

    Each must keep the synchronous-cut laws in the ordered-work and pagination oracles green (keeps rows, request traces, batches, errors, and liveness equal).

    Related, possibly same class: query-db refill after a marker removal

    packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts has an it.fails witness, "a current provider refills a joined window after a marker removal (known loader defect)". It expects window [5, 1] but gets [5]. An earlier trace showed that on the final flip loadMore computes needed 1 with boundary 203 and issues loadPage(1, 203). No further request follows. That exact request was already served during an earlier refetch, so query-db answers it from the cached Query result without fetching. The load settles with no new rows, and the refill stops there.

    Both are refills that stop after a request settles without proving the window. Check whether the fix for this issue also turns that it.fails green; if it does, change it to it. That case also involves query-db's cached answers, so it may need its own fix.

    Not fixed by invalidateLoadedSubsets()

    PR #2154 adds a sync-adapter report, invalidateLoadedSubsets(), which triggers each ordered loader's bounded prefix repair. It does not fix this issue: the failing provider in this replay keeps served ranges whole and never reports. The ordered-work oracle's new refetch-signal provider still excludes the peer pairing for the same reason.

    This comment was written by Isaac.

  2. KyleAMathews commented on Oct 15, 2026

    @KyleAMathews
    CollaboratorAuthor

    Update from #2154 (commit e34948bf1).

    The indexed trace now passes. #2154 fixed a loader stall. A Query that Query DB answers synchronously from its cache writes rows that start a graph turn inside the request. That turn's request for more data was ignored, and the staged continuation then waited for a graph turn that never came. With the fix, the pinned replay in packages/db/tests/query/ordered-work-oracle.property.test.ts expects the right peer window. It is renamed "shows a peer's full joined window after a refetch removes a tied row". The fix's comments do not trace why this closed the peer trace. Check that before you rely on it.

    An unindexed trace still fails. With the peer and refetching-provider pairing restored in the ordered-work grammar, random seed -1471487158 fails:

    {"path":"on-demand","provider":"refetch-signal","peer":true,"filter":"joined","via":"marker","indexed":false,"limit":1,…}
    peer rows after step 2: expected [ 100 ] to deeply equal [ 100, 101 ]
    

    It fails the same way without the #2154 loader fix, so it is a separate defect. The grammar still excludes the pairing, and the coverage map names this case.

    To close this issue:

    1. Replay that seed with TANSTACK_DB_ORACLE_SEED=-1471487158.
    2. Fix it, then restore the pairing in the grammar.
    3. Add the oracle extension from Close the remaining #2136-class gaps in the ordered refetch oracles #2186: combine held answers with the refetch-signal provider. The same mixed-provider harness serves both.

    This comment was written by Isaac.

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