fix(#16): page search results by accessible hits #19

Merged
KofTwentyTwo merged 5 commits from feature/GH-16-search-paging into develop 2026-10-07 00:03:29 +00:00
KofTwentyTwo commented 2026-10-06 22:57:09 +00:00 (Migrated from github.com)

Summary

Fixes search paging in QuickSearchAction.

  • Offset semantics: offset skips N results the session can see. Without record locks it is passed straight to OpenSearch (unchanged fast path). With locks, hits are read from position 0 in batches (2x what is still needed, at least doubling the previous batch, capped at 1,000), filtered through QueryAction, the first offset accessible results are skipped and limit are kept. Consecutive pages no longer overlap or skip accessible hits.
  • Per-table mode now honors offset within each table, with the same semantics.
  • Every OpenSearch request keeps from + size within QuickSearchOpenSearchClient.MAX_RESULT_WINDOW (10,000). Accessible results beyond the first 10,000 raw hits are not reachable; this is documented on QuickSearchInput.getOffset(), the action's class comment and the README.
  • With locks on, totalHits counts results confirmed accessible; totalHitsIsLowerBound is true only when the scan stopped before reading every candidate (exact once every hit was read). hasMore is true when an accessible result exists past the page or unread candidates remain.
  • Hits are deduplicated by table and record id within a call, and the batches of one scan share a per-call preference so they hit the same shard copies (no mapping change). A refresh between batches can still move a hit across a batch boundary; dedupe prevents duplicates, but such a hit can be missed.

Work bound (security review)

A single-list search with locks reads at most 10,000 raw hits (MAX_RESULT_WINDOW) in batches of at most 1,000 (MAX_LOCKED_BATCH_SIZE) over at most 32 OpenSearch requests (MAX_LOCKED_ROUND_TRIPS), each followed by one record-lock QueryAction per table in the batch (ids chunked at 1,000).

In per-table mode the T searched tables share that budget: each table gets max(10,000 / T, min(10,000, 2 x limitPerTable)) hits and max(2, 32 / T) requests, and its last allowed request takes the rest of its share. One call therefore reads at most max(10,000, T x 2 x limitPerTable) raw hits over at most max(32, 2T) requests and as many lock queries, never more than develop's per-table cost or one full-window scan. Offset never raises the budget, and a fully locked table cannot starve the others. A spent share ends paging in that table like the result window does: hasMore turns false and totalHitsIsLowerBound stays true, so paging within a table stops past max(10,000 / T, 2 x limitPerTable) raw hits and a client looping while hasMore always terminates. The last allowed request takes up to 1,000 hits of the remaining share (documented in the README and QuickSearchInput.getOffset()).

Testing

  • QuickSearchActionSecurityTest: lock pages do not overlap or skip; offset past the accessible results; per-table offset with and without locks; the 10,000 cap and 1,000 batch cap; a deep offset (4,975) batching; accessible hits found only in later batches; a hit repeated across two batches counted once with an offset; paging per table until hasMore is false terminates; fully locked scans stay within the request and trip bounds with rawFloor reported; a fully locked table with 12,000 hits does not starve a second table; total raw hits across two fully locked tables stay within max(10,000, T x 2 x limitPerTable); exact total when every hit was read.
  • OpenSearchIntegrationTest pagination test extended with a per-table offset check against a real OpenSearch.
  • CI=true ./mvnw verify: 319 unit and 18 integration tests pass, JaCoCo coverage checks met.

Closes #16

## Summary Fixes search paging in `QuickSearchAction`. - Offset semantics: `offset` skips N results the session can see. Without record locks it is passed straight to OpenSearch (unchanged fast path). With locks, hits are read from position 0 in batches (2x what is still needed, at least doubling the previous batch, capped at 1,000), filtered through `QueryAction`, the first `offset` accessible results are skipped and `limit` are kept. Consecutive pages no longer overlap or skip accessible hits. - Per-table mode now honors `offset` within each table, with the same semantics. - Every OpenSearch request keeps `from + size` within `QuickSearchOpenSearchClient.MAX_RESULT_WINDOW` (10,000). Accessible results beyond the first 10,000 raw hits are not reachable; this is documented on `QuickSearchInput.getOffset()`, the action's class comment and the README. - With locks on, `totalHits` counts results confirmed accessible; `totalHitsIsLowerBound` is true only when the scan stopped before reading every candidate (exact once every hit was read). `hasMore` is true when an accessible result exists past the page or unread candidates remain. - Hits are deduplicated by table and record id within a call, and the batches of one scan share a per-call `preference` so they hit the same shard copies (no mapping change). A refresh between batches can still move a hit across a batch boundary; dedupe prevents duplicates, but such a hit can be missed. ## Work bound (security review) A single-list search with locks reads at most 10,000 raw hits (`MAX_RESULT_WINDOW`) in batches of at most 1,000 (`MAX_LOCKED_BATCH_SIZE`) over at most 32 OpenSearch requests (`MAX_LOCKED_ROUND_TRIPS`), each followed by one record-lock `QueryAction` per table in the batch (ids chunked at 1,000). In per-table mode the T searched tables share that budget: each table gets max(10,000 / T, min(10,000, 2 x limitPerTable)) hits and max(2, 32 / T) requests, and its last allowed request takes the rest of its share. One call therefore reads at most max(10,000, T x 2 x limitPerTable) raw hits over at most max(32, 2T) requests and as many lock queries, never more than develop's per-table cost or one full-window scan. Offset never raises the budget, and a fully locked table cannot starve the others. A spent share ends paging in that table like the result window does: `hasMore` turns false and `totalHitsIsLowerBound` stays true, so paging within a table stops past max(10,000 / T, 2 x limitPerTable) raw hits and a client looping while `hasMore` always terminates. The last allowed request takes up to 1,000 hits of the remaining share (documented in the README and `QuickSearchInput.getOffset()`). ## Testing - `QuickSearchActionSecurityTest`: lock pages do not overlap or skip; offset past the accessible results; per-table offset with and without locks; the 10,000 cap and 1,000 batch cap; a deep offset (4,975) batching; accessible hits found only in later batches; a hit repeated across two batches counted once with an offset; paging per table until `hasMore` is false terminates; fully locked scans stay within the request and trip bounds with `rawFloor` reported; a fully locked table with 12,000 hits does not starve a second table; total raw hits across two fully locked tables stay within max(10,000, T x 2 x limitPerTable); exact total when every hit was read. - `OpenSearchIntegrationTest` pagination test extended with a per-table offset check against a real OpenSearch. - `CI=true ./mvnw verify`: 319 unit and 18 integration tests pass, JaCoCo coverage checks met. Closes #16
Sign in to join this conversation.