Use storage-level statistical range estimates in the query planner - #2163
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces statistical range estimation to the query planner, blending RocksDB's estimateCount with historical heuristics based on confidence. It also updates RocksDB's key count estimation to use the O(1) getEstimatedKeyCount method and adds comprehensive unit tests. The review feedback suggests guarding against nullish results from store.getStats() in estimatedEntryCount to prevent runtime errors, and preserving additional options (such as transactions or snapshots) when overriding estimateCount in RocksIndexStore.
| store.estimatedEntryCount = | ||
| store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount; |
There was a problem hiding this comment.
If store.getStats() returns nullish, accessing entryCount will throw a runtime error. Additionally, if store.getEstimatedKeyCount() or store.getStats()?.entryCount returns undefined, null, or NaN, store.estimatedEntryCount will be set to a non-finite value. We should guard against nullish results from getStats() and explicitly validate that the estimated count is a finite number, defaulting to 0 otherwise.
| store.estimatedEntryCount = | |
| store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount; | |
| const estimated = store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats()?.entryCount; | |
| store.estimatedEntryCount = Number.isFinite(estimated) ? estimated : 0; |
References
- When accessing properties of a result from a method that might return nullish (e.g.,
backend.capabilities()), guard against nullish values to prevent runtime errors.
| (RocksIndexStore.prototype as any).estimateCount = function estimateCount(options?: any) { | ||
| let { start, end, exclusiveStart, inclusiveEnd } = options ?? {}; | ||
| if (exclusiveStart && start !== undefined) start = [start, MAXIMUM_KEY]; | ||
| if (inclusiveEnd && end !== undefined) end = [end, MAXIMUM_KEY]; | ||
| return (RocksDatabase.prototype as any).estimateCount.call(this, { start, end }); | ||
| }; |
There was a problem hiding this comment.
When overriding estimateCount for RocksIndexStore, only start and end are passed to the base estimateCount call. Any other options (such as transaction, snapshot, or future parameters) are stripped. It is safer to preserve the other options while removing exclusiveStart and inclusiveEnd (since their effects have already been manually applied to the composite boundaries).
(RocksIndexStore.prototype as any).estimateCount = function estimateCount(options?: any) {
let { start, end, exclusiveStart, inclusiveEnd } = options ?? {};
if (exclusiveStart && start !== undefined) start = [start, MAXIMUM_KEY];
if (inclusiveEnd && end !== undefined) end = [end, MAXIMUM_KEY];
const baseOptions = { ...options, start, end };
delete baseOptions.exclusiveStart;
delete baseOptions.inclusiveEnd;
return (RocksDatabase.prototype as any).estimateCount.call(this, baseOptions);
};| // getStats is the LMDB fast path; for RocksDB, estimate-num-keys is O(1) where an exact | ||
| // getKeysCount() would iterate the entire store | ||
| store.estimatedEntryCount = | ||
| store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount; |
There was a problem hiding this comment.
What: store.getEstimatedKeyCount() is called unconditionally for every RocksDatabase instance, with no feature-detection, try/catch, or fallback — unlike the sibling estimateCount integration in this same PR (resources/search.ts:1120 guards with typeof store?.estimateCount !== 'function' and wraps the call in try/catch; resources/RocksIndexStore.ts:72 conditionally assigns the override so the capability probe doesn't lie).
Why it matters: estimatedEntryCount() is on the hot path for essentially every query-plan estimate against a RocksDB-backed table (not just the new range-estimation feature — it also feeds intersectionEstimate and every non-range branch's heuristic). If getEstimatedKeyCount is absent from the resolved @harperfast/rocksdb-js build (a platform-specific prebuild, a version skew, or simply if this method turns out not to exist on the pinned 2.7.1 the way estimateCount is claimed to need a future dependency bump), this throws a TypeError on effectively every RocksDB query, not a graceful degradation. That directly contradicts the PR's own stated design principle that "on the currently-pinned rocksdb-js the planner behaves exactly as before."
Suggested fix: Apply the same defensive pattern used for estimateCount elsewhere in this PR — feature-detect (typeof store.getEstimatedKeyCount === 'function') and fall back to the previous getKeysCount() when absent.
|
Blocker: resources/search.ts:1726 still calls |
f917da4 to
9b2cff2
Compare
face448 to
c362016
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
| // getStats is the LMDB fast path; for RocksDB, estimate-num-keys is O(1) where an exact | ||
| // getKeysCount() would iterate the entire store | ||
| store.estimatedEntryCount = | ||
| store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount; |
There was a problem hiding this comment.
Standing finding (unaddressed across 4 pushes): getEstimatedKeyCount() called with no feature-detection
What: store.getEstimatedKeyCount() is called unconditionally whenever store instanceof RocksDatabase, with no typeof/try-catch guard — unlike the sibling estimateCount integration added in this same PR (resources/search.ts:1124 guards with typeof store?.estimateCount !== 'function' and try/catches the native call).
Why it matters: estimatedEntryCount() runs on the hot path for essentially every RocksDB query-plan estimate (it also feeds intersectionEstimate). The store parameter here is untyped (implicit any — tsconfig.json has strict: false), so tsc provides no compile-time guarantee this method exists; a platform-specific prebuild or version skew that lacks it turns this into a TypeError on effectively every RocksDB query rather than a graceful degradation, unlike every other capability probe this PR adds for the same dependency.
This was flagged by gemini-code-assist (high) and in this bot's own review on 2026-08-13, and remains unresolved and unaddressed through the four commits pushed since (47e242a38, a76c45f2b, 641a1b725, c3620162d) — none of which touch this line.
Suggested fix: typeof store.getEstimatedKeyCount === 'function' ? store.getEstimatedKeyCount() : store.getKeysCount(), matching the defensive pattern already used for estimateCount in this PR.
ac31287 to
01b8d69
Compare
| // getStats is the LMDB fast path; for RocksDB, estimate-num-keys is O(1) where an exact | ||
| // getKeysCount() would iterate the entire store | ||
| store.estimatedEntryCount = | ||
| store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount; |
There was a problem hiding this comment.
Standing finding (unaddressed across all pushes, now at final head): getEstimatedKeyCount() called with no feature-detection
What: store.getEstimatedKeyCount() is called unconditionally whenever store instanceof RocksDatabase, with no typeof/try-catch guard — unlike the sibling estimateCount integration added in this same PR (resources/search.ts:1120, guarded by typeof store?.estimateCount !== 'function' and try/caught) and unlike RocksIndexStore.estimateCount, which is only conditionally assigned so its own capability probe doesn't lie.
Why it matters: estimatedEntryCount() is on the hot path for essentially every RocksDB query-plan estimate — it also feeds intersectionEstimate and every non-range branch's heuristic, not just the new range-estimation feature. store is untyped here (implicit any), so tsc gives no compile-time guarantee this method exists on the resolved @harperfast/rocksdb-js build. A platform-specific prebuild or version skew lacking it turns this into a TypeError on effectively every RocksDB query — not a graceful degradation — contradicting the very design principle this PR's own DESIGN.md addition documents ("Capability is feature-detected per store … so a store that answers differently … degrades the plan … instead of NaN-poisoning condition ordering"), which is written one paragraph away from this exact ungated call.
This was flagged by gemini-code-assist (high) on 2026-08-13 and by this bot on 2026-08-13 and again on 2026-08-26. It remains unaddressed through every commit since, including the two most recent (e02d9bb7b, 65259335b, 9541770b6) and the branch rebase that produced the current head — none touch this line.
Suggested fix: typeof store.getEstimatedKeyCount === 'function' ? store.getEstimatedKeyCount() : store.getKeysCount(), matching the defensive pattern already used for estimateCount in this PR.
Range comparators (starts_with/prefix, between and friends, lt/le/gt/ge) have always estimated as fixed fractions of the table size (5%/10%/30%), which makes condition ordering — and #2147 count=estimated totals — wildly wrong for any real range. When the store provides rocksdb-js estimateCount ({ count, confidence }), estimateCondition now estimates the actual range the search would iterate (mirroring searchByIndex range construction), blended with the old fraction heuristic by the estimate confidence, so low-confidence estimates (block-granular tiny ranges, open-ended complement subtraction) degrade gracefully to the previous behavior. RocksIndexStore translates value-space bounds to its composite [indexedValue, primaryKey] keys ([value, MAXIMUM_KEY]), mirroring its getRange. estimatedEntryCount switches from an exact full-store getKeysCount scan (every 10s per store) to the O(1) estimate-num-keys read. The capability is feature-detected (typeof store.estimateCount), so behavior is unchanged until the rocksdb-js dependency ships HarperFast/rocksdb-js#778; the end-to-end tests self-skip until then.
- negated conditions now estimate the complement of their positive
estimate (root fix in estimateConditionForTable — also covers the
pre-existing negated-equals defect): a narrow negated range previously
looked highly selective, won the condition ordering, and executed as a
full scan
- the estimate path validates {count, confidence} shape and wraps the
native call in try/catch, so a future dependency bump (or a
concurrently closing store) degrades to the fraction heuristic instead
of NaN-poisoning plan ordering or failing the request
- over-length string bounds fall back (execution truncates at
MAX_SEARCH_KEY_LENGTH + filters, so the executed range is wider than
the estimable one)
- intersectionEstimate divisor floored at 1
- comment trims per review
The complement inversion still let a wide positive range make its negation look cheap while executing as a full scan. estimated_count feeds driving-condition ordering, and the codebase already encodes full-scan cost as Infinity for the filter-only comparators (contains/ends_with) — negated conditions (which always force needFullScan) now follow the same convention.
rocksdb-js 2.8.0 ships the statistical range estimation the query planner was written against, so the RocksIndexStore override becomes a real typed method mirroring getRange (reverse flip included) instead of a conditional prototype assignment, and the real-store estimate tests run unconditionally. The per-store capability probe and result-shape validation stay: LMDB-backed and custom index stores don't implement estimateCount. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rototype test:unit:lmdb re-runs test:unit:resources with HARPER_STORAGE_ENGINE=lmdb, where the index stores have no estimateCount at all. Keying the skip off RocksDatabase.prototype was already wrong and became always-true once 2.8.0 was pinned, so gate on the table's actual index store instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
searchByIndex bounds lt/le at `start: true`, which sorts above `null`, so an indexNulls index's `[null, primaryKey]` entries are outside the executed range. estimateRangeCondition left the lower bound open and counted them: on an index that is 99% nulls the estimate came back 21x high (19960 for a 200-row condition), so a genuinely selective condition looked like a full scan and lost the driving-condition ordering — worse than the flat heuristic it replaces. getRange and estimateCount now share one translateIndexBounds helper so the "estimate the range you execute" invariant is structural instead of two copies, and a spy test pins that both forward identical bounds, reverse branch included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ther The real-store suite compared estimates to one another (narrow < wide), which passes even if every estimate is an order of magnitude off. Three cases now run the actual query and assert the estimate lands within 10x of what it returned, covering the primary-key path as well — that path passes `start: true` through to the primary store, and nothing pinned it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rocksdb-js 2.8.0 depends on msgpackr@2.0.6; the root package.json still pinned 2.0.5, so npm couldn't dedupe to a single msgpackr instance and the shrinkwrap-pin smoke check failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prettier wants blank lines around the two independently-appended sections merged during the rebase onto main.
condition.negated forced estimated_count to Infinity at the end of estimateConditionForTable, but every branch above it (including the native estimateCount FFI call for range comparators) still ran first and had its result discarded. Hoist the negated check to skip that work entirely — same outcome, no wasted native call. Found by the pre-push independent review (codex, graded leg) on this rebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
estimateRangeCondition checked Number.isFinite(count) and the confidence bound but not count's sign, so a store returning a negative count (a malformed/buggy native response) would pass validation and blend a negative number into the estimate. Found by the pre-push independent review (codex, graded leg) on this rebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
01b8d69 to
30c51bb
Compare
| // getStats is the LMDB fast path; for RocksDB, estimate-num-keys is O(1) where an exact | ||
| // getKeysCount() would iterate the entire store | ||
| store.estimatedEntryCount = | ||
| store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount; |
There was a problem hiding this comment.
Standing finding (unaddressed across 5+ pushes, still at current head): getEstimatedKeyCount() called with no feature-detection
What: store.getEstimatedKeyCount() is still called unconditionally whenever store instanceof RocksDatabase, with no typeof/try-catch guard — unlike the sibling estimateCount integration in this same PR (resources/search.ts:1126, guarded by typeof store?.estimateCount !== 'function' and try/caught) and unlike RocksIndexStore.estimateCount, which only overrides the base method so its own capability probe doesn't lie.
Why it matters: estimatedEntryCount() is on the hot path for essentially every RocksDB query-plan estimate — it also feeds intersectionEstimate and every non-range branch's heuristic, not just the new range-estimation feature. store is untyped here (implicit any), so tsc gives no compile-time guarantee this method exists on the resolved @harperfast/rocksdb-js build. A platform-specific prebuild or version skew lacking it turns this into a TypeError on effectively every RocksDB query — not a graceful degradation — contradicting this PR's own DESIGN.md addition ("Capability is feature-detected per store … so a store that answers differently … degrades the plan … instead of NaN-poisoning condition ordering"), written one paragraph away from this exact ungated call.
This was flagged by gemini-code-assist (high) on 2026-08-13, and by this bot on 2026-08-13, 2026-08-26, and 2026-08-27 (twice now). The two commits pushed since the last review (a824835d3, 30c51bb0f) both land above this line (in estimateRangeCondition/negation handling) and don't touch it.
Also still open from gemini's original comment on this line: the LMDB fallback branch, store.getStats().entryCount, has no nullish guard on getStats() either.
Suggested fix: typeof store.getEstimatedKeyCount === 'function' ? store.getEstimatedKeyCount() : store.getStats()?.entryCount ?? store.getKeysCount(), matching the defensive pattern already used for estimateCount in this PR.
…ation The malformed-shape guard rejected NaN/negative count but let a non-numeric-but-comparable confidence (e.g. NaN via >= coercion quirks, or an out-of-range non-finite value) slip through the >=/<= comparison. Mirror the count check: require Number.isFinite before bounds-checking. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| // getStats is the LMDB fast path; for RocksDB, estimate-num-keys is O(1) where an exact | ||
| // getKeysCount() would iterate the entire store | ||
| store.estimatedEntryCount = | ||
| store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount; |
There was a problem hiding this comment.
Standing finding (unaddressed across 6+ pushes, still at current head 44f7fff7f): getEstimatedKeyCount() called with no feature-detection
What: store.getEstimatedKeyCount() is still called unconditionally whenever store instanceof RocksDatabase, with no typeof/try-catch guard — unlike the sibling estimateCount integration in this same PR (guarded by typeof store?.estimateCount !== 'function' and try/caught) and unlike RocksIndexStore.estimateCount, which only overrides the base method so its own capability probe doesn't lie.
Why it matters: estimatedEntryCount() is on the hot path for essentially every RocksDB query-plan estimate — it also feeds intersectionEstimate and every non-range branch's heuristic, not just the new range-estimation feature. store is untyped here (implicit any), so tsc gives no compile-time guarantee this method exists on the resolved @harperfast/rocksdb-js build. A platform-specific prebuild or version skew lacking it turns this into a TypeError on effectively every RocksDB query — not a graceful degradation — contradicting this PR's own DESIGN.md addition ("Capability is feature-detected per store … so a store that answers differently … degrades the plan … instead of NaN-poisoning condition ordering"), written one paragraph away from this exact ungated call.
Flagged by gemini-code-assist (high) on 2026-08-13, and by this bot on 2026-08-13, 2026-08-26, and 2026-08-27 (three times now). The three commits pushed since the last review (a824835d3, 30c51bb0f, 44f7fff7f) all land above this line (negation short-circuit, count/confidence shape validation) and don't touch it.
Also still open from gemini's original comment on this line: the LMDB fallback branch, store.getStats().entryCount, has no nullish guard on getStats() either (pre-existing behavior, not introduced by this PR, but adjacent to the same line).
Suggested fix: typeof store.getEstimatedKeyCount === 'function' ? store.getEstimatedKeyCount() : store.getKeysCount(), matching the defensive pattern already used for estimateCount in this PR.
…#2479) #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>
) estimatedEntryCount() called RocksDatabase.getKeysCount(), a synchronous native iteration of the entire key space, once per store per 10-second memo window. On a 28.6M-row table that is ~11s on the main thread, and the memo never took effect there: `now` is sampled before the call, so `now + 10000` is already in the past when it returns. The query planner calls it once per additional condition through intersectionEstimate and the range heuristics, so an N-condition query paid N-1 full scans -- a 10-condition operation measured ~100s of main-thread stall with the Operations API unresponsive throughout. Read `rocksdb.estimate-num-keys` instead, which is O(1) and returned the identical count on the affected table. estimate-num-keys skews high on overwrite/delete-heavy data until compaction; every consumer is a relative-ordering or explicitly-estimated path, so the accuracy-per-cost trade is deliberate. It also reports 0 for a *populated* table once accumulated tombstones reach its non-deletions, which getKeysCount() never did -- there a 0 meant a genuinely empty store. Zero is still the truthful count, and Table.ts reports it verbatim as an estimated pagination total, so it is left alone and the two arithmetic sites that cannot take it are guarded instead: - the AND-group branch of estimateConditionForTable divides by it, yielding Infinity; - 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 matters to 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 and the first non-matching record flips the request onto searchByIndex plus a full Set build. Guarded, the same group estimates 200 and the threshold is a usable 12. It uses 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 -- but a negative cardinality is not a value to hand to a caller: on main Table.ts reports it verbatim as an estimated pagination total. It is clamped with Math.max at 0, not 1, since `|| 1` does not catch a negative and a table whose rows are all null has a true `ne null` count of zero. Backport of the estimatedEntryCount portion of #2163, whose helper and intersectionEstimate are taken byte-identical; the two arithmetic guards are the addition, and go to main separately. getEstimatedKeyCount() has been present since rocksdb-js 2.4.x, so this needs no dependency bump; the statistical range-estimate portion of #2163, which does require 2.8.0, is deliberately not included. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
) estimatedEntryCount() called RocksDatabase.getKeysCount(), a synchronous native iteration of the entire key space, once per store per 10-second memo window. On a 28.6M-row table that is ~11s on the main thread, and the memo never took effect there: `now` is sampled before the call, so `now + 10000` is already in the past when it returns. The query planner calls it once per additional condition through intersectionEstimate and the range heuristics, so an N-condition query paid N-1 full scans -- a 10-condition operation measured ~100s of main-thread stall with the Operations API unresponsive throughout. Read `rocksdb.estimate-num-keys` instead, which is O(1) and returned the identical count on the affected table. estimate-num-keys skews high on overwrite/delete-heavy data until compaction; every consumer is a relative-ordering or explicitly-estimated path, so the accuracy-per-cost trade is deliberate. It also reports 0 for a *populated* table once accumulated tombstones reach its non-deletions, which getKeysCount() never did -- there a 0 meant a genuinely empty store. Zero is still the truthful count, and Table.ts reports it verbatim as an estimated pagination total, so it is left alone and the two arithmetic sites that cannot take it are guarded instead: - the AND-group branch of estimateConditionForTable divides by it, yielding Infinity; - 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 matters to 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 and the first non-matching record flips the request onto searchByIndex plus a full Set build. Guarded, the same group estimates 200 and the threshold is a usable 12. It uses 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 -- but a negative cardinality is not a value to hand to a caller: on main Table.ts reports it verbatim as an estimated pagination total. It is clamped with Math.max at 0, not 1, since `|| 1` does not catch a negative and a table whose rows are all null has a true `ne null` count of zero. Backport of the estimatedEntryCount portion of #2163, whose helper and intersectionEstimate are taken byte-identical; the two arithmetic guards are the addition, and go to main separately. getEstimatedKeyCount() has been present since rocksdb-js 2.4.x, so this needs no dependency bump; the statistical range-estimate portion of #2163, which does require 2.8.0, is deliberately not included. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
Query-planner integration of rocksdb-js #778 — feat: statistical range key-count estimation.
Range comparators (
starts_with/prefix,betweenand thegele/gelt/gtlt/gtlefamily,lt/le/gt/ge) have always been estimated as fixed fractions of the table size (5%/10%/30%) — the code even said "just arbitrarily guess". That makes condition ordering, the adaptive filter→index switch, and #2147'scount=estimatedtotals wildly wrong for any real range. When the store providesestimateCount({ count, confidence }):estimateConditionnow estimates the actual range the search would iterate (range construction mirrorssearchByIndex's comparator switch, including the encoded prefix upper bound forstarts_withand[value, null]→[value, MAXIMUM_KEY]forprefix), blended with the old fraction heuristic by the estimate's confidence — a low-confidence estimate (block-granular tiny range, open-ended complement subtraction, degraded statistics) leans on the previous behavior instead of replacing it. Estimates never go below 1; primary-key ranges estimate against the primary store.Infinityat the root ofestimateConditionForTable, following the existing filter-only convention (contains/ends_with): the negated flag always forcesneedFullScan, so a (possibly narrow) positive-range estimate must never win the driving-condition ordering. This also fixes a pre-existing defect where negatedequalsestimated its (possibly tiny) positivegetValuesCount.RocksIndexStoregains anestimateCountoverride translating value-space bounds to its composite[indexedValue, primaryKey]keys ([value, MAXIMUM_KEY]). It andgetRangeshare onetranslateIndexBoundshelper, so "estimate the range you execute" is structural rather than two copies that can drift (reverse branch included).estimatedEntryCountswitches from an exactgetKeysCount()full-store iteration (re-run every 10 seconds per store) to the O(1)getEstimatedKeyCount()property read — this needs no dependency bump and pays off immediately on large tables. TheintersectionEstimatedivisor is floored at 1.The dependency is now pinned at
@harperfast/rocksdb-js@^2.8.0, which ships #778, so this path is live. The per-store capability probe (typeof store.estimateCount === 'function') stays because LMDB-backed and custom index stores do not implement it, and the estimate path validates the{count, confidence}shape and try/catches the native call, so a store that answers differently — or one closing concurrently — degrades to the fraction heuristic rather than NaN-poisoning plan ordering or failing the request. Bounds longer thanMAX_SEARCH_KEY_LENGTHalso fall back — execution truncates + filters there, so the executed range is wider than the estimable one.For the human reviewer
round(confidence × estimate + (1 − confidence) × fraction-heuristic). At confidence 1 the statistical estimate wins outright; at 0 the old behavior is preserved. A hard threshold was rejected as a cliff.estimated_countsemantics: for negated conditions it is now execution-cost ordering (Infinity), not result cardinality — consistent with the other full-scan comparators. If feat(rest): total-count pagination viaPrefer: count=(Content-Range) #2147 later wants a cardinality for negated queries, complement arithmetic can be added on its side.estimatedEntryCountsemantics shift slightly and this is the one behavior change live before the dependency bump:estimate-num-keysskews high on overwrite/delete-heavy data until compaction, where the old exact scan did not. All consumers are relative-ordering or explicitly-estimated paths; the accuracy-per-cost trade is deliberate (review kept it as a noted minor).sort,equals,in,nebranches untouched —equalsselectivity still uses the exact per-valuegetValuesCount.lt/lemust carrysearchByIndex'sstart: truelower bound. Without it the estimate counts the[null, primaryKey]entries anindexNullsindex holds and execution skips (truesorts abovenull) — measured at 21× inflation on an index that is 99% nulls, i.e. worse than the flat heuristic it replaces. Pinned by a real-store test.Verification
unitTests/resources/estimateRangeCondition.test.js— 26 passing on RocksDB, 19 passing / 7 pending underHARPER_STORAGE_ENGINE=lmdb(the real-store block gates on the table's actual index store, not the Rocks prototype). 13 stub-store tests — dispatch, per-comparator range construction (asserting the exact ranges passed to the store), the confidence blend (1 / 0 / 0.5), primary-key routing, capability-absent fallback, the ≥1 floor, negated→Infinity(range and equals), malformed-shape fallback (bare number, NaN, missing/out-of-range confidence, null), thrown-native-call fallback, and over-length-bound fallback. These run on the stock dependency. Plus 6RocksIndexStorebound-translation tests assertingestimateCountandgetRangeforward identical bounds, and 7 end-to-end tests against real tables with secondary indexes: width-orderedbetweenestimates, real prefix ranges forstarts_with, ordered open-range tails, three cases pinned within 10× of the executed result count (secondarybetween, secondarylt, primary-keylt), and the sparse-indexltnull-exclusion case.npm run test:unit:resources: 1581 passing. One unrelated pre-existing failure,requestPathRetryExhaustion.test.js— a background analytics timer callsstat(getLogFilePath())withundefinedduring the test's ~20s backoff window and leaks an unhandled rejection; reproduced identically with this branch's source reverted to the pre-change tree.unitTests/resources/conditionsArrayMutation.test.js(planner-adjacent) passes. Full unit gates rely on CI (local runs hit shared-lock contention per prior experience).tscbuild clean; oxlint clean; prettier applied to changed files.Prefer: count=(Content-Range) #2147'scount=estimatedtotals improve automatically once both PRs land. No user-facing API/config change → no documentation PR needed.Review coverage
Generated by Claude (Fable 5). Cross-model pre-push review via
prepush-review.mjs, six rounds:d05e194): Codex (graded) + Harper-domain adjudication — Gemini timed out, cursor-composer failed (output format), grok pruned. Two majors fixed in round 2: negated ranges receiving their positive-range estimate while execution full-scans, and blind trust in an unshipped dependency shape (caret-activation NaN risk). Minors fixed: no try/catch around the native call,MAX_SEARCH_KEY_LENGTHtruncation divergence forstarts_with, divisor floor.38052b9): Codex (Gemini failed again). Confirmed the guards; kept a major on the complement inversion (a wide positive range still made its negation look cheap) — fixed in round 3 by adopting theInfinityfull-scan convention.01b8d693c): Codex on the negation-convention fix + DESIGN.md.30c51bb0f): Codex + Gemini, full re-review (the rebase moved the base commit, so the prior codex session wasn't a resumable ancestor). Fixed: negated range conditions were computing the nativeestimateCountbefore discarding it for theInfinityshort-circuit (now short-circuits first); the malformed-shape guard didn't reject a negativecount. Domain adjudication was pruned from these rounds — this environment's ~10-minute foreground command cap is smaller than codex+gemini's own concurrent runtime on this diff's risk/size (~460–555s), leaving no budget left for the 900s-budgeted adjudication pass; graded+gemini still ran to completion and supply independent outside coverage on their own.44f7fff7f): Codex + Gemini on the confidence-validation fix below; no regressions found.Fixed:
estimateRangeCondition's malformed-shape guard checkedNumber.isFinite(count)but only bounds-checkedconfidencewith>=/<=(which coerce), letting a non-finite confidence slip through — now requiresNumber.isFinite(confidence)too.Declined, pre-existing/mirrors execution (not introduced by this PR — the estimate path intentionally replicates
searchByIndex's own range/bound construction so the estimated range never diverges from the executed one):prefix: [](empty array) writing to the array's"-1"property instead of a real bound (mirrors an identical, older bug insearchByIndex's ownprefixcase,search.ts:357-365); the string-length truncation guard not checking array bounds (mirrorssearchByIndex's own truncation guard,search.ts:417-425— arrays are never truncated by execution either);lt/le'sstart: trueallegedly excluding indexedfalsevalues (mirrorssearchByIndex's ownlt/leconstruction verbatim — pre-existing behavior, not new);RocksIndexStore's bound-translation object spread setting explicitundefinedkeys (this predates the PR —getRangedid the same spread before this PR extracted it into the sharedtranslateIndexBoundshelper).Declined, false positive: a Gemini finding that
estimateRangeConditionis reachable (and dead) forsearchType === 'sort'—sorthas its own dedicated branch inestimateConditionForTableand never callsestimateRangeCondition.Declined, judgment call / out of scope for this PR: implicit-primary-key and relationship-path range conditions fall back to the fraction heuristic instead of the statistical estimate (safe, just a missed optimization — extending coverage there is a feature addition, not this PR's scope); the broad
try/catcharound the nativeestimateCountcall masking any thrown error as a closing-store degrade rather than distinguishing a real engine defect (deliberate: this is a planner-optimization path with a safe fallback, not a correctness path); no end-to-end planner-integration test proving a multi-condition plan's driving-condition ordering actually changes (test-coverage gap, not a defect); moving thefraction * estimatedEntryCount(...) + 1fallback calculation insideestimateRangeConditionto dedup 3 call sites (legitimate DRY suggestion, but a deliberate function-contract choice carried through multiple review rounds).Accepted, not changed (with rationale): the per-condition native probe on the planning path (bounded, ~10µs, memoized per condition);
estimate-num-keyscardinality skew on overwrite-heavy tables (deliberate O(1) trade, floored divisor, relative-ordering consumers).Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=6 @ 44f7fff
Human-Review-Need: 4 @ 44f7fff