Skip to content

Use storage-level statistical range estimates in the query planner - #2163

Merged
kriszyp merged 13 commits into
mainfrom
kris/estimate-count-integration
Aug 31, 2026
Merged

Use storage-level statistical range estimates in the query planner#2163
kriszyp merged 13 commits into
mainfrom
kris/estimate-count-integration

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

Query-planner integration of rocksdb-js #778 — feat: statistical range key-count estimation.

Range comparators (starts_with/prefix, between and the gele/gelt/gtlt/gtle family, 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's count=estimated totals wildly wrong for any real range. When the store provides estimateCount ({ count, confidence }):

  • estimateCondition now estimates the actual range the search would iterate (range construction mirrors searchByIndex's comparator switch, including the encoded prefix upper bound for starts_with and [value, null][value, MAXIMUM_KEY] for prefix), 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.
  • Negated conditions estimate Infinity at the root of estimateConditionForTable, following the existing filter-only convention (contains/ends_with): the negated flag always forces needFullScan, so a (possibly narrow) positive-range estimate must never win the driving-condition ordering. This also fixes a pre-existing defect where negated equals estimated its (possibly tiny) positive getValuesCount.
  • RocksIndexStore gains an estimateCount override translating value-space bounds to its composite [indexedValue, primaryKey] keys ([value, MAXIMUM_KEY]). It and getRange share one translateIndexBounds helper, so "estimate the range you execute" is structural rather than two copies that can drift (reverse branch included).
  • estimatedEntryCount switches from an exact getKeysCount() 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. The intersectionEstimate divisor 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 than MAX_SEARCH_KEY_LENGTH also fall back — execution truncates + filters there, so the executed range is wider than the estimable one.

For the human reviewer

  • The confidence blend is the design decision: 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_count semantics: 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 via Prefer: count= (Content-Range) #2147 later wants a cardinality for negated queries, complement arithmetic can be added on its side.
  • estimatedEntryCount semantics shift slightly and this is the one behavior change live before the dependency bump: estimate-num-keys skews 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).
  • Per-condition native probe (~10µs, uncached beyond the existing per-condition memoization) replaces arithmetic on a 10s-cached integer in query planning. Accepted: it is bounded to one probe per range condition per planned query; a per-store range-estimate cache can be added if profiling ever shows it.
  • sort, equals, in, ne branches untouchedequals selectivity still uses the exact per-value getValuesCount.
  • lt/le must carry searchByIndex's start: true lower bound. Without it the estimate counts the [null, primaryKey] entries an indexNulls index holds and execution skips (true sorts above null) — 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 under HARPER_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 6 RocksIndexStore bound-translation tests asserting estimateCount and getRange forward identical bounds, and 7 end-to-end tests against real tables with secondary indexes: width-ordered between estimates, real prefix ranges for starts_with, ordered open-range tails, three cases pinned within 10× of the executed result count (secondary between, secondary lt, primary-key lt), and the sparse-index lt null-exclusion case.
  • npm run test:unit:resources: 1581 passing. One unrelated pre-existing failure, requestPathRetryExhaustion.test.js — a background analytics timer calls stat(getLogFilePath()) with undefined during 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).
  • tsc build clean; oxlint clean; prettier applied to changed files.
  • End-to-end route: the real-store block is the integration evidence; feat(rest): total-count pagination via Prefer: count= (Content-Range) #2147's count=estimated totals 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:

  • Round 1 (full, 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_LENGTH truncation divergence for starts_with, divisor floor.
  • Round 2 (delta, 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 the Infinity full-scan convention.
  • Round 3 (delta, pre-rebase final head 01b8d693c): Codex on the negation-convention fix + DESIGN.md.
  • Rounds 4–5 (post-rebase full, 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 native estimateCount before discarding it for the Infinity short-circuit (now short-circuits first); the malformed-shape guard didn't reject a negative count. 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.
  • Round 6 (delta, final head 44f7fff7f): Codex + Gemini on the confidence-validation fix below; no regressions found.

Fixed: estimateRangeCondition's malformed-shape guard checked Number.isFinite(count) but only bounds-checked confidence with >=/<= (which coerce), letting a non-finite confidence slip through — now requires Number.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 in searchByIndex's own prefix case, search.ts:357-365); the string-length truncation guard not checking array bounds (mirrors searchByIndex's own truncation guard, search.ts:417-425 — arrays are never truncated by execution either); lt/le's start: true allegedly excluding indexed false values (mirrors searchByIndex's own lt/le construction verbatim — pre-existing behavior, not new); RocksIndexStore's bound-translation object spread setting explicit undefined keys (this predates the PR — getRange did the same spread before this PR extracted it into the shared translateIndexBounds helper).

Declined, false positive: a Gemini finding that estimateRangeCondition is reachable (and dead) for searchType === 'sort'sort has its own dedicated branch in estimateConditionForTable and never calls estimateRangeCondition.

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/catch around the native estimateCount call 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 the fraction * estimatedEntryCount(...) + 1 fallback calculation inside estimateRangeCondition to 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-keys cardinality 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

@kriszyp
kriszyp requested a review from heskew August 13, 2026 18:42

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread resources/search.ts
Comment on lines +1715 to +1716
store.estimatedEntryCount =
store instanceof RocksDatabase ? store.getEstimatedKeyCount() : store.getStats().entryCount;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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
  1. 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.

Comment thread resources/RocksIndexStore.ts Outdated
Comment on lines +73 to +78
(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 });
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
};

Comment thread resources/search.ts
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Blocker: resources/search.ts:1726 still calls store.getEstimatedKeyCount() with no feature-detection or fallback, unlike every other rocksdb-js capability probe in this PR — see inline comment. Flagged by gemini and this bot across all prior pushes (2026-08-13, 2026-08-26, 2026-08-27 ×2) and remains unaddressed at the current head; the three commits since the last review don't touch this line.

@kriszyp
kriszyp force-pushed the kris/estimate-count-integration branch from f917da4 to 9b2cff2 Compare August 15, 2026 12:05
@kriszyp
kriszyp marked this pull request as ready for review August 26, 2026 12:20
@kriszyp
kriszyp requested a review from cb1kenobi August 26, 2026 12:21
@kriszyp
kriszyp force-pushed the kris/estimate-count-integration branch from face448 to c362016 Compare August 26, 2026 12:58
@socket-security

socket-security Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedmsgpackr@​2.0.5 ⏵ 2.0.699100100 +193 +4100
Updated@​harperfast/​rocksdb-js@​2.7.1 ⏵ 2.8.095 +1910010099100

View full report

Comment thread resources/search.ts
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 anytsconfig.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.

@kriszyp
kriszyp force-pushed the kris/estimate-count-integration branch from ac31287 to 01b8d69 Compare August 27, 2026 14:41
Comment thread resources/search.ts
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

kriszyp and others added 12 commits August 27, 2026 10:16
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>
@kriszyp
kriszyp force-pushed the kris/estimate-count-integration branch from 01b8d69 to 30c51bb Compare August 27, 2026 17:08
Comment thread resources/search.ts
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Comment thread resources/search.ts
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kriszyp
kriszyp merged commit 7acf008 into main Aug 31, 2026
50 of 51 checks passed
@kriszyp
kriszyp deleted the kris/estimate-count-integration branch August 31, 2026 13:14
kriszyp added a commit that referenced this pull request Sep 3, 2026
…#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>
kriszyp added a commit that referenced this pull request Sep 3, 2026
)

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>
kriszyp added a commit that referenced this pull request Sep 3, 2026
)

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant