Skip to content

feat: distributed cursor loadMore() + real MCP pagination + docs sweep — Query Phase 4 (SPEC-306a/b/c)#48

Merged
ivkan merged 11 commits into
mainfrom
sf-443-query-phase4
Jun 11, 2026
Merged

feat: distributed cursor loadMore() + real MCP pagination + docs sweep — Query Phase 4 (SPEC-306a/b/c)#48
ivkan merged 11 commits into
mainfrom
sf-443-query-phase4

Conversation

@ivkan

@ivkan ivkan commented Jun 11, 2026

Copy link
Copy Markdown
Member

Summary

Query Engine Consolidation Phase 4 (from TODO-443, executed as SPEC-306a/b/c) — closes the
milestone. Exposes the distributed cursor end-to-end with an ergonomic loadMore() surface,
makes MCP pagination real, and brings docs in line with behavior.

Changes

306a — server (Rust): accurate distributed cursor — query.rs emits next_cursor +
cursor_status (valid/expired/invalid/none), limit+1 sentinel, DistributedQueryResult; cursor
helpers in query/cursor.rs; coordinator/converter wiring; distributed sim tests.

306b — client / react / mcp-server (TS): QueryHandle.loadMore() (append-only merge) +
queryOncePaged() returning { items, cursor, hasMore }; memoized useQuery loadMore; MCP
topgun_query real cursor (removes the +1 probe, adds a cursor arg) + behavioral/concurrency
tests.

306c — docs: doc-vs-behavior sweep (TODO-438 exit gate) — client.mdx leads with loadMore(),
opt-in DataFusion SQL section, mcp.mdx documents the real cursor/hasMore; llms-full.txt
regenerated. Docs-only, zero Rust logic (default = ["redb"] unchanged).

Verification

Each sub-spec reviewed via the SpecFlow impl-review gate (306c clean APPROVED 0/0/0). Spans Rust +
TS + docs → CI gates: Rust (cargo test + sim + clippy -D warnings + fmt) AND
Build · Test · Lint (client/react/mcp-server) AND docs-astro build.

ivkan added 11 commits June 11, 2026 13:43
…QueryResult

- cursor.rs: add cursor_query_hashes (single authoritative hash source called by
  both consume-side and emit-side) and build_next_cursor (hash-source-agnostic
  single emission helper)
- converter.rs: refactor build_cursor_vertex_config to call cursor_query_hashes
  (removes private hash_debug closure); write limit+1 into DAG limit-vertex config
  so the limit+1 sentinel flows to the emission site
- coordinator.rs: introduce #[derive(Debug)] DistributedQueryResult { rows, has_more };
  apply_global_sort_and_limit fetches limit+1 globally, absorbs sentinel, truncates
  to limit, returns struct; execute_distributed and combine_group_by_results now
  return DistributedQueryResult; migrate all 5 coordinator-internal test sites to
  .rows/.has_more; add absorb_sentinel helper for single-node bypass
- http_sync.rs: refactor inline cursor-encode to call build_next_cursor (single
  emission path, zero duplication)
- query.rs: wire coordinator branch to DistributedQueryResult; capture
  cursor_query_hashes + query_limit + sort_values_template before query move;
  add coordinator_has_more + local_has_more tracking; local/predicate branch
  detects has_more via limit+1 sentinel and truncates; reconcile has_more before
  max_query_records clamp; emit real next_cursor via build_next_cursor; set
  cursor_status from input cursor presence
…m tests

- query.rs::handle_query_subscribe: capture cursor_query_hashes before
  query is moved; coordinator branch reads dist_result.rows/has_more;
  local DAG branch detects has_more from limit+1 sentinel and truncates;
  call build_next_cursor as single emission site; emit cursor_status
- sim/cluster.rs: migrate 2 existing distributed tests to .rows/.has_more;
  add paged_ws_query_under_partition_emits_cursor_and_correct_follow_up
  (2-node, proper partition-aware data distribution, fault injection);
  add single_node_cursor_roundtrip_has_more_flips_at_exhaustion (AC4 hash-match)
- cargo fmt: apply formatting to converter.rs, coordinator.rs, http_sync.rs

All 10 sim tests pass; 1293 lib tests pass; clippy clean; fmt clean.
AC7: no next_cursor: None remains in query.rs.
- Add classify_cursor_status helper in query/cursor.rs that reuses the same
  expiry + hash checks the DAG CursorProcessor applies, against the query's
  cursor_query_hashes — so the emitted status agrees with the DAG accept/reject
  decision by construction (deterministic, not inferred from an empty result page)
- query.rs::handle_query_subscribe decodes the input cursor before the move and
  reports None/Expired/Invalid/Valid via the helper instead of always Valid
- Add 4 unit tests for classify_cursor_status (none/valid/invalid/expiry-precedence)

Closes the AC2 gap: clients can now distinguish a stale pagination token
(restart) from genuine exhaustion.
- Add private mergePageResults() that only adds/updates keys, never removes
  existing keys — preserves results from prior pages absent in the new batch
