Cursors — Streaming Large Result Sets, Locally and Remotely¶
Overview and Motivation¶
Some queries don't fit in memory. A nightly batch over every invoice, an export of a million rows, a
migration pass — loading these into a List is the obvious approach and the wrong one.
The usual answer is a database cursor, and the usual catch is that it only works where the JDBC
connection is: a cursor is a live server-side resource tied to an open ResultSet. In a
multi-tier architecture, that would mean batch code runs
against the database tier or not at all — exactly the kind of "this code only works here" exception
Tentackle is built to avoid.
ResultSetCursor removes the catch. It is a
ScrollableResource<T>
that streams PDOs from a query, and it works whether the session is local or remote. The same
class, the same API, the same code:
try (ScrollableResource<SeismicEvent> cursor = on(SeismicEvent.class).findUnprocessed()) {
while (cursor.next()) {
cursor.get().process();
}
}
Connected straight to the database, that iterates a JDBC ResultSet. Connected to an application
server three tiers up, the cursor lives on the server holding the ResultSet, and the client drives
it over TRIP. The application code above does
not change and does not know which happened — consistent with the rest of the
PDO model, where any method can be invoked from any
JVM.
Cursors also carry a memory argument that goes beyond the result set. Because Tentackle has no
identity map, each PDO becomes garbage the moment the loop moves past it — there is no session cache
filling up behind you and nothing to clear(). See
pdo-vs-orm.md for that comparison.
Obtaining a Cursor¶
You rarely construct one. There are three sources, in increasing order of specificity:
1. Built-in methods. Every PDO inherits two cursor methods from
AbstractPersistentObject, no code
generation required:
| Method | Streams |
|---|---|
selectAllAsCursor() |
Every entity in the current context |
selectByNormTextAsCursor(text) |
Everything matching a normtext search |
2. Generated select methods. Add --cursor to a
PdoSelectList wurblet and
the generated query method returns a ScrollableResource<Pdo> instead of a List. The wurblet
generates both sides: locally the body ends in new ResultSetCursor<>(me(), rs); remotely, the
server creates a RemoteResultSetCursor
delegate and the client wraps it in new ResultSetCursor<>(getSession(), remoteDelegate...).
3. Custom queries. executeScrollableQuery(query[, js]) builds a cursor over any Query.
All three return the same type, and the built-in methods show the local/remote fork the generated ones also perform:
public ScrollableResource<T> selectAllAsCursor() {
if (getSession().isRemote()) {
DomainContext context = getDomainContext();
return new ResultSetCursor<>(context, getRemoteDelegate().selectAllAsCursor(context));
}
JoinedSelect<T> js = getEagerJoinedSelect();
return new ResultSetCursor<>(pdo, resultAllCursor(js), js);
}
The --cursor option comes with constraints, all of which follow from streaming:
| Constraint | Why |
|---|---|
Incompatible with --resultset |
--resultset hands out the raw ResultSetWrapper; a cursor is the wrapper's manager |
| Incompatible with immutability | Immutability is applied to a completed collection, which a stream never is |
| Incompatible with tracking | A TrackedList needs the whole list to track additions and removals |
With joins: must be sorted by id |
Rows of one aggregate must arrive consecutively for the cursor to assemble them (see below) |
Key Concepts¶
One class, two modes¶
ResultSetCursor holds either a ResultSetWrapper (local) or a RemoteResultSetCursor (remote) —
never both. Its constructors assert which:
public ResultSetCursor(T pdo, ResultSetWrapper rs, JoinedSelect<T> js) { // local
...
assertSessionIsLocal();
}
public ResultSetCursor(DomainContext context, RemoteResultSetCursor<T> remoteCursor) { // remote
...
assertSessionIsRemote();
}
Every navigation method then forks on session.isRemote(): locally it drives the JDBC result set,
remotely it forwards to the delegate. The remote interface mirrors the local API almost method for
method — next, previous, first, last, scroll, setRow, get, toList, fetch — which is
why the two modes present one face to the caller.
PDOs arriving from a remote cursor are passed through configureRemoteObject, which re-attaches them
to the local DomainContext and
session. A PDO that crossed the wire is a fully functional PDO on arrival, never a detached husk.
Navigation¶
The ScrollableResource
contract is deliberately close to JDBC's, with 1-based rows and row 0 meaning before the first:
| Method | Effect |
|---|---|
next() / previous() |
Move one row; false if there is none (position unchanged) |
first() / last() |
Jump to the ends; false if empty |
scroll(n) |
Move n rows, negative backwards |
setRow(n) / getRow() |
Absolute positioning |
beforeFirst() / afterLast() |
Park outside the result set |
get() |
Materialize the PDO at the current row |
Backward and absolute navigation depend on the underlying result set being scrollable; a
FORWARD_ONLY cursor supports only forward movement. The implementation carries a small scar from
this: after first(), the fetch logic deliberately avoids calling beforeFirst(), because some
databases (Oracle among them) reject it on forward-only cursors.
Fetching in blocks — fetch()¶
next()/get() is the natural loop, and over a remote session it costs two round trips per row.
For large result sets that is the dominant cost, so cursors offer a block API:
try (ResultSetCursor<Customer> cursor = ...) {
for (FetchList<Customer> block = cursor.fetch(); block != null; block = cursor.fetch()) {
for (Customer c : block) {
c.process();
}
if (block.isClosed()) {
break;
}
}
}
fetch() returns a FetchList<T> — an
ArrayList with one extra bit: isClosed(), meaning this was the last block. Its javadoc
explains the point plainly: the flag "adds a closed flag to save a roundtrip in remote sessions."
Without it the client would have to ask whether more rows exist; with it, the server says so while
delivering the final block.
Block size is setFetchSize(rows) — forwarded to the remote cursor or to the JDBC result set as
appropriate. If the fetch size is unset (< 1), fetch() falls back to reading everything, marking
the list closed and closing the cursor.
toList() and toListAndClose() are the "actually, it does fit in memory" shortcuts, and are still
worth using remotely: they collect on the server side and return in one round trip rather than a
get()-per-row conversation.
Joins and aggregates¶
A cursor can carry a JoinedSelect,
so eager relations
stream too. This is where the ORDER BY id requirement comes from: an outer join returns several
rows per root entity, and get() assembles them by reading ahead until the id changes. If rows of
one aggregate are not consecutive, the cursor cannot know an entity is complete. Sorting by id
guarantees they are.
Resource management¶
A cursor is a live resource — a JDBC ResultSet locally, a server-side cursor remotely — and must be
closed. It is AutoCloseable; use try-with-resources.
If you forget, the cursor still cleans up, but noisily. Rather than a finalizer, ResultSetCursor
registers its state with a Cleaner, so an unreachable cursor is closed on GC and logs a warning:
closing unreferenced local ResultSetCursor <rs>
closing unreferenced remote ResultSetCursor <cursor>
Seeing those in a log means application code leaked a cursor: the resource was reclaimed, but only
whenever GC noticed — and in the remote case a server-side cursor and its database connection stayed
alive until then. Treat the warning as a bug to fix, not as reassurance. A programmatic close()
takes the same path but marks the close as intentional, so no warning is logged.
How It Fits Together¶
A remote batch, end to end:
- Application calls the generated
selectAllAsCursor()on a PDO whose session is remote. - The client's remote delegate invokes the method on the server, which runs the query, builds a
local
ResultSetCursorover the JDBCResultSet, and returns aRemoteResultSetCursorhandle. - The client wraps that handle in its own
ResultSetCursor— the object the application holds. fetch()pulls a block per round trip; each PDO is reattached to the client's domain context on arrival. The final block carriesisClosed(), so no extra call is needed to discover the end.close()(via try-with-resources) releases the server-side cursor and itsResultSet.
Swap the session for a local one and steps 2–3 collapse into a direct ResultSetWrapper; steps 1, 4
and 5 read identically in the source. That is the whole point.
Related Documentation¶
- Tentackle Persistence — the layer cursors belong to.
- PdoSelectList and PdoSelectUnique — the
--cursoroption that generates cursor methods. - Eager Relations — joined selects, which cursors can stream.
- PDOs vs. Traditional ORMs — why cursor loops need no
clear(). - The Multi-Tier Cascade — the remoting model cursors stay transparent across.
- TRIP — the protocol remote cursors talk over.
- Miscellaneous — the
ScrollableResourcecontract.