Skip to content

feat: statistical range key-count estimation (getEstimatedKeyCount range support + CountEstimator) - #778

Open
kriszyp wants to merge 6 commits into
mainfrom
kris/range-count-estimate
Open

feat: statistical range key-count estimation (getEstimatedKeyCount range support + CountEstimator)#778
kriszyp wants to merge 6 commits into
mainfrom
kris/range-count-estimate

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

Adds a statistical range key-count estimate that never iterates, for query planning and pagination reporting (HarperFast/harper#2147). Closes #205, and supersedes the earlier attempt in #311.

Native estimateCount(start?, end?) combines three RocksDB primitives:

  • GetApproximateMemTableStats — returns an entry count for the memtable portion directly.
  • GetApproximateSizes (files only, 10% error margin) — approximate on-disk bytes covered by the range.
  • GetPropertiesOfTablesInRange — per-SST num_entries / num_deletions / block sizes for only the SSTs overlapping the range, giving a range-local live-entry density ((entries − deletions) / file bytes) that converts bytes → count.

Using range-local table properties (instead of #311's whole-CF cached mean with write-count invalidation) keeps the density honest when entry sizes vary across the keyspace and needs no cache or invalidation hooks. Open-ended ranges are estimated as estimate-num-keys minus the complementary range — an empty slice is the smallest key, so it must never be passed as an upper bound (a correctness bug in #311's open-ended path). Inverted, empty, and zero-length bounds are guarded (they would otherwise underflow RocksDB's uint64 offset subtraction).

Public API (shape chosen by Kris: estimates carry a trust signal):

  • db.estimateCount(options?: RangeOptions){ count, confidence } (CountEstimate). confidence is a heuristic 0–1 trust indicator, exactly 1 only when the count is exact — derived natively from the estimate's resolution (data-block/memtable-sampling granularity relative to the count), the tombstone fraction of overlapping SSTs, complement-subtraction error for start-only ranges, and failed statistics calls (a degraded estimate caps at 0.1; a failed estimate-num-keys read returns {0, 0}, not a confident empty). exclusiveStart/inclusiveEnd are honored via the bytewise-successor zero byte.
  • db.getEstimatedKeyCount()unchanged original no-arg signature (cheap estimate-num-keys alias), so existing callers are untouched and one name doesn't cover two cost profiles.
  • db.createCountEstimator(options?)CountEstimator — rides an iterator: advance(lastKey, count) checkpoints progress (e.g. once per page), estimate() returns {count, confidence} = exact traversed + remainder calibrated by the observed actual/estimated ratio (clamped 8×), memoized per checkpoint; confidence is the exactness-weighted blend, capped at 0.999 until finish() declares the traversal complete (then exact with confidence 1). Supports reverse and the range bound flags.

For the human reviewer

  • Cost model (the review's one carried major, accepted + documented): a bounded estimate enumerates table properties for every SST overlapping the range, synchronously on the JS thread — table-property reads go through the table cache and can do I/O for cold files, and a start-only range does the work of its complement. Inherent to the range-local-density design (the cached whole-CF mean alternative is what made Initial attempt at getApproximateCount for #205 #311 wrong); an async variant is additive later. Callers should prefer bounded ranges.
  • confidence is now API surface: callers will encode thresholds against it, so retuning the formula changes their behavior (review ledger point). The semantics doc deliberately promises only "heuristic ordering signal, 1 = exact" — the formula itself is not contract.
  • Whole-DB vs full-range disagreement (ledger): estimateCount() no-bounds uses estimate-num-keys while a bounded full range uses bytes×density; they can disagree. Deliberate — the no-bound path stays O(1) and matches getEstimatedKeyCount().
  • Calibration constants (CALIBRATION_MIN_TRAVERSED = 16, 8× clamp) are judgment calls; options can be added later.
  • Caller-owned progress contract: advance() trusts the caller (monotonic cursors, no double-reporting); a wrapping-iterator variant would be additive.
  • Estimates deliberately ignore transaction state (committed statistics only; covered by a test).

Verification

  • test/estimate-count.test.ts (12 tests): flushed / memtable-only / mixed ranges, open-ended both sides, empty DB (confident 0), inverted range (exact 0), zero-length native bounds, uncommitted-transaction exclusion, monotonic scaling with range width, estimator refinement (confidence must increase), reverse iteration, finish() exactness, and a paginated loop driven to completion (pre-finish confidence < 1, exact total after finish()). Full suite green at every commit (latest: 767 passed / 2 skipped, 56 files).
  • Accuracy/perf (500k entries, varied value sizes 20–220B, flushed): counts within −0.1% to −5.5% of exact across full/half/tenth/1% ranges at 9–83µs vs 1–85ms exact scans (~1000×). Confidence measured: 0.999 full/half ranges, 0.88 at 1%, 0.21 on a 50-key range (~2× over-report, correctly distrusted), 0.13 on a start-only tail (+39% error from complement subtraction, correctly distrusted). Estimator converged 4.2% → 1.4% error by 50% traversal. (Loose 2× bounds asserted in CI; these are measured local numbers.)
  • Downstream validation: the harper planner integration (follow-up PR) was run end-to-end against this branch linked into a harper worktree — real tables, secondary indexes (composite [value, primaryKey] keys through RocksIndexStore), width-ordered estimates confirmed.
  • Native GoogleTest target untouched (N-API-layer change; the vitest suite is end-to-end through the real binding).

Review coverage

Generated by Claude (Fable 5). Cross-model pre-push review via prepush-review.mjs, six rounds:

  • Round 1 (full, 1c4be8d): Codex (graded) + Gemini + Harper-domain adjudication — cursor-composer failed (output-format rejection), cursor-grok pruned. Fixed: inverted-range guard, exclusiveStart/inclusiveEnd, estimator forward off-by-one, finish(), memoization, tempered cost claims.
  • Round 2 (delta, 4481bc4): Codex (resumed) + Gemini. Fixed: zero-length end bound bypassing the guard on the native surface (napi nullptr for empty buffers).
  • Round 3 (delta, 0ddaf72): Codex + Gemini — hardening confirmed clean.
  • Round 4 (full, b2cb53a, API change): Codex (graded) + Gemini + Harper-domain — cursor-composer failed again, grok pruned. Adjudicated major fixed in round 5: failed statistics reported as confident estimates.
  • Round 5 (delta, 0548d42): Codex (Gemini leg returned no output this round). Follow-ups fixed in round 6: a successful zero estimate-num-keys claimed exactness; partial null table-properties collections didn't degrade.
  • Round 6 (delta, final head 89d437b): Codex + Gemini — verdict COMMENTS, both fixes confirmed, no new findings.

Accepted, not changed (with rationale): silent-degrade carries low confidence instead of an event; sync-on-JS-thread cost model (documented); caller-owned progress contract; calibration reads current state so concurrent writes behind the cursor can swing a checkpoint (inherent to statistical estimates on a live database, bounded by the 8× clamp); no native GoogleTest for the estimator math (it lives in an N-API translation unit, which cannot link into the gtest target — the vitest suite covers it end-to-end through the real binding).

Human-Review-Need: 4 @ 89d437b

kriszyp and others added 3 commits August 13, 2026 10:13
Adds Database::EstimateCount — a no-iteration range key-count estimate
built from RocksDB statistics: GetApproximateMemTableStats supplies the
memtable entry count directly, and the SST portion converts approximate
file bytes in range (GetApproximateSizes) to entries via the live-entry
density of only the SSTs overlapping the range
(GetPropertiesOfTablesInRange: (num_entries - num_deletions) / file
bytes). Open-ended ranges subtract the complementary range from
estimate-num-keys rather than passing an empty upper-bound slice (which
would denote the smallest key).

Public API: getEstimatedKeyCount(options?: RangeOptions) extends the
existing whole-DB method with range support, and createCountEstimator()
returns a CountEstimator that rides an iterator: advance(lastKey, n)
checkpoints progress and estimate() returns the exact traversed count
plus a remainder estimate calibrated by the observed actual/estimated
ratio over the traversed portion, converging toward the exact total.

Closes #205

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- guard inverted/empty bounded ranges (GetApproximateSizes would
  underflow end-start offsets in uint64) — returns 0
- honor exclusiveStart/inclusiveEnd by appending the bytewise-successor
  zero byte to the encoded bound
- CountEstimator: exclude the cursor entry from the remainder (forward
  mode double-counted it, blocking convergence), add finish() as the
  completion signal, memoize estimate() per checkpoint, and document the
  caller-owned progress contract
- temper the cost claims: scales with overlapping SSTs, table-property
  reads can do I/O for cold files, start-only ranges do complement work

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
napi can return a null data pointer for a zero-length buffer, which the
previous guard read as an omitted bound — an empty end bound (below
every key) became a whole-database estimate on the NativeDatabase
surface (encodeKey shields the public API). Track presence explicitly:
empty end returns 0, empty start is the minimum key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 implements a non-iterating key-count estimation feature for RocksDB ranges, exposing getEstimatedKeyCount with range options and introducing a new CountEstimator class to progressively refine estimates during iteration. The review feedback correctly identifies that CountEstimator currently discards the exclusiveStart and inclusiveEnd options from CountEstimatorOptions, and suggests storing and utilizing these options in the estimate() method to ensure bounds are correctly respected.

Comment thread src/count-estimator.ts
Comment on lines +48 to +63
export class CountEstimator {
#store: Store;
#start: Key | Uint8Array | undefined;
#end: Key | Uint8Array | undefined;
#reverse: boolean;
#cursor: Key | Uint8Array | undefined;
#traversed = 0;
#finished = false;
#memoized: number | undefined;

constructor(store: Store, options?: CountEstimatorOptions) {
this.#store = store;
this.#start = options?.start;
this.#end = options?.end;
this.#reverse = options?.reverse ?? false;
}

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

The CountEstimator constructor currently discards the exclusiveStart and inclusiveEnd options from CountEstimatorOptions. This causes the estimator to ignore these bounds during initial estimation and subsequent range segment queries, leading to inaccurate count estimates. We should store these options as private fields.

Suggested change
export class CountEstimator {
#store: Store;
#start: Key | Uint8Array | undefined;
#end: Key | Uint8Array | undefined;
#reverse: boolean;
#cursor: Key | Uint8Array | undefined;
#traversed = 0;
#finished = false;
#memoized: number | undefined;
constructor(store: Store, options?: CountEstimatorOptions) {
this.#store = store;
this.#start = options?.start;
this.#end = options?.end;
this.#reverse = options?.reverse ?? false;
}
export class CountEstimator {
#store: Store;
#start: Key | Uint8Array | undefined;
#end: Key | Uint8Array | undefined;
#exclusiveStart: boolean;
#inclusiveEnd: boolean;
#reverse: boolean;
#cursor: Key | Uint8Array | undefined;
#traversed = 0;
#finished = false;
#memoized: number | undefined;
constructor(store: Store, options?: CountEstimatorOptions) {
this.#store = store;
this.#start = options?.start;
this.#end = options?.end;
this.#exclusiveStart = options?.exclusiveStart ?? false;
this.#inclusiveEnd = options?.inclusiveEnd ?? false;
this.#reverse = options?.reverse ?? false;
}

Comment thread src/count-estimator.ts Outdated
Comment on lines +98 to +113
if (this.#cursor === undefined) {
return this.#store.estimateCount({ start: this.#start, end: this.#end });
}
if (this.#memoized !== undefined) {
return this.#memoized;
}

// The cursor entry itself belongs to the traversed side, so the
// remainder excludes it in both directions (an inclusive lower bound
// forward would count it twice and block convergence).
const traversedRange = this.#reverse
? { start: this.#cursor, end: this.#end }
: { start: this.#start, end: this.#cursor, inclusiveEnd: true };
const remainingRange = this.#reverse
? { start: this.#start, end: this.#cursor }
: { start: this.#cursor, end: this.#end, exclusiveStart: true };

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

Update the estimate() method to utilize the stored exclusiveStart and inclusiveEnd options when querying estimateCount for the initial estimate, the traversed range, and the remaining range. This ensures that the bounds are correctly respected throughout the estimation process.

		if (this.#cursor === undefined) {
			return this.#store.estimateCount({
				start: this.#start,
				end: this.#end,
				exclusiveStart: this.#exclusiveStart,
				inclusiveEnd: this.#inclusiveEnd,
			});
		}
		if (this.#memoized !== undefined) {
			return this.#memoized;
		}

		// The cursor entry itself belongs to the traversed side, so the
		// remainder excludes it in both directions (an inclusive lower bound
		// forward would count it twice and block convergence).
		const traversedRange = this.#reverse
			? { start: this.#cursor, end: this.#end, inclusiveEnd: this.#inclusiveEnd }
			: { start: this.#start, end: this.#cursor, exclusiveStart: this.#exclusiveStart, inclusiveEnd: true };
		const remainingRange = this.#reverse
			? { start: this.#start, end: this.#cursor, exclusiveStart: this.#exclusiveStart }
			: { start: this.#cursor, end: this.#end, exclusiveStart: true, inclusiveEnd: this.#inclusiveEnd };

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 23.96K ops/sec 41.73 39.80 2,060.798 0.148 119,823
🥈 rocksdb 2 11.19K ops/sec 89.40 85.99 32,217.381 1.27 55,930

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.57K ops/sec 35.01 33.88 556.112 0.103 142,832
🥈 rocksdb 2 11.01K ops/sec 90.79 87.08 610.984 0.051 55,071

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 25.32K ops/sec 39.50 36.78 1,746.676 0.295 126,581
🥈 rocksdb 2 15.25K ops/sec 65.56 57.89 1,082.197 0.114 76,271

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 386.85 ops/sec 2,584.982 142.527 28,459.004 10.77 775
🥈 lmdb 2 26.77 ops/sec 37,351.982 407.624 1,167,316.973 136.225 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 37.52K ops/sec 26.66 12.28 13,657.168 0.586 187,584
🥈 lmdb 2 435.27 ops/sec 2,297.424 155.375 25,341.39 1.63 2,177

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 729.31K ops/sec 1.37 1.20 493.953 0.069 3,646,528
🥈 lmdb 2 418.29K ops/sec 2.39 1.17 9,808.725 1.13 2,091,474

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 834.45 ops/sec 1,198.397 1,010.996 2,921.74 0.452 1,669
🥈 lmdb 2 1.13 ops/sec 881,665.892 833,902.058 979,251.081 3.37 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 23.98K ops/sec 41.71 29.63 592.853 0.573 47,951
🥈 lmdb 2 823.98 ops/sec 1,213.625 206.626 18,070.58 5.40 1,649

Results from commit 71f44b5

Per review feedback on the API shape: a bare number hides how much an
estimate should be trusted. New db.estimateCount(options?) returns
{ count, confidence }; getEstimatedKeyCount() reverts to its original
no-arg number signature (kept as the cheap estimate-num-keys alias), so
one name no longer covers two cost profiles. CountEstimator.estimate()
returns the same shape.

confidence is a heuristic [0,1], exactly 1 only when the count is exact
(finish(), inverted/empty-by-construction ranges). Computed natively
from the estimate components: resolution (SST data-block / memtable
sampling granularity relative to the count), tombstone fraction of the
overlapping SSTs, and for start-only ranges the error compounded by
complement subtraction. Measured on 500k varied entries: 0.999 on
full/half ranges (~3% error), 0.88 at 1%, 0.21 on a 50-key range (~2x
over-report), 0.13 on a start-only tail (+39% — complement subtraction
correctly distrusted); estimator confidence converges to 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kriszyp and others added 2 commits August 13, 2026 12:17
Adjudicated major from the API-shape review: a failed GetApproximateSizes/
GetPropertiesOfTablesInRange silently degraded to a memtable-only count
while the confidence formula still reported it as trustworthy, and a
failed estimate-num-keys property read returned { 0, 1.0 } — a missing
answer dressed as a confidently empty database. Track degradation in
RangeEstimate (capping confidence at 0.1) and return { 0, 0 } for the
failed property read. Also: guard null table-properties entries, honor
the range own exclusiveStart/inclusiveEnd flags in CountEstimator
segments, and cap non-exact estimator confidence at 0.999 so only
finish() and exact-by-construction ranges claim 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…degrade

Follow-ups from the delta review: a successful zero estimate-num-keys
read now reports 0.95 confidence (deletion entries can offset puts, so
even zero is estimated), and a null entry in the table-properties
collection marks the density degraded rather than being silently
skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/**
* Estimates are statistical (block-granular SST approximation + memtable
* skip-list approximation), so assertions use a tolerance factor rather than
* exact bounds. Uniform fixed-size entries keep the real accuracy well inside

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low: core design claim (range-local density under varying entry sizes) is untested

The stated advantage of this approach over #311 is that range-local table properties keep the byte-to-count density honest when entry sizes vary across the keyspace. Every test here uses uniform fixed-size entries (this comment says so explicitly), so the one scenario the design exists to handle has no CI coverage: if the SST density computation regressed to a whole-CF mean (the #311 behavior), these tests would still pass.

Suggested fix: add a case that writes large entries across one key sub-range and small entries across another, flush, then assert estimateCount over each sub-range tracks that sub-range's actual count (within the existing 2x factor). A whole-CF mean would skew both toward the average and fail.


Generated by Barber AI

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.

Investigate more efficient range count

2 participants