- Add public loadMore() that reads nextCursor/hasMore from _paginationInfo,
  creates a temporary one-shot QueryHandle with the cursor, waits for settlement,
  then merges results via mergePageResults() and advances pagination state
- Add _loadMoreInFlight string latch to deduplicate concurrent calls for the
  same cursor token
- Add QueryOncePagedResult<T> interface exported from TopGunClient and index.ts
- Add queryOncePaged() with typed overloads mirroring queryOnce pattern;
  after settlement reads handle.getPaginationInfo() and returns paged result
- Add handleUnsettledPaged() private helper for offline/timeout error handling
- queryOnce return type and behavior are left entirely unchanged
…oncurrency guard

- QueryHandle.test.ts: add loadMore() describe block with 4 tests:
  - appends page-2 rows without pruning page-1 rows (disjoint keys)
  - is a no-op when hasMore is false (no duplicate subscribeToQuery)
  - concurrent calls are deduplicated via in-flight latch (only 1 temp handle)
  - advances paginationInfo to the new page cursor after successful load
- QueryOnce.test.ts: add queryOncePaged describe block with 7 tests:
  - resolves to { items, cursor, hasMore } from server response
  - hasMore false and cursor undefined when no further pages
  - auto-unsubscribes after resolving
  - default offline throws QueryOnceUnsettledError
  - default timeout throws QueryOnceUnsettledError
  - allowLocal offline throws QueryOnceLocalError carrying local snapshot
- Add loadMore(): Promise<void> to UseQueryResult<T> interface
- Implement useCallback-memoized loadMore delegating to handleRef.current
- Guard: no-op when hasMore is false or handle is null (returns Promise.resolve())
- Add loadMore to useMemo return with paginationInfo.hasMore in dep array
- Add mock loadMore jest.fn() to the shared QueryHandle mock
- Test: loadMore() delegates to handle.loadMore() when hasMore is true
- Test: loadMore() is a no-op when hasMore is false
- Test: loadMore ref is stable across re-renders when hasMore stays true
- Fix: apply prettier formatting to mcp-server query.ts (pre-existing format drift)
…sor arg)

- Replace queryOnce +1 probe trick with queryOncePaged returning server-authoritative hasMore + cursor
- Add optional cursor field to QueryArgsSchema and toolSchemas.query JSON schema
- Thread cursor through queryFilter.cursor when provided by agent
- Surface real continuation cursor token in tool result when hasMore is true
- Replace "no cursor to page through" note with actionable next-page cursor instruction
- Keep narrow-query guidance alongside the cursor info
…ransition

Locks in correct useCallback semantics: callback identity must change when
hasMore flips so the new closure observes hasMore:true and delegates rather
than staying a stale no-op.
Rewrites the stale cursor-pagination example in client.mdx to the shipped
loadMore() / paged-query API (SPEC-306b), adds loadMore() to QueryHandle<T>
method list, documents client.sql() as opt-in/server-feature-gated with the
joins/window/HAVING/DISTINCT niche, and corrects mcp.mdx topgun_query to
reflect the real cursor parameter + hasMore continuation note.

Doc-vs-behavior sweep checklist (TODO-438):
[x] client.mdx loadMore() — ships in QueryHandle.ts:446
[x] client.mdx getPaginationInfo() — ships in QueryHandle.ts
[x] client.mdx cursor example uses sort (required for keyset cursor build)
[x] client.mdx sql() opt-in/OFF-by-default/niche — ships in TopGunClient.ts:1070 + SqlClient.ts
[x] client.mdx sql() niche (joins/window/HAVING/DISTINCT, group-by on DAG) — matches #[cfg(feature="datafusion")]
[x] mcp.mdx topgun_query cursor arg — matches query.ts:34 (QueryArgsSchema)
[x] mcp.mdx hasMore continuation note — matches query.ts:139-147 (continuationNote)
[x] mcp.mdx cursor in parameters list — matches QueryArgsSchema
[x] Cargo.toml default=["redb"] unchanged — confirmed at line 12
[x] datafusion NOT in default features — confirmed (optional only)
[x] README — no update needed (no query feature matrix exists)

AC2 execution (cursor pagination against local server with 306a binary):
  Server: topgun-server release (built from 306a commits)
  12 records in 'items' map, limit=4, sort={score:'asc'}
  Page 1: items=[item-001..004], hasMore=true, cursor=YES
  Page 2: items=[item-005..008], hasMore=true, cursor=YES (via p1.cursor)
  Page 3: items=[item-009..012], hasMore=false (last page)
  Overlap between pages: 0
  loadMore() test (10 records, limit=3):
    After settle: 3 items, hasMore=true
    After loadMore(): 6 items (append-only merge, no duplicates)
    After 2nd loadMore(): 9 items — multiple calls work
  Demo server (demo.topgun.build): WebSocket non-101 from Node.js environment
  (browser connections work; verified via /health endpoint returning ok)

Cargo.toml not touched (verify-only — constraint confirmed correct).
@ivkan ivkan merged commit b90acdf into main Jun 11, 2026
13 checks passed
@ivkan ivkan deleted the sf-443-query-phase4 branch June 11, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant