Skip to content

Ordered loader: recover failed ordered requests with a bounded repair instead of a full-source read #2165

Open

Description

@KyleAMathews

Problem

When an ordered request fails, the ordered loader recovers by reading the full source. That happens after a failed page, tie, or bounded-repair request, including a synchronous throw or a failed boundary read, and the automatic retries at 250 ms and 500 ms do the same. Full-source reads can be very expensive. Each plan that can continue from a provider prefix (!requiresFullSource) has a cheaper option: a bounded ordered-prefix repair from the start of the order. That repair sets its boundary from its own settled requests, so partial writes from the failed request cannot mislead it.

Rule (maintainer decision): avoid full-source reads whenever a bounded read is available.

PR #2154 applies this rule to an ordering invalidation that overlaps an in-flight page or tie request. It leaves failure recovery unchanged. These paths still read the full source:

  • recovery after a failed or canceled request;
  • an ordering invalidation that arrives before any request has settled.

What a first attempt showed

A prototype replaced requireFullSourceRepair() with a bounded repair on failure. It also retried the bounded repair in the automatic retries, and let a newer ordering invalidation retry once. Across five test files, 48 pinned expectations then failed. Some of them were real regressions:

  • pagination-oracle.property.test.ts, "recovers a failed tie boundary …": the window was [1, 4], expected [1, 2].
  • ordered-source-loader-oracle.test.ts, "keeps the 'boundary' acquisition lifecycle exact for reject/abort": after the tie request failed, the original page acquisition was not released.
  • pagination-oracle.property.test.ts, the pending-history campaigns and their captured-history controls: logical settlement did not match the model.

The failure path re-enters the bounded chain in states it was not built for, for example a failed request that overlaps a repair (the abandonOrderedPrefixRepair path).

Work

  1. In the ordered-source-loader, loader-state and pagination oracles, state the bounded failure-recovery law first:
    • recovery shapes;
    • the lease release of failed and superseded acquisitions;
    • the retry budget;
    • retry after a newer invalidation.
  2. Then change OrderedSourceLoader: fail, failRequest, finishOrderedPrefixRepair, abandonOrderedPrefixRepair, scheduleRepairRetry, and the retry gate in loadMore.
  3. Update "Ordered requests, continuation, and recovery" in packages/db/src/query/live/ARCHITECTURE.md.

Related: #2136, #2154. The coverage map backlog entry is "Bounded recovery after a failed ordered request".

Activity

  1. KyleAMathews commented on Nov 4, 2026

    @KyleAMathews
    CollaboratorAuthor

    Additional receiving-test evidence on main at 8feab6ea3eaae4456f738da39fa93508ddd5cc01, using the real Query Collection + createCursorPager + live-query setWindow path.

    Measured backend work

    The source uses syncMode: 'on-demand', autoIndex: 'eager', and BasicIndex. Rows have unique ascending numeric ranks; backend pages contain 10 rows. TanStack Query retries are disabled. The failed page throws before returning or writing any rows.

    Each case first loads 10 rows, then requests a 20-row window. Counts below exclude the initial load and, in failure cases, the failed attempt itself. Counts include boundary-equality probes and, for the reload case, reload work.

    Backend size Path to the 20-row window Backend calls Rows returned by backend Final source size
    100 No failure 2 11 20
    100 Fail expansion, then retry setWindow directly 10 100 100
    100 Fail expansion, await live.reload(), then expand again 3 21 20
    1,000 No failure 2 11 20
    1,000 Fail expansion, then retry setWindow directly 100 1,000 1,000
    1,000 Fail expansion, await live.reload(), then expand again 3 21 20

    All six cases return the correct first 20 rows. After each failed expansion, both the source and visible result still contain only the original 10 rows. Direct retry emits a main-sequence subset request with no limit and the cursor pager exhausts the backend. The successful and reload-then-expand controls do not emit an unlimited main-sequence request. Equality probes are counted separately from that check.

    Reproduction shape

    await live.preload() // orderBy(rank).limit(10)
    failNextPage = true
    await expect(
      live.utils.setWindow({ offset: 0, limit: 20 }),
    ).rejects.toBe(failure)
    
    expect(source.size).toBe(10)
    expect(live.toArray).toHaveLength(10)
    
    // Direct retry: 100 backend calls / 1,000 rows for a 20-row result.
    await live.utils.setWindow({ offset: 0, limit: 20 })
    
    // In a separate fresh fixture, the alternative is bounded:
    // await live.reload() // restores the last settled 10-row window
    // await live.utils.setWindow({ offset: 0, limit: 20 })
    // => 3 calls / 21 returned rows, source.size === 20

    The fixture passes loadSubsetOptions.offset/limit to pager.read(window, context.signal); it serves rank-equality boundary probes exactly. The backend continuation token encodes the next offset. This is an in-memory transport measurement, not a network-latency or database-scan benchmark.

    Interpretation and limits

    This confirms a practical amplification of the documented full-source recovery contract: a single failed continuation can make subsequent direct retry scale with total dataset size rather than the requested window, even when that particular failure wrote nothing.

    It does not establish that bounded repair is safe for generic providers. Partial writes, tied ranks, overlapping invalidations, lease ownership, and automatic repair retries still need the oracle-first work described above. These are fixed receiving examples, not a new recovery law or a production fix.

    The reload control is important: awaited reload followed by expansion stays bounded in this fixture. We encountered this while investigating a real app's reload-based pagination retry, but this test does not reproduce that app's timing-sensitive failure or establish that it has the same cause.

    Validation: six focused runtime tests pass, file ESLint and pnpm lint:changed pass. No production source changes.

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