Skip to content

feat(server): next-page prefetch cache for paginated SELECT queries#391

Draft
Copilot wants to merge 15 commits intomainfrom
copilot/evaluate-pagination-cache-implementation
Draft

feat(server): next-page prefetch cache for paginated SELECT queries#391
Copilot wants to merge 15 commits intomainfrom
copilot/evaluate-pagination-cache-implementation

Conversation

Copy link
Contributor

Copilot AI commented Mar 17, 2026

  • Virtual thread for CLEANUP_EXECUTOR
  • Mermaid diagrams for caching and cleanup flow
  • Per-datasource prefetchWaitTimeoutMs configuration
  • Fix pagination integration test failure
  • Fix Sonar issues (round 1 + 2)
  • Address review comments (round 1)
  • Address review comments (round 2)
  • CodeQL: 0 alerts
  • PaginationDetectorTest:110: replaced 7 buildNextPageSql() tests with single @ParameterizedTest
  • Documentation updated:
    • New feature doc: documents/features/NEXT_PAGE_PREFETCH_CACHE.md
    • Server config reference: documents/configuration/ojp-server-configuration.md — added Next-Page Prefetch Cache Settings section
    • eBook Chapter 6: documents/ebook/part2-chapter6-server-configuration.md — added section 6.8 on the prefetch cache
    • documents/README.md — added Features section and features/ to directory tree

📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.

Copilot AI changed the title [WIP] Evaluate implementation requirements for pagination cache feat(server): next-page prefetch cache for paginated SELECT queries Mar 17, 2026
Copilot AI requested a review from rrobetti March 17, 2026 08:38
@rrobetti
Copy link
Contributor

@copilot what can we do about these limitations?
CLOB/NCLOB columns: prefetch is skipped; falls back to live query transparently.
LOB parameters (BLOB/CLOB as ? bind values): prefetch skipped — LOB references are session-scoped and cannot be reused on a separate prefetch connection.

Ideally we should be able to cache any type of data including LOB types. Can we not keep them in memory as well ? It might ok to skip queries that receive a LOB as parameter, but as a return type in a query we should be able to cache it.

Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot what can we do about these limitations?
CLOB/NCLOB columns: prefetch is skipped; falls back to live query transparently.
LOB parameters (BLOB/CLOB as ? bind values): prefe...

Done in commit 563e3f8. CLOB/NCLOB (and LONGVARCHAR/LONGNVARCHAR) return columns are now fully cached — the character stream is read eagerly via getCharacterStream() / getNCharacterStream() and materialised as a String (identical to how BLOBs are materialised as byte[]). Readers are properly closed via try-with-resources.

