[improve][ml] Optimize individual-ack filtering by skipping scans for batches without acked entries - #26311
Conversation
… batches without acked entries
lhotari
left a comment
There was a problem hiding this comment.
Thanks for this — it arrives with more rigor than a managed-ledger performance change usually does: a randomized oracle test against a boolean array, deterministic boundary cases, a regression test at the cursor call site, and a JMH harness across eight scenarios that self-checks old-vs-new agreement in @Setup. The optimization is sound and the reasoning in the description holds up.
I went over it with AI assistance (Claude + Codex gpt-5.6-sol) and then verified the findings myself locally at f4d9668, including mutation testing. I found no correctness or thread-safety issues.
Within a single ledger the new check is exact rather than approximate: for 0 <= e <= Integer.MAX_VALUE, contains(L, e) reduces to bitmap.contains(e) because getSafeEntry(e) == (int) Math.max(e, -1) == e, so containsAny(L, first, last) is precisely ∃ e ∈ [first, last] : contains(L, e). Combined with the batch precondition I note in the comment on the new condition (every entry in a batch lies in [first, last] of a single ledger), no acknowledged entry can leak back to a consumer.
Read-position advancement is unaffected as well: the batches that newly take the fast path are exactly the ones where the old path filtered nothing, so entriesCount != filteredEntries.size() in OpReadEntry.internalReadEntriesComplete evaluates the same either way and nexReadPosition is unchanged.
Verified locally at f4d9668:
testFilterReadEntriesSkipsFilteringForGapBetweenIndividualAcksgenuinely pins the change — revertingfilterReadEntriesto the old span-based check makes it fail on theisSameAsassertion. Good test.PositionRangeSetTestis green, andtestContainsAnyRandomizedAgainstBooleanOraclecosts 0.33 s, so the 96,000 comparisons are not a CI cost concern.:microbench:compileJava,:microbench:checkstyleMain,:managed-ledger:checkstyleMainand:managed-ledger:checkstyleTestall pass.
I'm marking this request-changes for one item only; everything else below is non-blocking, and I'm happy to flip to approve as soon as it lands.
The Math.max(0, lowerEntryId) guard in containsAny is defensive but unpinned — I verified by mutation that deleting it leaves the entire test class green. It is latent today (no caller can pass a negative entry id), so this is not an active bug. But it is exactly the kind of guard a later "simplification" removes silently, and the failure mode at the filterReadEntries call site would be redelivering acknowledged messages. One assertion closes it, and there is a one-click suggestion inline on testContainsAnyBoundaries.
Five inline comments in total: one I would like addressed (the clamp on PositionRangeSet.java, with the exact fix suggested on testContainsAnyBoundaries), and three non-blocking — a Javadoc nit on containsAny, a precondition worth writing down at the call site, and an optional allocation cleanup.
|
The |
lhotari
left a comment
There was a problem hiding this comment.
Re-reviewed at ff214557efe. All five round-1 comments are addressed, and I checked each against the code rather than the replies. Approving — the single item below is non-blocking.
What I verified
The Math.max(0, lowerEntryId) clamp is now genuinely pinned. That was my only blocking ask, so I re-ran the exact round-1 mutation: substituting bitmap.nextPresentValue(lowerEntryId) now fails PositionRangeSetTest.testContainsAnyBoundaries at line 268 — the new assertTrue(set.containsAny(1, -1, 0)). As I noted when suggesting it, line 269 passes either way; it documents the empty-range contract rather than pinning the clamp. Closed.
The containsAny Javadoc is accurate, not just present. Each claim checks out against the implementation: cardinality really is inclusive/inclusive (LongPairRangeSet: "from lower (inclusive) to upper (inclusive)", implemented as rank(upper+1) - rank(lower)), addOpenClosed really does lowerEntryIdOpen + 1, and nextPresentValue really returns -1 for from < 0 — which is exactly what makes the clamp sentence true. Both {@link}s resolve; javadoc -Xdoclint:all,-missing on the file exits 0.
The new firstEntryId > lastEntryId arm is a strict improvement, and it's worth writing down why, because "conservative fallback" undersells it. On master a descending batch never reached a conservative path at all: Range.closed(first, last) threw IllegalArgumentException: Invalid range: [1:10..1:1] (Position.compareTo orders by ledger then entry), which OpReadEntry converted into readEntriesFailed — the whole read failed. Degrading to per-entry filtering returns the correct result instead. It is pinned by testFilterReadEntriesFallsBackForDescendingBatch.
The fast path no longer touches Entry.getPosition() — the lazy Position allocation is gone from it along with the Range and its two Cuts. The four new .attr(...) calls bind to slog's primitive attr(String, long) overload, so removing the Range didn't reintroduce boxing.
No compatibility surface moves. containsAny is package-private on the package-private PositionRangeSet and correctly stays off the public LongPairRangeSet. The fast path returning the caller's own list was already reachable pre-PR, and OpReadEntry only reads it. quickCheck is green (checkstyle and rat both executed); PositionRangeSetTest plus the three filterReadEntries tests are green on a clean tree — 26 tests, 0 failures.
CI
The only red job is SplitManagerTest.testTimeout in Broker Group 1 — a load-balancer in-flight cleanup race with nothing to do with managed-ledger, which you've already root-caused in #26363. Re-running the failed job now.
| // Production read paths currently return ordered batches from a single ledger, so this cross-ledger batch | ||
| // is not expected in normal operation. If one is ever passed in, the conservative fallback must inspect | ||
| // every entry, remove and release the acknowledged entry, and retain the unacknowledged entries. | ||
| Position firstPosition = PositionFactory.create(1, 10); |
There was a problem hiding this comment.
Non-blocking, and the only thing left from my side.
This test doesn't pin the guard it's named after. The batch is 1:10 → 2:0 → 2:1, so firstEntryId (10) > lastEntryId (1) is also true — the descending arm fires on this input and the cross-ledger arm never gets to matter.
Mutation-checked both ways on a clean tree:
| mutation | testFilterReadEntries* |
|---|---|
delete firstLedgerId != lastLedgerId |
3/3 green — survives |
delete firstEntryId > lastEntryId |
…FallsBackForDescendingBatch fails, as it should |
So the descending guard is pinned and the cross-ledger one isn't — the same shape of gap as the clamp in round 1, and the same risk: someone later deletes the ledger comparison as "redundant" and nothing goes red.
One character fixes it — make the batch ascending across the ledger boundary so only the ledger comparison can force the fallback:
| Position firstPosition = PositionFactory.create(1, 10); | |
| Position firstPosition = PositionFactory.create(1, 0); |
With 1:0 → 2:0 → 2:1, 0 > 1 is false and containsAny(1, 0, 1) finds nothing in ledger 1 (the ack is at 2:0), so the ledger guard becomes the only thing standing between the batch and a skipped filter. Verified: green against the real code, and it fails the mutant that today survives. It also fits the test's name and comment better than a descending batch does.
Motivation
Before this change,
ManagedCursorImpl.filterReadEntriesused the global span of individually acknowledged positions to decide whether a read batch needed per-entry filtering.A span records only the outer boundaries and does not represent holes between acknowledged positions or ledgers. As a result, a batch entirely inside one of those holes could still appear connected to the span. The cursor would then check every entry and allocate a new result list even though the batch contained no individually acknowledged entries.
Modifications
PositionRangeSet.containsAnyto query whether a ledger-local entry range contains an individually acknowledged position.entriesRangeallocation from the read path.Verifying this change
This change added tests and can be verified as follows:
Representative JMH results on Java 21 with a batch size of 100 are shown below. These figures compare
oldSpanDecisionwithnewContainsAnyDecision. Their batch inputs are prepared in@Setup, so the results isolate the decision logic rather than end-to-end read-path latency:The sparse-gap and missing-ledger rows represent false positives from the old global-span check. The exact ledger-local lookup lets those batches avoid unnecessary per-entry membership checks and intermediate result-list allocation. Cases containing acknowledged entries continue to use the existing per-entry filtering path. Additionally,
filterReadEntriesnow logs the endpoint IDs directly instead of eagerly constructing anentriesRange.Does this pull request potentially affect one of the following parts: