Serve Cassandra cursor repositioning with CQL slice queries instead of a no-op iterator restart - #865
Conversation
…f a no-op iterator restart The driver ResultSet is consumed once: rc.iterator() always returns the same iterator, so the old "restart" never rewound. Backward positionToKeyOrNext returned the wrong row, positionToKey drained the whole remaining partition and returned false for existing keys, and positionToIndex counted from the current position, breaking VLV searches. Reposition now runs a "key>=? ORDER BY key" slice on the clustering column, forward moves are served from already-fetched rows, positionToLastKey uses "ORDER BY key DESC LIMIT 1", and cursors no longer start a full partition scan on open.
maximthomas
left a comment
There was a problem hiding this comment.
The premise checks out: PagingIterable javadoc confirms "subsequent calls to iterator() will return the same iterator instance", so the //restart iterator lines really were no-ops. This fixes three genuine wrong-results bugs (VLV-by-assertion offsets, DN2URI.targetEntryReferrals never finding referrals above the target, backward repositioning), and on every seek-shaped path the new page is a strict suffix of what master transferred. positionToLastKey going from a full partition drain to one row is a large win.
One change requested before merge; everything else is follow-up material.
positionToKey missed the buffer fast path (major)
positionToKeyOrNext gained the forward buffer walk; positionToKey did not, so every non-exact call issues a fresh page-sized slice:
// CASStorage.java:419
public boolean positionToKey(ByteSequence key) {
if (isDefined() && key.compareTo(getKey())==0) {
return true;
}
if (seek(key) && key.compareTo(getKey())==0) { // always a new CQL query
return true;
}
current=null;
return false;
}opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java:1724 loops it over an ascending-sorted ID list on one shared id2entry cursor (Collections.sort(entriesToBeDeleted) at :1718); renameSingleEntry at :2135 has the same shape (:2086 sorts by Pair.getPairComparator(), and positionToKey uses getFirst()):
try (final Cursor<EntryID, Entry> cursor = id2entry.openCursor(txn)) {
for (Long entryIDLong : entriesToBeDeleted) { // strictly ascending
if (!cursor.positionToKey(entryID.toByteString())) { ... }Master walked this in one forward pass. Now it is K queries, each eagerly pulling up to basic.request.page-size = 5000 rows of full entry blobs (default confirmed in java-driver-core 4.19.2 reference.conf:135; no override anywhere in the repo). Single-entry delete gets faster; a 100-entry subtree delete goes from ~5 MB to ~500 MB. That is the same amplification this PR exists to remove, one method over.
Fix is the loop already written next door:
if (isDefined()) {
final int cmp=key.compareTo(getKey());
if (cmp==0) return true;
if (cmp>0) {
while (rc.getAvailableWithoutFetching()>0) {
current=iterator.next();
final int c=key.compareTo(getKey());
if (c==0) return true;
if (c<0) { current=null; return false; } // walked past it
}
}
}Keep the key>= slice rather than switching to the point read in TransactionImpl.read — DN2ID.openCursor0 positions and then iterates with next(), and the new test asserts that contract.
Tests claim coverage they don't have (minor)
Three separate gaps in opendj-server-legacy/src/test/java/org/opends/server/backends/cassandra/TestCase.java:
testCursorKeyOrderIsUnsigned doesn't exercise the buffer path its javadoc names. The second next() leaves the cursor on high, so the reposition compares {0x80} vs {0x80,0x01} → negative (shorter prefix is less, ByteString.compareTo returns length1 - length2) → backward branch → server-side seek:
assertTrue(cursor.next());
assertEquals(cursor.getKey(), high); // {0x80,0x01} <-- makes the next line a backward jump
assertTrue(cursor.positionToKeyOrNext(ByteString.valueOfBytes(new byte[] { (byte) 0x80 })));It verifies Cassandra's collation — worth keeping — but not the client-side comparison at CASStorage.java:408. Dropping the two lines above restores the intended branch.
The cross-page fallthrough is untested. With 40 rows in a 5000-row page, getAvailableWithoutFetching() is never 0 mid-scan, so CASStorage.java:406 → :415 never runs. That branch is what makes the fix correct on production-sized trees; a regression in it would pass CI.
The queryCount assertions silently depend on the default page size. assertEquals(impl.queryCount, 1) and assertTrue(impl.queryCount <= 3) only hold while one page covers all 40 rows. datastax-java-driver.basic.request.page-size is a live system property (this class already tunes the driver that way at CASStorage.java:188), so setting it to 10 fails the test with sibling scan took 5 queries — pointing at code that is behaving correctly. Pin the page size in the test.
Nits
- Failed
positionToKeyskips a row:seek()consumes the first row>= key, then:426setscurrent=nulland leaves the iterator past it, so a followingnext()returns the second row. Unreachable today — onlyDN2ID.openCursor0ignores the return value, andChildrenCursor/SubtreeCursormask it vialimit = ByteString.empty()— but it is a trap for the next caller. Worth a test asserting miss-then-next(). positionToLastKeyleaves a DESC ResultSet inrc: safe only becauseLIMIT 1guaranteesgetAvailableWithoutFetching()==0. Change that LIMIT and the forward-serve loop walks rows in descending order. Worth a comment at:406, not just at:433.- No page sizing at all:
setPageSizeis never called, sohasSubordinatesstill moves ~4000 rows to answer a 2-row question. Not a regression — master was worse — but it means this is roughly a 2x cut on the hottest path where #863 got ~100x from adaptive sizing. Suggest not describing it as closing #860 for Cassandra. - Post-
close()behaviour is inconsistent:next()returns false silently whilepositionTo*throw a bareIllegalStateException, which is not aStorageRuntimeException. Still an improvement over master's NPE. getTableName()per query: tworeplaceAllcalls plus a concat on everyselect(), now once per reposition rather than once per cursor.- Cleanup swallows everything:
catch (Exception ignored) {}around thedeleteTreewrites leaves a stale partition with no log line.
positionToKeyOrNext served forward moves from the rows the driver had already fetched, positionToKey did not, so the ascending positionToKey loops in EntryContainer.deleteSubtree (:1729) and renameSubtree (:2135) ran one page-sized slice of full entries per entry, where master walked them in a single forward pass. Both methods now share one forward-in-page walk. Cursors also stopped relying on the driver page of 5000 rows: a page starts at 32 rows and doubles up to 1000 (both tunable through org.openidentityplatform.opendj.cassandra.fetchsize[.initial]), and a scan that outruns its page continues with a "key>?" slice instead of letting the driver page at the size the cursor started with. Point lookups - ID2Entry.containsEntryID, DN2URI.targetEntryReferrals walking up the DIT - no longer transfer thousands of entries, and hasSubordinates no longer moves thousands of rows to answer a two-row question. Behaviour that was left undefined is pinned down: a failed positionToKey stops just before the first key after the missing one, so next() no longer skips a row; a closed cursor is undefined and every navigation on it returns false, instead of throwing IllegalStateException from some methods and returning false from others; the DESC page of positionToLastKey can never serve a forward move; the table name is resolved once per cursor instead of once per query. The tests pin the page size instead of inheriting the driver default, cover the page boundary branches on a tree several pages long, assert that an ascending positionToKey walk takes 2 queries and not 40, and start from an empty tree so that a rerun against a persistent Cassandra is clean. testCursorKeyOrderIsUnsigned checked Cassandra's collation but not the client-side comparison it names: its second next() turned the reposition into a backward jump. The class also needs sequential=true - TestListener rejects a class that declares test methods of its own without it, which is what turned every Linux CI job red.
|
Thanks — the blocking finding was exact, including the two call sites and the arithmetic. Everything below is in f0f7f55; the PR description is updated to match.
|
Follow-up to the JDBC cursor analysis in #863/#860: the Cassandra backend's
CursorImplhas the same class of problem, but worse — broken repositioning semantics, not just excess traffic.Problem
The DataStax driver's
ResultSetis consumed once:rc.iterator()always returns the same iterator (MultiPageResultSetreturns the sameRowIteratorfield;SinglePageResultSet.currentPage()is() -> iteratorpolling rows off a queue), so the//restart iteratorlines never rewound anything. Consequences:positionToKeyOrNext(k)backward returned the next remaining row (silently skipping[k .. current]), orfalseafter exhaustion even though matching rows exist; repositioning to the current key moved the cursor forward instead of staying.positionToKey(k)backward drained the whole remaining partition over the network and returnedfalsefor a key that exists.DN2URI.targetEntryReferralswalks up the DIT, so every one of its lookups is a backward one: referrals above the target were never found.positionToIndex(i)counted from the current position, not from the first row —VLVIndex.evaluateVLVRequestByAssertiondoespositionToKeyOrNext(assertion) && positionToIndex(0)on one cursor, so VLV searches with an assertion returned wrong offsets.SELECT ... ORDER BY keyat the driver default of 5000 rows per page, so positioning to a key pulled every row before it, everyopenCursoreagerly fetched the first page of the whole partition, and a point lookup such asID2Entry.containsEntryIDpaid for thousands of full entries.Changes
Repositioning
and key>=:key ORDER BY key(the table isPRIMARY KEY ((baseDN,indexId),key), blob clustering collates in unsigned byte order — same property the JDBC fix relies on).positionToKeyOrNextandpositionToKey(one sharedforwardInPagewalk). The ascendingpositionToKeyloops on a single sharedid2entrycursor inEntryContainer.deleteSubtreeandrenameSubtreetherefore stay at one forward pass instead of one slice per entry.positionToIndexrestarts from the first row (CQL has no offset clause) and asks for the rows it has to walk in one page;positionToLastKeyisORDER BY key DESC LIMIT 1instead of draining the partition.Paging
org.openidentityplatform.opendj.cassandra.fetchsize.initialandorg.openidentityplatform.opendj.cassandra.fetchsize, mirroringorg.openidentityplatform.opendj.jdbc.fetchsize.and key>:key ORDER BY key) at the bigger page size, rather than letting the driver page at the size the cursor started with.Semantics that used to be undefined
positionToKeyleaves the cursor undefined and stopped just before the first key after the missing one, so a followingnext()returns that row instead of skipping it (this is what pdb does).false, likeEmptyCursor— instead ofnext()silently returningfalsewhilepositionTo*threw a bareIllegalStateException.positionToLastKeyis flagged and can never serve a forward move.Tests
testCursorReposition— backward/in-place/between-keys repositioning, misses (including that a miss inside the page runs no query and thatnext()resumes on the following key), the VLV pattern (positionToKeyOrNext+positionToIndex(0)), theChildrenCursorsibling-scan pattern, and an ascendingpositionToKeywalk over 40 keys that must take 2 queries, not 40.testCursorPagingAcrossPages— page pinned to 4 rows growing to 8 over a 40 row tree: full scan across page boundaries, forward repositioning falling back to a slice when the page runs out,positionToKey/positionToIndex/positionToLastKeyfar beyond the first page, andnext()after a positioned key across several pages.testCursorKeyOrderIsUnsigned—{0x7F}vs{0x80,0x01}verify the unsigned clustering order the buffer-serving logic relies on, and the reposition is now a forward move so it exercises the client-side comparison (with the secondnext()it used to be a backward jump served by the server).testCursorAfterClose— navigation on a closed cursor.sequential=true:TestListenerrejects a class that declares test methods of its own without it.Full local run against testcontainers:
org.opends.server.backends.cassandra.TestCase38 tests, 0 failures, 0 skipped (34 inheritedPluggableBackendImplTestCasetests + the 4 cursor tests) andEncryptedTestCase34 tests, 0 failures, 0 skipped, both withorg.opends.server.TestListenerenabled.