Repository navigation
Pin every Table.getRecordCount caller shape against its estimate-vs-exact contract - #3174
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request documents the heuristic nature of getRecordCount in DESIGN.md and introduces a comprehensive test suite in getRecordCountCallerImpact.test.js to verify its behavior under various conditions. The review feedback recommends optimizing the test setup by batching put operations instead of executing thousands of concurrent requests via Promise.all, which can cause performance overhead or timeouts in CI environments.
kriszyp
force-pushed
the
test/record-count-caller-impact
branch
2 times, most recently
from
October 23, 2026 13:59
6bf356e to
d0faea8
Compare
…xact contract
getRecordCount's internal estimator mechanics are already covered by
getRecordCount.test.js. Nothing pinned the CALLER-visible contract: the
return shape ({ recordCount, estimatedRange? }), when estimatedRange
appears, the MIN_ESTIMATOR_SAMPLE=1000 floor below which the count is
always exact, the exactCount escape hatch, and the describe_table
(exact_count/skip_record_count) pass-through.
Research finding: the caller-visible contract has been stable since
30f4df6 (2023-10-19). The internal data source feeding the estimate
on RocksDB (an exact getKeysCount() scan vs. a cheap
createCountEstimator/estimateCount sample) was swapped by 921a635 on
the v5.3.x line and independently by #2612 on the v5.2.x line one day
later; v5.2.15, v5.3.0 and main are byte-identical for this function.
The PR body's Caller impact rule has the full analysis.
Also documents the invariant in resources/DESIGN.md: estimatedRange
is sized to contain the true count, not to bound recordCount's error.
A caller that compares recordCount for equality, or does exact page
math on it, relies on a guarantee the contract never made -- pinned
with a forced-wrong estimator base so the divergence is deterministic
rather than a matter of luck.
Dispatch-Task: harper-upgrade-rule-record-count-530
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
…w found wrong Round-1 review (graded + Gemini + Cursor Composer + domain adjudication) found: - DESIGN.md claimed estimatedRange is guaranteed to contain the true count; Table.ts's own comment at the computation calls it a widened heuristic, not a guaranteed bound, and an underestimated base can produce a range that excludes the truth. Reworded, and added a test that forces that underestimate and asserts the upper bound actually lands below the live count. - DESIGN.md called REST's describe-without-id path a "thin pass-through" of the same shape as describe_table/describe_all. It differs in three ways: camelCase vs snake_case field names, gated by a context flag vs always running, and no exact_count/skip_record_count equivalent. Reworded to state the three differences explicitly. - The exact_count test never proved forwarding -- a 3,000-row scan finishes within the default budget regardless, so the default call being exact proved nothing. Now spies on the options getRecordCount receives and asserts exactCount flips between calls. - No describe_table call ever produced an estimated_record_range, so that half of the pass-through claim was unchecked. Added a test that stubs getRecordCount's return value directly (describe_table exposes no timeLimit knob to force the real sampling path deterministically). - Trimmed comments that quoted a production line verbatim (goes stale) or carried commit/PR history better kept in the PR description. Dispatch-Task: harper-upgrade-rule-record-count-530 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
…ess, and comment nits Round 2 (codex graded + Gemini + Cursor Composer + domain adjudication) found: - DESIGN.md said recordCount had no promise of lying inside estimatedRange; Table.ts actually clamps it there unconditionally (resources/Table.ts:8123-8126). Also, "both endpoints are themselves estimates" is wrong for `lower`, which is a direct count of sampled live rows, not an estimate -- only `upper` is estimate-derived and can undershoot. Reworded with the precise claim, and reordered to lead with the heuristic framing rather than "meant to bracket" (Cursor's suggestion). - DESIGN.md's escape-condition description omitted the 20-checkpoint ceiling, which fires independently of the halfway test. Added. - The default-path describe_table test asserted an exact count on a 3,000-row table with no way to force the sampling path, so a contended CI run could legitimately estimate and fail for a reason unrelated to the actual claim (option forwarding). Fixed by substituting timeLimit: Infinity on the delegated call while still capturing the options describe_table itself passed. - One comment claimed the forced base (2,100) was "fewer than half" of 3,000 live rows, which is false (2,100 > 1,500). Corrected. Trimmed two more comments that restated what the adjacent assertion already shows. - Gemini's assert.equal(undefined, false) concern was checked against dataLayer/schemaDescribe.ts:274 (`!!describeTableObject.exact_count`, always boolean) and correctly dropped by domain adjudication as factually wrong; no change needed. The disputed await-last-only and setMainIsWorker-not-reverted nits remain unchanged, matching the pattern used across the rest of the resources suite. Dispatch-Task: harper-upgrade-rule-record-count-530 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
…'s nit Round 3 (codex graded + Gemini + domain adjudication) kept one substantive nit open across all three rounds: comments narrating what the test name and assertions already say, rather than explaining something non-obvious. Removed the file header, the fixture-size comment, and the "both of the next two" transition comment; trimmed the three remaining test-body comments down to the one detail in each that isn't obvious from the code (why timeLimit: Infinity, why the underestimated-base numbers are what they are). Kept the three disputed nits (await-last-only in the fixture loop, setMainIsWorker not reverted, LMDB skip) unchanged across all three rounds, with the same recorded rationale each time. Dispatch-Task: harper-upgrade-rule-record-count-530 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
…ducible evidence Three rounds disputed these as the house pattern across unitTests/resources, test-only, and loud-on-failure. Round 4 (the closing full round) escalated both to minor with a concrete repro: holding one write pending while completing the rest finished with 24/25, 899/900 and 2,999/3,000 rows committed -- a real, demonstrated short-fixture race, not a theoretical one. Fixing is cheap and ends the back-and-forth: - buildTable() now collects every put's promise and awaits them with Promise.all, instead of only the loop's final assignment. - The suite now saves getWorkerIndex() !== undefined before setMainIsWorker(true) and restores it in an after hook, so this file no longer leaves the shared mocha process on worker-0 identity for whatever runs next. Dispatch-Task: harper-upgrade-rule-record-count-530 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
…ate description Round 4 (the closing full round) raised four more findings, all confirmed against code before fixing: - The note said the 20-checkpoint escape fires "regardless"; it actually also requires a usable positive base (entryCount > 0), same as the halfway escape -- a store with no usable base just keeps scanning. Fixed the wording. - The root DESIGN.md's own placement rule says user-facing API behavior is "never here" -- the note's REST-vs-operations-API field-name/option comparison is exactly that. Removed it; the PR body's Caller impact rule already covers it in full, and that's where it belongs per the rule the root index states. - The note restated MIN_ESTIMATOR_SAMPLE/MAX_ESTIMATE_CHECKPOINTS as bare numbers, which drifts from the named constants in Table.ts. Now references the constant names instead. - Dropped a line directing where future tests should be pinned -- process, which the same root rule says belongs in the PR, not the note. Also fixed two inaccuracies the review caught: - "LMDB has no estimator path" was wrong -- LMDB does estimate, from getStats().entryCount; the skip condition is specifically about the createCountEstimator/estimateCount seam this file mocks, which only RocksDB has. - assert.equal(x, undefined) on estimatedRange/estimated_record_range also passes for null; tightened to assert.strictEqual. Left as a conscious, recorded decision (not a fix): the underestimate test pins today's behavior (upper can land below the live count) rather than asserting the invariant the whole-store fallback is meant to hold -- which is exactly what the dispatch task asked for, and the DESIGN.md note now says so explicitly. The zero-remainder-only limitation in that fallback (resources/Table.ts:8046) is a pre-existing implementation gap, not something this tests-only PR should change; noted for the dispatch findings queue instead. Dispatch-Task: harper-upgrade-rule-record-count-530 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
…nt wording Round 5 found two more real issues: - originalWasWorker was captured AFTER setupTestDBPath(), so a throw there would leave it undefined and the after hook would clear a worker mode an earlier file had legitimately set to true. Moved the capture to the first line of before(). - The previous DESIGN.md trim dropped the time-budget condition entirely (the escape needs both the floor AND an exhausted budget, not just the floor) and mischaracterized the forward+reverse-samples-meet case as "scanning to completion" when it actually stops early and returns an exact sum through a different code path. Also restored a line for exactCount: true, which got cut along with the REST/operations content it was adjacent to in the last trim, but is core getRecordCount behavior, not user-facing API documentation. Also removed the "see that fallback's zero-remainder-only condition for the gap" backlink that had no issue to point to, per the root DESIGN.md's own rule that a note's history/process needs a link or doesn't belong here; that gap is already in this task's dispatch Findings for the EM to triage into an issue. The disputed "mutations outside try" and "narrating comments" findings this round were already disputed by the domain adjudicator itself (an assignment to an existing property can't throw; the remaining comments explain the skip reason, the fixture's specific numbers, and the infinite budget -- none restate what the assertions already show), so left unchanged. Dispatch-Task: harper-upgrade-rule-record-count-530 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
…g PR Round 6 found one real inaccuracy: the note's exact-count list included "a usable base that stays past halfway" unconditionally, but the checkpoint ceiling (MAX_ESTIMATE_CHECKPOINTS) escapes into sampling regardless of the halfway test once 20 checkpoints pass -- unitTests/resources/getRecordCount.test.js's "stops scanning when the base keeps saying the scan is past halfway" test pins exactly that. Reworded: staying past halfway is only exact if the scan finishes before the checkpoint ceiling, not unconditionally. Also linked PR #2612 ("Size describe record counts with a bounded estimate instead of a second full key scan") as the motivating reference the root DESIGN.md's placement rule asks for, closing the one remaining placement nit. The two disputed nits this round (mutations outside try, narrating comments) were already disputed by the domain adjudicator itself across two straight rounds now -- left unchanged. Dispatch-Task: harper-upgrade-rule-record-count-530 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GekbXU9ChZ7PTPWkuNa9LL
Keep the exact-count and heuristic-range contract and its named tests. Preserve the LMDB open-state guard in the pruned note, and correct the forced estimator fixture explanation. Co-Authored-By: GPT-5 Codex <noreply@openai.com> Dispatch-Task: pr-maint-5db61ef52ae7d5658c48656bb65c567c
kriszyp
force-pushed
the
test/record-count-caller-impact
branch
from
October 23, 2026 21:37
f4fa985 to
173b524
Compare
kriszyp
marked this pull request as ready for review
October 26, 2026 12:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
The upgrade-impact pipeline needs a caller rule for
Table.getRecordCount(): large or slow scans can return estimates, and callers may mistake those numbers for exact counts.🤔 Does the release framing hold?
The function is identical in v5.2.15 and v5.3.0. The motivating estimator change landed separately on both release lines (#2612); the useful result is a caller-contract rule, rather than a claim that those tags differ.
💡 Solution
Eight caller tests pin default calls, the sampling floor,
exactCount, forced high/low estimates, anddescribe_tableforwarding/skipping. The invariant note and root index explain why the estimated range can miss the live count. No runtime behavior changes.🤔 Pin the estimator undershoot?
Yes. The forced-low case characterizes current behavior for the upgrade scanner. It does not claim that an undershoot is desirable or prevent a future runtime fix from updating this expectation.
Caller impact rule and supporting research
🤔 Why keep focused test seams?
describeTableexposes no time budget: its spy verifies option forwarding and a fixed return verifies field mapping. Real sampling and clamping have separate coverage. Two local estimator fixtures keep the examples independent. LMDB uses a different statistics seam, so only the two forced RocksDB estimator cases skip there; six caller cases still run.Caller impact rule
(a) Trigger conditions
A call's
recordCountis an estimate (andestimatedRangeis present) only when all three hold:options.exactCountis not set.exactCount: truealways scans to completion; no version of this code path has ever skipped that.options.timeLimit, default 500ms, checked each ~500ms interval) and has scanned at leastMIN_ESTIMATOR_SAMPLE(1,000) entries. Below that floor, the loop always completes exactly.What changed in September 2026, and what did not: the data source for the halfway decision and the extrapolation base swapped from an exact key-count scan to a cheap sampled estimate. The return shape, the floor, the
exactCountescape, and the fact that large/slow tables get an estimate with a range were all already true (since 2023). The numeric value ofrecordCount/estimatedRangefor a table that lands on the sampling path can differ from what an older build would have returned for the identical data — because the extrapolation base is computed differently. The halfway gate also uses that base, so a particular call can switch between exact and estimated results. Both variants were already part of the caller-visible contract.(b) Caller shapes
Classifications:
Rows are covered by
getRecordCountCallerImpact.test.js, by tests already on main (noted), or are classified from the code alone (the "approximately N" display and the "not this API" row have no test).await Table.getRecordCount()exactCount: true, any table sizeawait Table.getRecordCount({ exactCount: true })await Table.getRecordCount()on a table whose scan is fastestimatedRangewas already possible here; its numeric value can shift between builds, and the estimated halfway gate can also change whether a particular call returns a range; both return variants were already supportedrecordCount === knownLiveCount(tests, "has data changed" checks)assert.equal((await T.getRecordCount()).recordCount, N)upperis only a heuristic, not a hard bound — but a build whose estimate happened to land on the true value can start landing elsewhereMath.ceil(recordCount / pageSize)used as the authoritative last-page boundaryupperdoes not even guarantee the true page count is bracketed; the specific wrong answer can shift between buildsestimatedRangeto show "approximately N"`~${recordCount} (${lower}–${upper})`estimatedRangesignal is unchanged — but see the migration guidance below;upperis advisory, not a hard bound (loweris an exact count of the rows actually sampled, so it is trustworthy as a floor)describe_table/describe_all, default{"operation":"describe_table",...}record_count/estimated_record_rangepass throughgetRecordCount()unchanged in shapedescribe_tablewithexact_count: true{"operation":"describe_table","exact_count":true,...}exactCountdescribe_tablewithskip_record_count: true{"operation":"describe_table","skip_record_count":true,...}getRecordCountis never calledGET /Table/(describe, no id), defaultGET /Product/recordCountstaysundefined;getRecordCountis never called unless the caller opts in (already pinned inbasicREST-test.mjs)GET /Table/withincludeExpensiveRecordCountEstimatesrecordCount/estimatedRecordRange(camelCase), not the operations API's snake_case fields, and has noexact_count/skip_record_countequivalentMap.prototype.get/size, a customgetRecordCounton a non-tableResourcethat never callssuperMigration guidance for flagged callers:
recordCountas exact for a table that can exceed ~1,000 rows: readestimatedRange, but treatupperas advisory, not a hard bound — passexactCount: true/exact_count: trueinstead if an authoritative number is actually needed and the cost is acceptable.loweris safe to treat as a real floor (it is a direct count, not an estimate).upper-is-heuristic caveat.(c) Intended contract change, not a regression
No contract changed. The internal data-source swap is an intended performance fix, not a behavior change:
getRecordCount's escape path exists because the value scan already blew its time budget, and it answered by starting an exactgetKeysCount()— a second, unbounded scan of the same key space. Measured on rocksdb-js 2.9.0 ... that is 92ms at 1M keys and 934ms at 10M ...estimateCount()is flat and under 0.05ms at both sizes."CountEstimatorcheckpointed as the scan advances ... Verified across nine churn distributions that the range contains the true live count in all of them." — an empirical check over nine distributions, not a mathematical guarantee;Table.getRecordCount's own comment calls the result "a widened heuristic interval, not a guaranteed bound," and this PR's underestimate test injects fixed estimator answers and showsupperlanding below the live countgetRecordCount.test.jsto cover checkpoint deltas, calibration under churn, and estimator-failure fallback — all internal mechanics, none change the return shaperesources/Table.ts:1020-1029type declarationPromise<{recordCount: number, estimatedRange?: undefined} | {recordCount: number, estimatedRange: number[]}>(d) Static-detection recipe
\.getRecordCount\s*\((any receiver)Table/a table class, not an unrelatedgetRecordCountexactCount\s*:\s*truein the same call's argument objectexactCount\s*:\s*trueinside the call, orexact_count\s*:\s*truein an operations-API object\brecord_count\b/\bestimated_record_range\bread from an operations response, or\.recordCount\b/\.estimatedRange\bread off agetRecordCount()result===,Math.ceil(x.recordCount / ...)used as a hard boundary)False positives:
getRecordCountdefined on an unrelated class;exactCount/exact_countpassed as a variable that is provably alwaystrueat that call site (data-flow, not a line regex); a comment or test name containing the string.False negatives:
exactCountpassed as a variable that is sometimesfalse(the regex only catches the literaltrue); a wrapper function that callsgetRecordCount()internally and the caller only sees the wrapper; destructured imports (const { getRecordCount } = Table) renaming the call; askip_record_count-aware caller that computes its own count elsewhere and compares it against a different record's cached estimate.On real code: zero non-test, non-comment hits for
\.getRecordCount\s*\(outsideresources/Table.ts,dataLayer/schemaDescribe.tsand their tests in Harper's own tree. This API has exactly the two call sites documented above inside Harper; the scanner's job is finding it in application code.(e) Main versus the target release
Main behaves as v5.3.0 (and v5.2.15) did:
getRecordCount's implementation atresources/Table.tsis byte-identical across v5.2.15, v5.3.0 and main — confirmed by diffing the function body (not just surrounding context) directly.exactCountescape,describe_table/REST pass-through) has been stable since 30f4df6 (2023-10-19).✅ Verification
npm run build,lint:required, Prettier andcheck:design-docspass. Focused run (caller-impact,getRecordCount, eviction and LMDB close-completion suites): 32 passing, 3 skipped.PR QA: skipped @ 173b524 — tests and documentation only.
Docs companion: none; this records an existing internal contract and changes no public API.
🤖 Generated by OpenAI Codex; posted via @kriszyp.
Release target: main — tests and documentation only
Related PRs: 13 others independent
Complexity: medium
Dispatch: task
harper-upgrade-rule-record-count-530· queued by unknown · ran by claude/sonnet/high · worker kzyp-xps-1Deferred findings
a1496b2b35969f76minor test-coverage —unitTests/resources/getRecordCountCallerImpact.test.js— Characterization requires an underestimated upper endpoint — NOT FILED9c54836bfc5f4abcminor correctness —resources/Table.ts— Whole-store fallback only on zero remainder; low positive remainder yields upper below live count — NOT FILED8fc12de59c6fb7b3nit test-coverage —dataLayer/schemaDescribe.ts— describe_table sets estimated_record_range to explicit undefined on exact counts; no test pins missing-key vs undefined — NOT FILEDReview-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,muse; rounds=12; full=3 @ 173b524
Review-Attention: skim ~2m (decisions: pin-current-undershoot, contract-in-design-note) @ 173b524