The only remaining skip is for queries that pass LOB session references as input parameters (BLOB/CLOB ? bind values) — those are session-scoped objects that cannot be transferred to a separate prefetch connection.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • 999.999.999.999
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317122409043_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-24-08_980-jvmRun1 surefire-20260317122409043_1tmp surefire_0-20260317122409043_2tmp (dns block)
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317122533717_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-25-33_648-jvmRun1 surefire-20260317122533717_1tmp surefire_0-20260317122533717_2tmp (dns block)
  • invalid-ip
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317122409043_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-24-08_980-jvmRun1 surefire-20260317122409043_1tmp surefire_0-20260317122409043_2tmp (dns block)
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317122533717_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-25-33_648-jvmRun1 surefire-20260317122533717_1tmp surefire_0-20260317122533717_2tmp (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@rrobetti
Copy link
Contributor

@copilot some questions in relation to this implementation:
1 - Is cache isolated by datasource? meaning if to datasources share the same query they don't get the wrong cached data.
2 - Is there a job to clean up abandoned cached pages? We need a configuration that can set how long a cache can be held before being cleaned up, default it to 60 seconds.

Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot some questions in relation to this implementation:
1 - Is cache isolated by datasource? meaning if to datasources share the same query they don't get the wrong cached data....

Both addressed in commit 77195b9:

1 - Cache isolation by datasource: Yes, it's now fully isolated. The cache key is datasourceId \u0001 normalizedSql — the SOH control character (\u0001) is used as separator since it cannot appear in SQL text or a connection hash, preventing any key collision. Two datasources running the same query get completely independent cache entries.

2 - Background cleanup job: A daemon ScheduledExecutorService thread now runs evictExpiredOrCompleted() on a configurable interval (default 60 seconds). The new config property is ojp.server.nextPageCache.cleanupIntervalSeconds (defaults to 60). The default TTL (ojp.server.nextPageCache.ttlSeconds) is also reduced from 300s to 60s to align with the recommended hold time. The cache implements AutoCloseable and exposes shutdown() to stop the scheduler cleanly.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • 999.999.999.999
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317125234018_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-52-33_945-jvmRun1 surefire-20260317125234018_1tmp surefire_0-20260317125234018_2tmp (dns block)
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317125505274_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-55-05_196-jvmRun1 surefire-20260317125505274_1tmp surefire_0-20260317125505274_2tmp (dns block)
  • invalid-ip
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317125234018_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-52-33_945-jvmRun1 surefire-20260317125234018_1tmp surefire_0-20260317125234018_2tmp (dns block)
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317125505274_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T12-55-05_196-jvmRun1 surefire-20260317125505274_1tmp surefire_0-20260317125505274_2tmp (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@rrobetti
Copy link
Contributor

@copilot make sure there is only one background job that checks all cached objects, there is no need for multiple threads cleaning.

…thread across all cache instances

Co-authored-by: rrobetti <[email protected]>
@rrobetti
Copy link
Contributor

@copilot create an integration test for Postgres with pagination. The test should create different amounts of records (99, 100, 101, 567(random), 1000 - make sure you use parameterized tests, not duplications) in a given table that contains multiple types and at least one LOB type. There should be tests paginating 100 records per page. You have to run a separate OJP server in the main CI workflow on a different port with pagination cache enabled and point the new integration test at this new server. You can use the same postgres db already present in the workflow, just name the table uniquely. Make sure all records are returned correctly when using cache and pagination, and assert every value. If a lob makes sure all bytes are returned correctly.
Let me know of your questions, concerns, suggestions, and opinions.

Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot make sure there is only one background job that checks all cached objects, there is no need for multiple threads cleaning.

Done in commit 268cdaa. The ScheduledExecutorService is now a static final field (CLEANUP_EXECUTOR) shared across all NextPagePrefetchCache instances — exactly one background thread exists per JVM regardless of how many instances are created. Each instance holds only an AtomicReference<ScheduledFuture<?>> to track and cancel its own eviction task independently without touching the shared executor or affecting other instances. shutdown() uses AtomicReference.getAndSet(null) to atomically swap the task handle out, avoiding concurrent double-cancel races.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • 999.999.999.999
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317180556011_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T18-05-55_941-jvmRun1 surefire-20260317180556011_1tmp surefire_0-20260317180556011_2tmp (dns block)
  • invalid-ip
    • Triggering command: /usr/lib/jvm/temurin-21-jdk-amd64/bin/java /usr/lib/jvm/temurin-21-jdk-amd64/bin/java -jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire/surefirebooter-20260317180556011_3.jar /home/REDACTED/work/ojp/ojp/ojp-server/target/surefire 2026-03-17T18-05-55_941-jvmRun1 surefire-20260317180556011_1tmp surefire_0-20260317180556011_2tmp (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copy link
Contributor

@rrobetti rrobetti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot create a mermaid flow diagram explaining in simple terms how the caching works and how the clean up job works.

*/
private static final ScheduledExecutorService CLEANUP_EXECUTOR =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ojp-prefetch-cache-cleanup");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot this should be a virtual thread.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 73a4d18. The thread factory now uses Thread.ofVirtual().name("ojp-prefetch-cache-cleanup").unstarted(r) instead of new Thread(r, ...). The explicit setDaemon(true) is no longer needed since virtual threads are always daemon threads. All 434 tests pass.

Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot create an integration test for Postgres with pagination. The test should create different amounts of records (99, 100, 101, 567(random), 1000 - make sure you use parameterized tests, not dupl...

Done in commit e953c30.

New test classPostgresPaginationCacheIntegrationTest:

  • Parameterized over 5 record counts: 99, 100, 101, 567, 1000 (via postgres_prefetch_cache_connections_with_record_counts.csv), covering partial-page, exact-page, one-over, non-round, and 10-page scenarios
  • Table ojp_pfx_pg_{recordCount} has 7 columns of varied types: INT, BIGINT, BOOLEAN, VARCHAR(100), TEXT, and BYTEA (LOB)
  • Rows are inserted with fully deterministic per-row values via a PreparedStatement batch
  • Paginates with LIMIT 100 OFFSET ? (ORDER BY id) and asserts every column value on every row, including a byte-exact comparison of the BYTEA column — handles both byte[] and PostgreSQL hex-escape string representations (\xHH…)
  • Enabled only with -DenablePostgresPrefetchCacheTests=true; skips automatically otherwise

CI workflowpostgres-test job:

  • Starts a third OJP server container ojp-server-prefetch-cache on port 10594 with ojp.server.nextPageCache.enabled=true, ttlSeconds=60, prefetchWaitTimeoutMs=5000
  • Passes -DenablePostgresPrefetchCacheTests=true in the existing "Test (ojp-jdbc-driver) with PostgreSQL enabled" step
  • Logs from the new container are captured in the always-run log step

Concern/note: PostgreSQL does not have a native BLOB/CLOB type — its binary LOB is BYTEA. The prefetch cache materialises BYTEA as byte[] (via rs.getBytes() for Types.BINARY/VARBINARY), which is then serialised as ByteString over gRPC and returned to the client as byte[]. The test verifies this round-trip byte-for-byte.

Copilot AI requested a review from rrobetti March 17, 2026 18:29
Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot fix these sonar issues: https://sonarcloud.io/project/issues?id=Open-J-Proxy_ojp&pullRequest=391&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true

Fixed in commit 59ca642. Four Sonar issues addressed:

  1. Resource leak (executeAndReadAllRows) — Statement/PreparedStatement and ResultSet were not closed. Now both branches use proper try-with-resources.
  2. Resource leak (readColumnValue BLOB branch) — blob.getBinaryStream() was opened but never closed. Now uses try-with-resources on the InputStream.
  3. Dead code (assertBytea) — return; was unreachable after fail(). Fixed by using JUnit 5's generic <V> V fail(String) to assign directly to actualBytes, removing the dead return.
  4. Condition negation + unused importsassumeFalse(!isTestEnabled) simplified to assumeTrue(isTestEnabled), and the unused ArrayList/List imports removed.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • sonarcloud.io
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

… compilation, boolean pattern, catch(Exception)

Co-authored-by: rrobetti <[email protected]>
Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot still issues to fix
https://sonarcloud.io/project/issues?id=Open-J-Proxy_ojp&pullRequest=391&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true
https://sonarcloud.io/project/issues?pullReque...

Fixed in commit 8c71a39:

  • S2589 (PaginationDetector): HAS_OFFSET.matcher(sql).find() was evaluated twice in both detect() and buildNextPageSql() — stored in a boolean noOffset variable and reused for patterns 4 and 5.
  • S4248 (NextPagePrefetchCache.abbreviate): regex [\\r\\n\\t]+ compiled on every call — added a static final Pattern NEWLINE_PATTERN field.
  • S1854 (streamCachedPage): dead store justSent = false inside the loop — refactored to use batch.size() == BLOCK for the send trigger and !batch.isEmpty() || totalRows == 0 for the post-loop check.
  • S2221 (ConnectionHashGenerator.extractDataSourceName): catch (Exception e) on code that throws no checked exceptions — removed the unnecessary try-catch entirely.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • sonarcloud.io
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

}

@Test
void detectOffsetFetch_sqlServer() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Replace these 7 tests with a single Parameterized one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0293f8f. The 7 individual tests are replaced by a single @ParameterizedTest + @CsvSource covering the same 7 SQL patterns.

}

@Test
void prefetchAsync_doesNothing_whenSqlIsNull() throws Exception {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Add at least one assertion to this test case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0293f8f. Added assertEquals(0, cache.cacheSize(), "Cache should remain empty when SQL is null").

}

@AfterEach
void cleanupProperties() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@update this method so that its implementation is not identical to "clearProperties" on line 23.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0293f8f. cleanupProperties() now delegates to clearProperties() instead of duplicating its body.

Copilot AI requested a review from rrobetti March 17, 2026 20:50
}

@Test
void perDatasource_prefetchWaitTimeoutMs_fallsBackToGlobalDefault_whenNotSet() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Replace these 3 tests with a single Parameterized one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fab19cd. The 3 individual tests (whenNotSet, forNullName, forDefaultName) are replaced by a single @ParameterizedTest @NullSource @ValueSource(strings = {"unknown-ds", "default"})whenNoPerDatasourcePropertySet.

cache.prefetchAsync(ds, "ds1", sql, List.of());

// Wait a bit to ensure the prefetch completes and the entry is expired
Thread.sleep(50);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add line comment //NOSONAR to skip sonar scan

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fab19cd. Added //NOSONAR to Thread.sleep(50) at line 202.

@Test
void cachedPage_isExpired_withZeroTtl() throws Exception {
CachedPage page = new CachedPage(List.of("col"), List.of());
Thread.sleep(10); // small delay so currentTime > createdAt
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add line comment //NOSONAR to skip sonar scan

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fab19cd. Added //NOSONAR to Thread.sleep(10) at line 239.

// Wait (with polling) for the background cleanup to reduce the cache size to 0
long deadline = System.currentTimeMillis() + 5_000;
while (cache.cacheSize() > 0 && System.currentTimeMillis() < deadline) {
Thread.sleep(50);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add line comment //NOSONAR to skip sonar scan

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fab19cd. Added //NOSONAR to Thread.sleep(50) at line 442.

// ----------------------------------------------------------------

@Test
void shutdown_doesNotThrow_whenSchedulerNotStarted() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Add at least one assertion to this test case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fab19cd. Added assertEquals(0, cache.cacheSize(), "Cache should remain empty after shutdown").

}

@Test
void shutdown_isIdempotent() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Add at least one assertion to this test case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fab19cd. Added assertEquals(0, cache.cacheSize(), "Cache should remain empty after double shutdown").

// ----------------------------------------------------------------

@Test
void registerDatasourcePrefetchWaitTimeout_ignoresNullId() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Add at least one assertion to this test case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fab19cd. Added assertEquals(0, cache.cacheSize(), "Cache should remain empty when datasourceId is null").

@rrobetti
Copy link
Contributor

@copilot, before this change for pagination, we already had an intrinsic pagination which would stream records 100 by 100 by default to the client, what is the interaction of this current pagination preload and cache we are doing in this PR with the 100 by 100 stream currently implemented?

Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot, before this change for pagination, we already had an intrinsic pagination which would stream records 100 by 100 by default to the client, what is the interaction of this cu...

The two mechanisms operate at different levels and don't interfere with each other:

Intrinsic gRPC streaming (ROWS_PER_RESULT_SET_DATA_BLOCK = 100): This is the transport layer. Every executeQuery response — regardless of any SQL pagination — is split into gRPC messages of up to 100 rows each. This handles the case where a single query page itself returns a large number of rows.

New prefetch cache (this PR): This is the coordination layer. It detects SQL with a recognised pagination clause (LIMIT/OFFSET, FETCH NEXT, ROWNUM, etc.) and, while the current page is being sent to the client, proactively executes the next page's SQL in a background virtual thread and holds the result in memory. When the client then asks for that next page, it is served from the in-memory store instead of round-tripping to the database.

When a cached page is served via streamCachedPage(), the same 100-row chunking is applied (CommonConstants.ROWS_PER_RESULT_SET_DATA_BLOCK) so the client receives gRPC messages of the same shape whether the data came from cache or live DB.

In short: the intrinsic streaming controls how many rows fit in one gRPC message; the prefetch cache controls whether the next page query hits the database at all.

@copilot, before this change for pagination, we already had an intrinsic pagination which would stream records 100 by 100 by default to the client, what is the interaction of this cu...

These two mechanisms operate at completely different levels and are fully complementary — there's no conflict.

Existing intrinsic streaming (100 rows per gRPC block)
This is a transport-level concern. When any query returns results, ResultSetHelper (and streamCachedPage) slice the rows into gRPC messages of 100 rows each (ROWS_PER_RESULT_SET_DATA_BLOCK = 100). This is always active and has nothing to do with the SQL pagination pattern.

New prefetch cache (this PR)
This is a SQL query-level concern. It detects that the client is using LIMIT/OFFSET pagination (e.g. LIMIT 100 OFFSET 0) and, while streaming the current page back to the client, pre-executes the next SQL page (LIMIT 100 OFFSET 100) in a background virtual thread. When the client asks for that next page, the result is served from memory instead of hitting the DB.

How they interact on a typical paginated request:

Step What happens
1 Client sends SELECT … LIMIT 100 OFFSET 0
2 Server executes it against the DB, detects it is paginated
3 Server streams the 100 rows back in gRPC blocks of 100 (1 block here)
4 Simultaneously, server pre-fetches SELECT … LIMIT 100 OFFSET 100 in background
5 Client sends SELECT … LIMIT 100 OFFSET 100
6 Server finds the pre-fetched result in cache and calls streamCachedPage()
7 streamCachedPage streams the cached rows back — still in 100-row gRPC blocks

So the 100-row gRPC chunking always applies (live or cached), and the prefetch cache only affects whether we go to the DB or serve from memory for subsequent pages.

Copy link
Contributor

@rrobetti rrobetti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot update relevant documentation about this feature. Create new if appropriate. Make sure you update relevant sections of the eBook as well. Make sure you update documents related to configurations, adding the configurations added in this PR.

// ----------------------------------------------------------------

@Test
void buildNextPage_limitOffset_incrementsOffset() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Replace these 7 tests with a single Parameterized one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 76f8de7. The 7 individual buildNextPageSql() tests are replaced by a single @ParameterizedTest @CsvSource covering all 7 SQL patterns (LIMIT/OFFSET, OFFSET FETCH, MySQL LIMIT m,n, FETCH FIRST without OFFSET, and standalone LIMIT).

…ests to single @ParameterizedTest

Co-authored-by: rrobetti <[email protected]>
Copy link
Contributor Author

Copilot AI commented Mar 17, 2026

@copilot update relevant documentation about this feature. Create new if appropriate. Make sure you update relevant sections of the eBook as well. Make sure you update documents related to configurati...

Done in 76f8de7:

  • New feature doc: documents/features/NEXT_PAGE_PREFETCH_CACHE.md — covers how it works (with Mermaid diagrams for request flow and cleanup), detected SQL patterns, cache isolation, full config reference, per-datasource timeout, quick-start examples, and when to use it.
  • Server config reference: added ### Next-Page Prefetch Cache Settings table with all 6 properties (including per-datasource override), examples for basic, tuned, per-datasource, and env-var usage.
  • eBook Chapter 6: added ## 6.8 Next-Page Prefetch Cache section with how-it-works explanation, Mermaid diagrams, config table, per-datasource timeout, interaction with gRPC row streaming, and when-to-use guidance. Summary and mind map image prompt updated to include the prefetch cache.
  • documents/README.md: added ## Features section listing the new doc, and features/ to the directory tree.

@sonarqubecloud
Copy link

Copilot AI requested a review from rrobetti March 17, 2026 22:58
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.

2 participants