Skip to content

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

Draft
kriszyp wants to merge 4 commits into
mainfrom
kris/estimate-count-integration
Draft

Use storage-level statistical range estimates in the query planner#2163
kriszyp wants to merge 4 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]), mirroring its getRange — assigned conditionally so typeof store.estimateCount === 'function' remains a capability probe.
  • 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.

Everything is feature-detected and defensive: on the currently-pinned rocksdb-js the planner behaves exactly as before (verified against the stock dependency), and the estimate path validates the {count, confidence} shape and try/catches the native call, so a future caret-activated dependency (or a concurrently closing store) 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.
  • The E2E tests self-skip until the rocksdb-js dependency ships test(integration): port Northwind test suite to self-contained framework #778 (they were executed locally against the linked branch — see Verification). The dependency bump PR is the activation moment and should pin the minimum version that defines the {count, confidence} contract.

Verification

  • unitTests/resources/estimateRangeCondition.test.js: 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 3 end-to-end tests against real tables with secondary indexes (capability-gated): width-ordered between estimates, real prefix ranges for starts_with, ordered open-range tails.
  • Run both ways locally: with the linked rocksdb-js test(integration): port Northwind test suite to self-contained framework #778 branch (16/16 passing — real composite-key index estimates confirmed) and with the stock npm dependency (13 passing / 3 skipped — fallback behavior identical to the old heuristics).
  • 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 capability-gated E2E 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 plus the dependency bump. 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, three 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, final head): Codex on the negation-convention fix + DESIGN.md.

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

Human-Review-Need: 3 @ f917da4

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

Reviewed. One finding posted inline on resources/search.ts:1716: getEstimatedKeyCount() is called with no feature-detection/fallback, unlike the sibling estimateCount integration in this same PR.

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