Guard the condition estimates that a zero RocksDB entry-count estimate turns into Infinity or a negative - #2479
Merged
Merged
Conversation
#2163 switched RocksDB plan sizing from an exact getKeysCount() scan to the O(1) rocksdb.estimate-num-keys read. That property reports 0 for a *populated* table once its accumulated tombstones reach its non-deletions, which the exact count never did -- a 0 there meant a genuinely empty store. Measured on the shipped binding: a store holding 200 live records reports an estimate of 0 after 5000 tombstones for keys that were never written, while getKeysCount() still returns 200. Two arithmetic sites in estimateConditionForTable cannot take a 0 there: the AND-group branch divides by it, yielding estimated_count === Infinity, and the `ne null` branch subtracts the index's null count from it, yielding a negative estimate -- the exact count could not underrun getValuesCount(null), a 0 estimate can. The divisor breaks planning: the adaptive filter/index switch derives thresholdRemainingMisses as `estimated_count >> 4`, and Infinity >> 4 is 0 rather than NaN, so the isNaN check passes it through and the first non-matching record flips the request onto searchByIndex plus a full Set build, memoized for the next 10 seconds -- the inverse of the plan #2163 exists to protect. Guarded, the same group estimates 200 and the threshold is a usable 12. It takes the `|| 1` idiom already applied to the sibling relationship divisor two branches below. The subtraction does not affect planning: -32 and 0 both trip the switch on the first miss. It matters because Table.ts reports estimated_count verbatim as the estimated pagination total, so an empty page could answer Content-Range */-500. Math.max at 0 rather than 1, because `|| 1` does not catch a negative and a table whose rows are all null has a true `ne null` count of zero -- flooring at 1 would trade a negative total for a phantom row. estimatedEntryCount itself keeps returning 0 for the same reason: it is not planner-private, so flooring it in the helper would make an empty table report a record count of 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request prevents division-by-zero and negative estimation issues in resources/search.ts when estimatedEntryCount returns zero. It adds a fallback divisor of 1 during condition estimation and ensures that ne null condition estimates are non-negative by wrapping the calculation in Math.max(..., 0). Additionally, a comprehensive suite of unit tests has been added in unitTests/resources/estimatedEntryCount.test.js to verify these behaviors, including memoization and edge cases for zero-entry stores. I have no feedback to provide as there are no review comments.
Contributor
|
Reviewed; no blockers found. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#2163 switched RocksDB plan sizing from an exact
getKeysCount()full-key-space scan to the O(1)rocksdb.estimate-num-keysread. That property reports 0 for a populated table once its accumulated tombstones reach its non-deletions, which the exact count could not do — a 0 there meant a genuinely empty store. Measured on the shipped binding: a store holding 200 live records reports an estimate of 0 after 5000 tombstones for keys that were never written, whilegetKeysCount()still returns 200.Two arithmetic sites in
estimateConditionForTablecannot take a 0 there:estimated_count === Infinity|| 1, the idiom already on the sibling relationship divisor two branches belowne nullbranch subtracts the index null count from it-500)Math.max(…, 0)The divisor breaks planning. The adaptive filter/index switch derives
thresholdRemainingMissesasestimated_count >> 4, andInfinity >> 4is0rather thanNaN, so theisNaNcheck passes it through,canUseIndexstays true with a threshold the loop can never respect, and the first non-matching record flips the request ontosearchByIndexplus a fullSetbuild, memoized for the next 10 seconds — the inverse of the plan #2163 exists to protect, on exactly the eviction- and replication-heavy tables that accumulate tombstones. Guarded, the same group estimates 200 and the threshold is a usable 12.The subtraction does not.
-32and0both trip the switch on the first miss, so clamping it changes no plan. It matters becauseTable.tsreportsestimated_countverbatim as the estimated pagination total, so an empty page could answerContent-Range: */-500— the clamp below that call only ever raises a total, never lowers it.Math.max(…, 0)rather than 1, because a table whose rows are all null has a truene nullcount of zero and flooring at 1 would trade a negative total for a phantom row.estimatedEntryCountitself keeps returning 0 for the same reason: it is not planner-private, so flooring it in the helper would make an empty table report a record count of 1 — pinned by a test.The same two guards ship to v5.1 and v5.2 alongside the backport of #2163's
estimatedEntryCountportion, so those branches do not inherit this.For the human reviewer
estimate-num-keyscounts entries without reconciling overwrites, so it reads high on rewrite-heavy stores and collapses to 0 under tombstones.DESIGN.mdrecords 37,775 against an exact 4,001 on a real HNSW index store, and notes it "validates clean in isolation and only misleads on a real index" — which is why HNSW node counting uses a monotonic id counter. This PR does not change that; it only removes the two values the>> 4guard provably cannot handle. The four multiplier call sites stay unguarded on purpose: a 0 estimate makes them 1, which is finite and positive, so the guard still functions — they degrade ordering, which is Use storage-level statistical range estimates in the query planner #2163's trade.estimatedEntryCount, which is the tidier-looking fix and covers all ten call sites at once. It is wrong for the reason above: the accessor has two contracts, a planner input and a user-visible cardinality, and only the former needs a positive value. If you'd rather see a separate planner-only accessor wrapping the raw one, that is a reasonable alternative and a bigger diff.|| 1on the divisor,Math.max(…, 0)on the subtraction. Deliberate:|| 1cannot catch a negative, and the sites want different floors — 1 for a divisor, 0 for a cardinality.estimated_count >> 4is not a safe way to derive a threshold from an unbounded estimate. It wraps negative above 2³¹ (1e12 >> 4 === -45461248), and itsisNaNguard is dead code regardless —NaN >> 4is0, so the check can never fire. Every pathology this PR removes passes through that one expression, which is the real thing to fix. Review also surfaced a separate pre-existing defect in the same accumulator, which this PR does not touch: a zero-match condition followed by a full-scan comparator makes itNaN(0 * Infinity), andisFinite(NaN)being false then discards every estimate accumulated before it. The|| 1divisor is not involved — it isNaNfor any divisor. And the underlying estimate is inaccurate in both directions — inflated under overwrite churn, collapsed under tombstones — so condition ordering can still put a wide range ahead of a narrow indexedequals. Those are plan-quality problems, not correctness ones, and each wants its own change; this PR only removes the values the guard provably cannot handle.Verification
unitTests/resources/estimatedEntryCount.test.js— 6 tests, all passing under RocksDB; 5 passing and 1 pending underHARPER_STORAGE_ENGINE=lmdb. The RocksDB-specific test gates onstore instanceof RocksDatabaserather than ontypeof store.getEstimatedKeyCount, so a renamed or dropped binding method fails the guard loudly instead of silently skipping the one test that covers it.Fails on base against
origin/main, withrm -rf distbefore each build becausetscincremental will otherwise report a pass against stale output:Infinity !== 200for the AND-group estimate and-500 !== 0for thene nullestimate — the two values this PR removes. The other four pass on base and should:mainalready has the O(1) swap and theintersectionEstimateclamp, and the "count is reported as 0" test guards against the wrong fix rather than proving this one.The tombstone premise was measured, not argued: on the shipped binding, 200 live rows plus 5000 tombstones for never-written keys gives
{estimated: 0, exact: 200}.test:unit:resources— 2003 passing, 0 failing.test:unit:maindid not complete in this worktree: it wedges in thebefore allhook of a git-tag test (resolves a local tag to its commit SHA), which needs tag/remote state the worktree does not have, and never reaches a summary. Not related to this diff — the same suite runs to completion in a v5.1 worktree — but stating it rather than reporting a pass I did not observe. CI is the authoritative run.Not covered: the tests prove no
Infinityor negative escapes into a plan estimate. They do not prove the resulting plan is the better one on a churned table, and the 2,000-row fixture is a fresh store with simple puts — precisely the profileDESIGN.mdsays the estimate reads accurately on. Neither the rewrite-inflated nor the tombstone-collapsed real-store case is covered; both need an end-to-end query against a churned table and are additive.Complexity: medium
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=3 @ 64b408a
Human-Review-Need: 3 (decisions: ne-null-clamp-to-zero, divisor-floor-vs-product-cap, guard-at-call-sites-vs-in-the-helper, stub-store-test-scope) @ 64b408a