Skip to content

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.

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:

  1. Application calls the generated selectAllAsCursor() on a PDO whose session is remote.
  2. The client's remote delegate invokes the method on the server, which runs the query, builds a local ResultSetCursor over the JDBC ResultSet, and returns a RemoteResultSetCursor handle.
  3. The client wraps that handle in its own ResultSetCursor — the object the application holds.
  4. fetch() pulls a block per round trip; each PDO is reattached to the client's domain context on arrival. The final block carries isClosed(), so no extra call is needed to discover the end.
  5. close() (via try-with-resources) releases the server-side cursor and its ResultSet.

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.