Skip to main content

Continuous Page Fetch

Design status

This page records an agreed design direction. The proposed instruction is not a promise that every TeaQL runtime already implements it. Check the runtime release notes before using it.

Deep offset pagination becomes progressively more expensive even when a person is simply reading older records in order:

SELECT ...
FROM customer_order
WHERE <active_filter>
ORDER BY id DESC
OFFSET 1000000
LIMIT 20;

Continuous page fetch keeps the existing page execution API and response shape. It adds only an explicit request instruction, provisionally named:

.optimizeForContinuousPageFetch()

The client still supplies the same offset and page size. The runtime remembers a short-lived server-side Cursor and transparently replaces a continuous deep offset with a seek predicate when it is safe to do so.

This is deliberately a browsing optimization. It does not promise snapshot pagination. If matching data changes between requests, adjacent pages can contain a small overlap or gap.

The ideal case

The most useful case is a recent-first list ordered by a unique, non-null, indexed ID:

ORDER BY id DESC

After returning the first 20 rows, the runtime remembers the last ID, for example 987600. A continuous request for offset 20 can use:

SELECT ...
FROM customer_order
WHERE <active_filter>
AND id < :last_id
ORDER BY id DESC
LIMIT 20;

For ascending order, the seek predicate is id > :last_id. The cost of reading the list can remain nearly constant as the person moves deeper into history. No additional count is implied. Without an explicit count instruction, the reported record count remains the length of the returned list.

Prefer browsing without an exact total

Continuous browsing usually does not need to say “there are 8,731,492 records.” Removing that requirement is an important part of the fast path. The seek query can read one small indexed page at nearly constant cost, while an exact count may still examine the complete matching data set. In that case pagination is fast, but the response remains slow because the explicitly requested count dominates the latency.

For a browsing interface, prefer this behavior:

  • request only the next list window;
  • keep offering next while a full page is returned;
  • stop when the returned list is shorter than the requested page size, or empty;
  • refresh from the first page when the user wants to see newly arrived records;
  • do not display an exact total unless it serves a real product requirement.

This requires no extra count query and no new hasMore query. It may allow one final next-page request that returns an empty list, which is normally a better trade-off than counting a very large result set on every browsing request.

An explicit count instruction remains valid and independent. Continuous page fetch can still optimize the list SQL, and an accepted aggregation-result cache can separately optimize the count where its staleness contract is appropriate. Neither optimization should make an exact total the default for browsing.

When to use it

Good candidates include:

  • order history and recent-order browsing;
  • audit, activity, message, and operational event lists;
  • append-oriented records whose IDs increase over time;
  • a user repeatedly moving forward through the same filter and ordering;
  • screens where refresh is an acceptable way to reconcile concurrent changes.
  • interfaces that can omit an exact total and finish on a short or empty page.

Do not enable it for:

  • code that performs a mutation for every row;
  • settlement, reconciliation, exact export, or batch processing;
  • approval, eligibility, compliance, permission, or financial decisions;
  • pagination that must visit every matching row exactly once;
  • non-deterministic ordering or a nullable/non-unique ordering field;
  • grouped, aggregated, or per-parent nested queries in the first version.

Request and execution contract

The optimization does not introduce executeForCursorPage or any other new execution method. The generated request keeps its existing page/list execution. The instruction can appear while the request is being built; comment still comes before purpose, and purpose still exposes execution:

Q.customerOrders()
.orderByIdDescending()
.optimizeForContinuousPageFetch()
.offset(offset, pageSize)
.comment("Browse recent orders")
.purpose("Show order history")
.executeForList(ctx);

The final generated method name must be verified from the released generated Request source. Other languages should preserve the same meaning with idiomatic casing only.

The runtime uses the optimization only when all required capabilities are present. Otherwise it runs the original offset plan without changing the result contract.

Query identity

The runtime computes a stable query fingerprint. Callers should not have to invent an ID for ordinary use.

The fingerprint includes inputs that determine the root row set and ordering:

  • entity and data source;
  • normalized active filter, including trusted injected constraints;
  • effective order field and direction;
  • provider and query semantic version;
  • trusted tenant, merchant, user, permission, and policy scope.

It excludes offset, page size, count instructions, purpose, comment, projection, and relation loading. Page size is stored in and validated against the Cursor, but does not define the query itself.

An optional application namespace can later distinguish separate presentations of the same query. It never replaces fingerprint validation.

