perf(inmem): stop evicting the index store on a provenance mismatch - #272
Conversation
|
Hi Heiko, reviewed this the same way as #271 — clean worktree of the branch, full read of Points 2, 3 and 4 are good and I would take them as-is. Point 1 I have to walk back — and that is my fault, not yours: my measurement in #271 only covered the interleaved case, and I presented it as if it were the general one. It is not. Removing the eviction is a large regression in the far more common case, and the measurement I gave you did not surface it. Details below. Verified cleanNo correctness gap from dropping the Mutation proof holds after the removal. With Full The problem: the eviction removal starves the transaction sideWithout the Measured with the
Identical numbers for one and for two secondary indexes. A is my case from #271 and your improvement there is real. But B and C are the ordinary case, and they go from one rebuild for the entire transaction to one rebuild per operation — O(documents × indexes) per read or write, unbounded in the length of the transaction. A needs concurrent non-transactional traffic on the same collection to occur at all; B and C need only that the collection was touched once before the transaction started, which in any long-running process is essentially always. C is worse than the count suggests: the write path mutates an unpublished store, so its incremental So the "cost proportional to what a transaction actually touches" argument, which is the right argument, actually cuts against the change as written. SuggestionKeep the removal of the eviction, but let the newcomer take the entry over atomically after building instead of leaving it to prev = indexStoreByCollection.replace(key, existing, built) ? null : indexStoreByCollection.get(key);with the Getting A and B/C to 1 needs the cache keyed by One factual correctionThe "needs TWO secondary indexes" note is not right, and it is currently baked into the source comment, the commit message and the CHANGELOG, so it would ship as-is. I could also not reproduce 12 vs 2 for the interleaved case; I get 20 vs 10 for the analogous setup. Same direction, different magnitude — probably setup differences, and not important once the shape of the effect is agreed. Where that leaves itPoints 2, 3 and 4 are ready. For point 1 I would ask for the Apologies again for sending you after a measurement that only covered half the picture. |
Follow-up to #271, correcting the four points from review - and correcting a regression the first version of this commit introduced. The mismatch branch in getIndexStore() originally evicted the entry before rebuilding. My first attempt simply removed that eviction, which fixed one case and badly broke a more common one: with the entry left in place, a transaction that meets a pre-existing NO_TRANSACTION entry loses putIfAbsent against it on every call, forever. It never publishes its own store, so it pays a full buildIndexStore - O(documents x indexes) - per operation for the whole life of the transaction. The entry now changes owner atomically once the rebuild finishes, via a compare-and-swap keyed on the exact entry this call observed. A same-key swap, never a remove-then-publish, so there is no moment with no entry for the key at all - which is strictly better than the eviction it replaces, because two callers reach getIndexStore() without holding the collection lock (the ExplainCommand path in runCommand, and recordAggregateSlowQueryIfNeeded) and each gap is a chance to publish a store built from a document list another thread is mutating. Measured on 5000 documents, counting buildIndexStore passes: 20 operations in a transaction against a pre-existing store with eviction: 20 with CAS: 1 40 lookups with no transaction open with eviction: 0 with CAS: 0 Retracting a claim from the first version of this commit: it said the measurement needs TWO secondary indexes because a single one falls into the defs.size() <= 1 full-scan path. That is wrong. CollectionIndexStore registers the built-in _id_ definition in its constructor, so one secondary index already gives defs.size() == 2. The numbers above are identical for one and for two secondary indexes. The claim had reached the source comment, the commit message and the CHANGELOG; it is gone from all three. Unchanged from the previous version of this commit: - Documented the reachability consequence of provenance on the OwnedIndexStore javadoc: a context owner keeps that transaction's whole deepCloneDatabase snapshot reachable, so an ABANDONED transaction (dead thread, or a pooled thread whose currentTransaction ThreadLocal is never cleared) pins the snapshot until a later caller replaces the entry. - Fixed a comment wrapped mid-sentence. - Corrected the test class javadoc: without the fix it is the FULL SCAN assertion that fires first, not the index-vs-full-scan divergence. The stale index-backed candidate makes the update land on the live document, so the transaction's own snapshot never sees it. Verified: provenance recording still runs only when this call actually published (behind the prev == null early return), so commit and abort keep invalidating exactly the context-owned entries. Disabling the provenance guard itself still reddens all three regression tests with the same messages as #271, i.e. the CAS does not weaken their proof; restoring the file is byte-identical by md5. Full "inmemory" group 846/846, 0 failures, 0 errors, 7 skipped.
685912b to
b0d5a3c
Compare
|
You were right on both counts, and the regression was mine. Corrected in The starvation is realI reproduced your B and C before changing anything, with the Identical for one and for two secondary indexes. Mechanism exactly as you describe: the surviving entry keeps winning Took the I also re-ran the mutation proof after the change, since with the CAS a foreign entry can now be overwritten and I did not want to assume that left the guard's proof intact: disabling the provenance check still reddens all three tests with the same messages as #271, and the file restores byte-identically by md5. Provenance recording still sits behind the The two-index claim was wrongConfirmed, and thanks for not letting it ship. I have not been able to explain my original 3-vs-3 result. My scratch harness then used two Your 20-vs-10 for the interleaved case is more trustworthy than my 12-vs-2 for the same reason; I would go with yours. Points 2, 3, 4Unchanged from the previous version, as you suggested. Your framing on point 4 is in the class javadoc now. Follow-upLeaving Full No apology needed - the measurement you gave me was reproducible and pointed at something real. Presenting one case as the general one is exactly the kind of thing a second pass is for, and yours caught it. |
|
Filed the Also cross-referenced it with #278: if MVCC / copy-on-write lands first, #279 may become moot rather than just cheaper, since transaction-owned stores might stop being a thing at all. |
|
Reviewed the CAS version the same way as before: fresh worktree of the branch, read of VerifiedThe CAS is the right shape and correctly placed. Rebuild counts reproduce. 5000 documents, one secondary index, B/C/D match yours exactly. My A differs from both our earlier figures again (17/15 where you
The two-index claim is gone from all three places and replaced with the correct statement. Re-running the mutation proof after the CAS rather than assuming it still held was the right One correction, for #279 rather than for this PR#279 point 3 says provenance recording "stays behind The safety-critical direction still holds: published ⟹ recorded, so no clone-seeded store can Merging. |
|
Thanks for merging — and your correction to #279 is right. I checked it against the code rather than taking it on trust, and the race is exactly where you say.
Agreed it does not warrant a change here: worst case is one wasted rebuild from invalidating a key that is not ours, and no clone-seeded store can escape a transaction unrecorded, which is the property that matters. But you are right that #279 would have inherited the simplified version and built on it, so I have replaced point 3 there with the precise statement, both directions spelled out, the three removers named, and the consequence for the On scenario A: agreed it is scheduling noise. Three harnesses, three different magnitudes (12/2, 20/10, 17/15), and the only thing all three agree on is the direction and that this PR does not change it. #279 needs a deterministic harness before it can claim an improvement — the issue notes that no test asserts rebuild counts at all today, and A is the one case where the count is the assertion. Test-count difference is on my side: I run the group with |
|
Thanks — and agreed on all three points. The asymmetry is the part worth having written down precisely, and #279 is where it matters: with per-owner keys, cleanup becomes mandatory rather than tidy, so "recorded" being weaker than "an entry exists under that key" stops being a footnote and starts being a constraint on the design. Good that it landed there before anything was built on the simplified version. On scenario A — agreed, and I think the deterministic harness is the actual first task in #279, not a prerequisite to it. No test asserts rebuild counts today, so there is nothing to regress against either way; A is the one case where the count is the assertion, and without a harness that pins it, any improvement claim is unfalsifiable. I have put #279 on the 6.4.0 milestone. Unrelated find while writing the release notesThe 6.3.0 changelog entry for That turned into three tickets, since the situation is currently detected only by
Review welcome on #280 in particular, since it is scheduled for the next patch. On the releaseThe regression chain from #267 is fully merged on You asked me to hold back on 7 August because of the regression you saw in two of your applications. Since your fixes are what closed it, you are in a better position than I am to say whether that hold can come off: are those two applications clean against current |
|
Ran the first of the two applications from 7 August against current Built The second application from 7 August is not one I have access to, so I can't speak to it — that half of the hold is yours to lift. From my side, the hold has nothing left standing on it. |
|
Thanks for running it — and for checking the jar actually changed, which is the step everyone skips. Worth noting for the record: you built From our side the full matrix finished green this morning on
1127 test classes / 6807 test methods, 1:35 h wall clock across the parallel waves. Zero failures, zero errors, zero flakes, zero broken — I checked every per-phase counter individually rather than trusting the summary banner. The two PoppyDB phases run against a real three-node replica set and a real single node, so the replication and failover surface that changed most in this release is exercised rather than emulated. Your 1794 are the more interesting number of the two, though. Ours prove the library is self-consistent; yours prove it behaves inside an application that actually broke. On the second application — that one is mine to answer, and it is in a better state than a test suite. Two of our own production applications have been running a 6.3.0-SNAPSHOT for a while now, and both are clean. Both were also hit by the eviction-without-reseed failure behind #233, which is part of why this release matters to us rather than being a nice-to-have. So both halves of the hold are lifted. I am tagging 6.3.0 today. Thanks for the thoroughness through this whole cycle — three regressions in the index-store/transaction area found and closed before a release rather than after one is exactly the outcome that makes this kind of review worth the effort on both sides. |
Follow-up to #271, taking up the four non-blocking points from your review. Thanks for the independent verification - especially for re-running the mutation proofs rather than taking them on trust, and for catching that
InMemTransactionIsolationTeststaying green under the mutation is the part that actually matters. That framing is better than mine.Point 1: the eviction is gone
I reproduced your measurement before changing anything, and the effect is real. My numbers differ from yours in absolute terms but agree in direction and magnitude - on a 5000-document collection with two secondary indexes and 20 interleaved transactional / non-transactional lookups:
One thing worth recording for anyone repeating this: it needs two secondary indexes. With a single one the index plan falls back to a full scan (
defs.size() <= 1) and no store is ever built, so the eviction makes no measurable difference at all - my first attempt at the measurement showed 3 vs 3 for exactly that reason and I nearly concluded the effect was not there.Your reasoning for why the line buys nothing holds up: any other caller applies the same provenance check and rejects the entry anyway, and the
putIfAbsentbranch already handles a foreign entry occupying the key. Removed, with the measurement recorded in the comment so it does not get "tidied" back in.I also took the secondary point in that section. Not evicting keeps the "no entry present" windows rare rather than routine, which matters precisely because of the two lock-free callers you identified (
runCommand'sExplainCommandpath andrecordAggregateSlowQueryIfNeeded) - that reasoning is now in the comment too, credited to the review in the commit message.Verified the removal does not weaken the proof: with the eviction gone I re-ran the mutation with the provenance guard itself disabled (
if (true) { return existing.store(); }plusreturn prev.store()), and all three tests still go red with the same messages. So their proof rests on the guard, not on the eviction.Point 2: reachability documented
Added to the
OwnedIndexStorejavadoc. Your framing was more precise than what I would have written - the pre-provenance version pinned only that collection's clones, whereas a context owner pins the wholedeepCloneDatabasesnapshot, and it is the abandoned transaction (dead thread, or a pooled thread whosecurrentTransactionThreadLocal is never cleared) that turns this from theoretical into a real footprint difference. Written up as such, including that it is bounded at one entry per collection.Point 3: comment reflow
Fixed.
Point 4: test narrative corrected
You are right, and I should have caught this myself - it was visible in my own mutation output. The full-scan assertion is the one that fires first, and the reason is the sharper statement of the bug: the stale index-backed candidate makes the update land on the live document, so the transaction's own snapshot never sees the change at all. The divergence is the visible surface, the lost write the consequence. Rewrote the class javadoc that way; the assertions themselves are unchanged.
Verification
Full
inmemorygroup: 846 tests, 0 failures, 0 errors, 7 skipped - unchanged from #271.Not in this PR
cappedOnInsert/cappedOnRemoveand thecappedDocSizesByCollectionIdentityHashMap, as agreed - that belongs in the #269 bundle. Good to hear on CodeRabbit; happy to be a data point if it turns out noisy on other parts of the codebase.