Cursors
LogCursor is the Lakestream API for tracking read position, covering durable and ephemeral cursors, acknowledgements and their defaults.
LogCursor (io.lakestream.api, extends AutoCloseable) tracks read position and acknowledgment against a single Log. It has 21 methods — 10 abstract, 11 default — and implementations must be safe for concurrent use.
Type documented on this page (1): LogCursor. This page also documents Log's cursor-management methods (openCursor, openEphemeralCursor, loadCursor, loadAllCursors, deleteCursor), since they are inseparable from how a LogCursor is obtained.
Durable and ephemeral cursors are both API concepts
Log declares two ways to get a cursor, both default methods:
CompletableFuture<LogCursor> openCursor(String name, long initialOffset); // durable
CompletableFuture<LogCursor> openEphemeralCursor(String name, long initialOffset); // ephemeral
CompletableFuture<LogCursor> loadCursor(String name); // load a durable cursor
CompletableFuture<List<LogCursor>> loadAllCursors();
CompletableFuture<Void> deleteCursor(String name);openCursor(name, initialOffset) takes an initial mark-delete offset (-1 for none) and is meant for a cursor whose progress is persisted. openEphemeralCursor(name, initialOffset) instead takes an initial read offset and keeps its state in memory only; the Javadoc says callers should open one per read rather than pool them.
Both kinds are concepts defined by this API, on equal footing as two default methods of the same interface — neither is more "real" than the other at the interface level. Ursa 1.0 provides ephemeral cursors only. See Implementation status for what a specific implementation supports.
LogCursor
String name();
Log log();
long readOffset();
long markDeleteOffset();
CompletableFuture<List<LogEntry>> readEntries(int maxEntries, long maxSizeBytes);
CompletableFuture<LogEntry> readEntry(long offset);
CompletableFuture<Void> markDelete(long offset, Map<String, Long> properties);
CompletableFuture<Void> seek(long offset);
CompletableFuture<LogEntryHeader> getEntryMetadata(long offset);
void close() throws Exception;
CompletableFuture<Void> individualDelete(long offset, int numberOfRecords); // default
boolean isOffsetIndividuallyDeleted(long offset); // default
long individualDeleteCount(); // default
long firstNonDeletedOffset(); // default
CompletableFuture<List<LogEntry>> readEntries(int maxEntries, long maxSizeBytes,
Predicate<Long> skipCondition, long maxOffset); // default
CompletableFuture<Void> persistState(); // default
long persistedMarkDeleteOffset(); // default
Map<String, Long> properties(); // default
boolean hasMoreEntries(); // default
long getNumberOfEntriesInBacklog(); // default
CompletableFuture<Void> deleteCursor(); // defaultreadOffset() is the next offset readEntries will start from; markDeleteOffset() is the offset up to and including which entries are acknowledged. readEntries bounds its read by maxEntries — a count of entries, not records (contrast Log.readEntries's maxMessageCount, a count of records; see Logs). seek moves the read position; for an ephemeral cursor this only changes in-memory state. close() keeps the checked Exception from AutoCloseable so a durable implementation can report a failed detach; an ephemeral cursor holds only in-memory state, so closing it releases that state and returns immediately without a round trip, and never throws. Closing a durable cursor never deletes its persisted state — that is what deleteCursor() is for.
Close order
The interfaces do not declare an order between closing a cursor and closing its log directly, but LogCursor.log() returns the Log the cursor reads from, so the same shape as the catalog's own rule applies: close cursors before the log they belong to, not after.
Defaults
On LogCursor:
| Method | Default behavior |
|---|---|
individualDelete(offset, numberOfRecords) | No-op; returns an already-completed future |
isOffsetIndividuallyDeleted(offset) | Returns false |
individualDeleteCount() | Returns 0 |
firstNonDeletedOffset() | Returns markDeleteOffset() + 1 |
readEntries(maxEntries, maxSizeBytes, skipCondition, maxOffset) | Ignores skipCondition and maxOffset; delegates to readEntries(maxEntries, maxSizeBytes) |
persistState() | No-op |
persistedMarkDeleteOffset() | Returns markDeleteOffset() |
properties() | Returns Map.of() |
hasMoreEntries() | Returns false |
getNumberOfEntriesInBacklog() | Returns 0 |
deleteCursor() | No-op; returns an already-completed future |
On Log's cursor methods:
| Method | Default behavior |
|---|---|
openCursor(name, initialOffset) | Returns a failed future carrying UnsupportedOperationException("Cursor management not supported") |
openEphemeralCursor(name, initialOffset) | Returns a failed future carrying UnsupportedOperationException("Ephemeral cursors not supported") |
loadCursor(name) | Returns a failed future carrying UnsupportedOperationException("Cursor management not supported") |
loadAllCursors() | Returns an already-completed future holding List.of() |
deleteCursor(name) | No-op; returns an already-completed future |
Six of these defaults return a plausible-looking answer instead of failing: binarySearchOffset and computeRetentionTrimOffset (documented on Logs, since both belong to Log rather than to cursor management), and individualDelete, hasMoreEntries, loadAllCursors, and deleteCursor above. Most of these can be misread as "checked and negative" when the truth is "not implemented": individualDelete reports success without recording anything; hasMoreEntries reports false — indistinguishable from "confirmed empty"; loadAllCursors reports an empty list — indistinguishable from "no cursors exist"; deleteCursor reports success without deleting anything. computeRetentionTrimOffset is the exception, and the more dangerous one: its default doesn't look negative, it looks like a real, maximal answer — trimming a log to the result removes every entry up to maxOffset, even when retention is meant to keep everything. See Logs for that hazard in full.
There is no capability-discovery method on Log or LogCursor: a caller cannot ask an implementation in advance whether a given optional operation is really implemented or only running its default. The only way to find out is to call it and check whether the result is the default's fixed answer, or to consult that implementation's own documentation.