Server-side Cursor structure

The Cursor is runtime state, not a token returned to the client. A useful cache record has five parts:

{
"formatVersion": 1,
"identity": {
"cursorId": "cpg_01J5A8R7",
"namespace": "recent-orders",
"queryFingerprint": "sha256:..."
},
"owner": {
"tenantId": "tenant-10001",
"merchantId": "merchant-20001",
"userId": "user-30001",
"sessionId": "session-4f829",
"applicationId": "order-console",
"permissionScopeHash": "sha256:...",
"policyVersion": "2026-08-14.1"
},
"query": {
"entity": "CustomerOrder",
"orderField": "id",
"direction": "DESC",
"filterSummary": "status.code IN (?) AND orderDate >= ?",
"provider": "postgresql",
"purpose": "Show order history",
"comment": "Browse recent orders"
},
"position": {
"sourceOffset": 0,
"nextOffset": 20,
"pageSize": 20,
"boundary": {"type": "LONG", "value": "987600"}
},
"lifecycle": {
"createdAt": "2026-08-14T02:10:00Z",
"lastUsedAt": "2026-08-14T02:11:12Z",
"expiresAt": "2026-08-14T02:20:00Z",
"runtimeInstance": "order-api-7c884",
"runtimeVersion": "teaql-java/1.528-RELEASE"
},
"statistics": {
"hitCount": 12,
"fallbackCount": 0,
"largestAvoidedOffset": 240,
"lastPlan": "CONTINUOUS_SEEK",
"lastFallbackReason": null
}
}

Owner and query metadata are intentionally observable. They let an operator answer who created the Cursor, which query it belongs to, which runtime used it, and why a fallback happened. Store stable internal IDs, not names or email addresses. Filter summaries retain structure while replacing values with ?. Passwords, tokens, cookies, complete connection strings, and raw sensitive filter values are forbidden.

Checkpoints and cache keys

Store checkpoints by the offset they can begin:

query fingerprint + nextOffset 20 -> boundary 987600
query fingerprint + nextOffset 40 -> boundary 987100
query fingerprint + nextOffset 60 -> boundary 986300

This is safer than one mutable lastId: retries and a small amount of concurrent browsing do not overwrite the only useful position. A cache key can keep a readable prefix while hashing sensitive scope and normalized query content:

teaql:continuous-page:v1:<scope-hash>:CustomerOrder:<query-hash>:20

Only a full page registers the next checkpoint. A short page already indicates the end of the current browse result.

State store

The state survives reconstructed Request objects and, where configured, multiple service instances. The runtime should own a small interface with in-memory, Redis, and application-provided implementations:

get(queryKey, targetOffset)
put(queryKey, cursorState)
invalidate(queryKey)

Suggested initial bounds are a 10-minute TTL, no more than 32 checkpoints per query fingerprint, and a bounded number of active query identities per trusted scope. These defaults must remain configurable. An in-memory implementation must not use an unbounded map.

Safe fallback

A Cursor is used only when its format, entity, ordering, typed boundary, page size, target offset, trusted scope, and expiry all validate. Any cache miss, invalid state, unsupported query, unavailable store, or provider limitation selects the original offset plan.

The cache is never a correctness dependency. Cache failure must not make an otherwise valid page request fail.

Observability

Ordinary traces should record enough to diagnose plan selection without copying sensitive values:

continuous-page.cursor-id=cpg_01J5A8R7
continuous-page.owner=tenant-10001/user-30001
continuous-page.entity=CustomerOrder
continuous-page.position=20->40
continuous-page.plan=CONTINUOUS_SEEK
continuous-page.cache=HIT
continuous-page.avoided-offset=20

Do not put the raw boundary or filter values into ordinary logs. Authorized cache inspection can show the typed boundary and the controlled context metadata. Applications should be able to receive structured observer events and route them to their own metrics, trace, or management tooling.

Relationship to aggregation caching

Continuous page fetch and aggregation result caching share a policy: both are explicit acknowledgements that presentation may be approximate in exchange for speed. They cache different things and remain independent:

  • continuous page fetch stores a short-lived position and optimizes the list SQL;
  • aggregation caching stores a previously calculated aggregation result;
  • enabling one does not enable the other;
  • a count is performed only when the query explicitly requests it.

Code review should treat either instruction as a signal to verify that the result is used for browsing or presentation, not business logic.