From 827294b34e95864edb154dc01c0ec13ebfcda9b0 Mon Sep 17 00:00:00 2001
From: Peter Stukalov <peter.stukalov01@gmail.com>
Date: Mon, 7 Sep 2026 06:32:12 +0000
Subject: [PATCH] Fix ambuild overwriting the heap's reltuples with the indexed
tuple count
`IndexBuildResult.heap_tuples` is "# of tuples seen in parent table"
(`access/genam.h`), and `index_build()` hands it straight to
`index_update_stats()` for the *heap's* pg_class row:
index_update_stats(heapRelation,
true,
stats->heap_tuples);
whose contract is, in `src/backend/catalog/index.c`:
* hasindex: set relhasindex to this value
* reltuples: if >= 0, set reltuples to this value; else no change
*
* If reltuples >= 0, relpages and relallvisible are also updated
`ambuild()` puts the number of tuples that went *into the index* there, so
every diskann build overwrites the table's reltuples with its own row count.
For a full index over a live table the two numbers coincide, which is why
this is easy to miss. For an index that covers a subset of the table they do
not, and the heap loses a statistic that is not the index's to write:
CREATE TABLE probe (id int, v vector(128));
INSERT INTO probe SELECT g, ... FROM generate_series(1, 2000) g;
ANALYZE probe; -- reltuples = 2000
CREATE INDEX probe_half ON probe USING diskann (v) WHERE id > 1000;
-- reltuples = 1000
CREATE INDEX probe_none ON probe USING diskann (v) WHERE id > 100000;
-- reltuples = 0
A partial btree or hnsw index over the same table leaves reltuples alone:
`table_index_build_scan()` counts a live tuple before it evaluates the index
predicate, and both of those AMs report the value it returned.
reltuples = 0 is worse than a stale estimate. The planner then plans against
a table it believes is empty, and diskann disables its own parallel builds
for every index created afterwards, because `ambuild()` reads
`heap_relation.rd_rel.reltuples` to compare against
`diskann.min_vectors_for_parallel_build`. Nothing warns, and the value stays
wrong until the next ANALYZE or autovacuum.
The count the field wants is the one the heap scan already computes and both
of pgvectorscale's scan wrappers throw away:
* `util::ports::IndexBuildHeapScan` returns the double that the table AM's
`index_build_range_scan()` produced. pgrx's `pg_sys::IndexBuildHeapScan`
discards it and cannot be fixed from here, so this is a copy of that
wrapper which does not discard it;
* `IndexBuildHeapScanParallel` returns each worker's share, and the workers
accumulate it into a new field of the shared `ParallelBuildState` -- the
same accumulation nbtree performs on `btshared->reltuples` under its
spinlock;
* `ambuild()` reports the live heap tuples in `heap_tuples` and keeps the
indexed count in `index_tuples`, which is the field for it.
With the patch the reproduction above leaves reltuples at 2000 for both
partial builds, serial and parallel (`diskann.force_parallel_workers = 2`),
while the "Indexed 1000 tuples" / "Indexed 0 tuples" notices and the
indexes' own pg_class.reltuples are unchanged.
Tested on PostgreSQL 17.10 with pgvector 0.8.5.
---
pgvectorscale/src/access_method/build.rs | 134 ++++++++++++++++++-----
pgvectorscale/src/util/ports.rs | 41 ++++++-
2 files changed, 146 insertions(+), 29 deletions(-)
diff --git a/pgvectorscale/src/access_method/build.rs b/pgvectorscale/src/access_method/build.rs
index 0aea049..af712a7 100644
--- a/pgvectorscale/src/access_method/build.rs
+++ b/pgvectorscale/src/access_method/build.rs
@@ -1,4 +1,4 @@
-use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::time::Instant;
use pg_sys::{FunctionCall0Coll, InvalidOid};
@@ -18,7 +18,7 @@ use crate::util::ports::acquire_index_lock;
use crate::access_method::DISKANN_DISTANCE_TYPE_PROC;
use crate::util::page::PageType;
-use crate::util::ports::IndexBuildHeapScanParallel;
+use crate::util::ports::{IndexBuildHeapScan, IndexBuildHeapScanParallel};
use crate::util::tape::Tape;
use crate::util::*;
@@ -223,11 +223,54 @@ struct ParallelSharedParams {
#[cfg_attr(not(feature = "build_parallel"), allow(dead_code))]
struct ParallelBuildState {
ntuples: AtomicUsize,
+ /// Bits of an `f64`: the live heap tuples seen by all workers' scans, summed.
+ /// This is a different quantity from `ntuples`, which counts only the tuples
+ /// that went into the index.
+ live_heap_tuples: AtomicU64,
start_nodes_initialized: AtomicBool,
initializing_worker_done: AtomicBool,
initialization_cv: ConditionVariable,
}
+#[cfg_attr(not(feature = "build_parallel"), allow(dead_code))]
+impl ParallelBuildState {
+ /// Add one worker's live heap tuple count to the shared total. nbtree does
+ /// the same accumulation under a spinlock (`btshared->reltuples +=
+ /// reltuples`); an atomic compare-exchange on the `f64`'s bits keeps the
+ /// value exact without adding a lock to this struct.
+ fn add_live_heap_tuples(&self, tuples: f64) {
+ let mut current = self.live_heap_tuples.load(Ordering::Relaxed);
+ loop {
+ let total = (f64::from_bits(current) + tuples).to_bits();
+ match self.live_heap_tuples.compare_exchange_weak(
+ current,
+ total,
+ Ordering::Relaxed,
+ Ordering::Relaxed,
+ ) {
+ Ok(_) => return,
+ Err(observed) => current = observed,
+ }
+ }
+ }
+
+ fn live_heap_tuples(&self) -> f64 {
+ f64::from_bits(self.live_heap_tuples.load(Ordering::Relaxed))
+ }
+}
+
+/// What a heap scan for an index build produced.
+///
+/// The two counts differ for a partial index, and for any index whose predicate
+/// or expression skips rows: `index_tuples` counts what was indexed, while
+/// `live_heap_tuples` counts every live row the scan walked past. Postgres
+/// writes the latter into the *heap's* `pg_class.reltuples`, so conflating them
+/// corrupts the table's statistics.
+struct HeapScanResult {
+ index_tuples: usize,
+ live_heap_tuples: f64,
+}
+
/// Status data for parallel index builds, shared among all parallel workers.
#[derive(Debug)]
#[cfg_attr(not(feature = "build_parallel"), allow(dead_code))]
@@ -385,6 +428,7 @@ pub extern "C-unwind" fn ambuild(
},
build_state: ParallelBuildState {
ntuples: AtomicUsize::new(0),
+ live_heap_tuples: AtomicU64::new(0.0f64.to_bits()),
start_nodes_initialized: AtomicBool::new(false),
initializing_worker_done: AtomicBool::new(false),
initialization_cv: std::mem::zeroed(), // Will be initialized below
@@ -427,7 +471,7 @@ pub extern "C-unwind" fn ambuild(
None
};
- let ntuples = if let Some(ParallelData { pcxt, snapshot }) = parallel_data {
+ let scan_result = if let Some(ParallelData { pcxt, snapshot }) = parallel_data {
unsafe {
pg_sys::WaitForParallelWorkersToFinish(pcxt);
let parallel_shared: *mut ParallelShared =
@@ -437,8 +481,12 @@ pub extern "C-unwind" fn ambuild(
.build_state
.ntuples
.load(Ordering::Relaxed);
+ let live_heap_tuples = (*parallel_shared).build_state.live_heap_tuples();
parallel::cleanup_parallel_context(pcxt, snapshot);
- ntuples
+ HeapScanResult {
+ index_tuples: ntuples,
+ live_heap_tuples,
+ }
}
} else {
do_heap_scan(
@@ -453,8 +501,12 @@ pub extern "C-unwind" fn ambuild(
};
let mut result = unsafe { PgBox::<pg_sys::IndexBuildResult>::alloc0() };
- result.heap_tuples = ntuples as f64;
- result.index_tuples = ntuples as f64;
+ // `heap_tuples` is the number of tuples seen in the parent table, and
+ // `index_build` hands it straight to `index_update_stats` for the *heap's*
+ // pg_class row. It must be the scan's live tuple count, never the number of
+ // rows this index happens to contain.
+ result.heap_tuples = scan_result.live_heap_tuples;
+ result.index_tuples = scan_result.index_tuples as f64;
result.into_pg()
}
@@ -594,7 +646,8 @@ fn maybe_train_quantizer(
};
unsafe {
- pg_sys::IndexBuildHeapScan(
+ // The quantizer only needs the sample, not the tuple count.
+ IndexBuildHeapScan(
heap_relation.as_ptr(),
index_relation.as_ptr(),
index_info,
@@ -723,7 +776,7 @@ fn do_heap_scan(
mut write_stats: WriteStats,
parallel_build_info: Option<ParallelBuildInfo>,
worker_count: usize,
-) -> usize {
+) -> HeapScanResult {
unsafe {
pgstat_progress_update_param(PROGRESS_CREATE_IDX_SUBPHASE, BUILD_PHASE_BUILDING_GRAPH);
}
@@ -762,7 +815,7 @@ fn do_heap_scan(
);
let mut state = StorageBuildStateParallel::Plain(&mut plain, &mut bs);
- unsafe {
+ let live_heap_tuples = unsafe {
IndexBuildHeapScanParallel(
heap_relation.as_ptr(),
index_relation.as_ptr(),
@@ -770,12 +823,21 @@ fn do_heap_scan(
Some(build_callback_parallel),
&mut state,
parallel_info.tablescandesc,
- );
- }
+ )
+ };
+ shared_state.build_state.add_live_heap_tuples(live_heap_tuples);
// In parallel mode, nodes are finalized during insertion via streaming
// Just need to handle any remaining cached nodes and update meta page
- finalize_remaining_parallel_nodes(&mut plain, bs, index_relation, write_stats)
+ HeapScanResult {
+ index_tuples: finalize_remaining_parallel_nodes(
+ &mut plain,
+ bs,
+ index_relation,
+ write_stats,
+ ),
+ live_heap_tuples,
+ }
}
StorageType::SbqCompression => {
let mut bq = unsafe {
@@ -797,7 +859,7 @@ fn do_heap_scan(
);
let mut state = StorageBuildStateParallel::SbqSpeedup(&mut bq, &mut bs);
- unsafe {
+ let live_heap_tuples = unsafe {
IndexBuildHeapScanParallel(
heap_relation.as_ptr(),
index_relation.as_ptr(),
@@ -805,8 +867,9 @@ fn do_heap_scan(
Some(build_callback_parallel),
&mut state,
parallel_info.tablescandesc,
- );
- }
+ )
+ };
+ shared_state.build_state.add_live_heap_tuples(live_heap_tuples);
unsafe {
pgstat_progress_update_param(
@@ -817,7 +880,15 @@ fn do_heap_scan(
// In parallel mode, nodes are finalized during insertion via streaming
// Just need to handle any remaining cached nodes and update meta page
- finalize_remaining_parallel_nodes(&mut bq, bs, index_relation, write_stats)
+ HeapScanResult {
+ index_tuples: finalize_remaining_parallel_nodes(
+ &mut bq,
+ bs,
+ index_relation,
+ write_stats,
+ ),
+ live_heap_tuples,
+ }
}
}
} else {
@@ -842,17 +913,25 @@ fn do_heap_scan(
let mut bs = BuildState::new(index_relation, graph, page_type);
let mut state = StorageBuildState::Plain(&mut plain, &mut bs);
- unsafe {
- pg_sys::IndexBuildHeapScan(
+ let live_heap_tuples = unsafe {
+ IndexBuildHeapScan(
heap_relation.as_ptr(),
index_relation.as_ptr(),
index_info,
Some(build_callback),
&mut state,
- );
- }
+ )
+ };
- finalize_index_build(&mut plain, bs, index_relation, write_stats)
+ HeapScanResult {
+ index_tuples: finalize_index_build(
+ &mut plain,
+ bs,
+ index_relation,
+ write_stats,
+ ),
+ live_heap_tuples,
+ }
}
StorageType::SbqCompression => {
let mut bq = unsafe {
@@ -868,15 +947,15 @@ fn do_heap_scan(
let mut bs = BuildState::new(index_relation, graph, page_type);
let mut state = StorageBuildState::SbqSpeedup(&mut bq, &mut bs);
- unsafe {
- pg_sys::IndexBuildHeapScan(
+ let live_heap_tuples = unsafe {
+ IndexBuildHeapScan(
heap_relation.as_ptr(),
index_relation.as_ptr(),
index_info,
Some(build_callback),
&mut state,
- );
- }
+ )
+ };
unsafe {
pgstat_progress_update_param(
@@ -885,7 +964,10 @@ fn do_heap_scan(
);
}
- finalize_index_build(&mut bq, bs, index_relation, write_stats)
+ HeapScanResult {
+ index_tuples: finalize_index_build(&mut bq, bs, index_relation, write_stats),
+ live_heap_tuples,
+ }
}
}
}
diff --git a/pgvectorscale/src/util/ports.rs b/pgvectorscale/src/util/ports.rs
index 2531d47..1f1d956 100644
--- a/pgvectorscale/src/util/ports.rs
+++ b/pgvectorscale/src/util/ports.rs
@@ -178,7 +178,42 @@ pub fn buffer_align(len: usize) -> usize {
}
}
-/// Custom IndexBuildHeapScan that uses parallel table scan descriptor
+/// Scan the heap for an index build, returning the number of live heap tuples the
+/// scan saw -- the `double` that `IndexBuildResult.heap_tuples` is defined to
+/// carry ("# of tuples seen in parent table", `access/genam.h`).
+///
+/// pgrx's own `pg_sys::IndexBuildHeapScan` wrapper drops that return value, so it
+/// cannot be used where the count is needed.
+#[allow(non_snake_case)]
+pub unsafe fn IndexBuildHeapScan<T>(
+ heap_relation: pg_sys::Relation,
+ index_relation: pg_sys::Relation,
+ index_info: *mut pg_sys::IndexInfo,
+ build_callback: pg_sys::IndexBuildCallback,
+ build_callback_state: *mut T,
+) -> f64 {
+ let heap_relation_ref = heap_relation.as_ref().unwrap();
+ let table_am = heap_relation_ref.rd_tableam.as_ref().unwrap();
+
+ table_am.index_build_range_scan.unwrap()(
+ heap_relation,
+ index_relation,
+ index_info,
+ true, // allow_sync
+ false, // anyvisible
+ true, // progress
+ 0, // start_blockno
+ pg_sys::InvalidBlockNumber, // end_blockno
+ build_callback,
+ build_callback_state as *mut std::os::raw::c_void,
+ std::ptr::null_mut(),
+ )
+}
+
+/// Custom IndexBuildHeapScan that uses parallel table scan descriptor.
+///
+/// Returns the number of live heap tuples this worker's share of the scan saw;
+/// the caller is responsible for summing the workers' counts.
#[allow(non_snake_case)]
pub unsafe fn IndexBuildHeapScanParallel<T>(
heap_relation: pg_sys::Relation,
@@ -187,7 +222,7 @@ pub unsafe fn IndexBuildHeapScanParallel<T>(
build_callback: pg_sys::IndexBuildCallback,
build_callback_state: *mut T,
tablescandesc: *mut pg_sys::ParallelTableScanDescData,
-) {
+) -> f64 {
let scan = pg_sys::table_beginscan_parallel(heap_relation, tablescandesc);
let heap_relation_ref = heap_relation.as_ref().unwrap();
@@ -205,7 +240,7 @@ pub unsafe fn IndexBuildHeapScanParallel<T>(
build_callback,
build_callback_state as *mut std::os::raw::c_void,
scan,
- );
+ )
}
/// Is a snapshot MVCC-safe? (This should really be a part of pgrx)
--
2.34.1
What happened?
ambuild()reports the number of tuples that went into the index asIndexBuildResult.heap_tuples. That field is the table's live row count, not theindex's, and
index_build()hands it straight toindex_update_stats()for theheap's
pg_classrow. So everyCREATE INDEX ... USING diskannoverwrites thetable's
reltupleswith the number of rows that index happens to contain.pgvectorscale/src/access_method/build.rs:449-450onmain(
9d9851ce9966f01cdbbdc80e510d4938e3cd26bf), and456-457at tag0.9.0:ntuplesis what the build callbacks counted, i.e. the rows that reached the index.index_tuplesis right;heap_tuplesis a different quantity and gets the same value.PostgreSQL's two contracts, quoted from
REL_17_10.src/include/access/genam.h:30-34says what the field means:src/backend/catalog/index.c:2766-2770, theindex_update_stats()header comment,says what is done with it:
and
src/backend/catalog/index.c:3100-3106, insideindex_build(), is where the twomeet — note which relation each call names:
For a full index over a live table the two counts coincide, which is why this is easy
to miss. For an index whose predicate skips rows they do not, and the table loses a
statistic that was never the index's to write. Nothing warns, and the value stays wrong
until the next
ANALYZEor autovacuum.The count
heap_tupleswants already exists: it is thedoublethat the table AM'sindex_build_range_scan()returns, which both of pgvectorscale's scan wrappers discard(pgrx's
pg_sys::IndexBuildHeapScandrops it, andIndexBuildHeapScanParallelinsrc/util/ports.rsreturns()).Why this is about this access method and not about partial indexes
A partial btree and a partial hnsw over the same zero-row predicate leave
reltuplesuntouched:
table_index_build_scan()counts a live tuple before it evaluates theindex predicate, and both of those AMs report the value it returned. Both controls are
in the script below and both hold at 2000.
Three consequences
1. The planner plans against a table it believes is empty. With
reltuples = 0andrelpages = 143the density estimate is zero, so every row estimate over the tablecollapses to the 1-row clamp. Measured below:
rows=1on a scan that returns 2000.2. Autovacuum's analyze threshold collapses with it.
relation_needs_vacanalyze()reads this exact field —
src/backend/postmaster/autovacuum.c:3065isreltuples = classForm->reltuples;and:3076isanlthresh = (float4) anl_base_thresh + anl_scale_factor * reltuples;. With thedocumented defaults (
autovacuum_analyze_threshold = 50,autovacuum_analyze_scale_factor = 0.1) the threshold for this 2000-row table dropsfrom
50 + 0.1 * 2000 = 250modifications to50 + 0.1 * 0 = 50.3. diskann disables its own parallel builds, which is what makes the defect
self-perpetuating. This one is checkable inside your own code without a database:
ambuild()reads back the very statistic it overwrites.build.rs:315onmain:and
build.rs:326:So one partial build that writes a value below
diskann.min_vectors_for_parallel_build(default 65536) makes every later diskann build on that table serial — silently, until
something re-ANALYZEs. The script below observes this in both directions through the
extension's own
Parallel build with N workersnotice: absent atreltuples = 0,present at
reltuples = 2000, same table, same settings, same statement.pgvectorscale extension affected
0.9.0.
mainat9d9851ce9966f01cdbbdc80e510d4938e3cd26bfstill carries it — the linenumbers quoted above are
main's.PostgreSQL version used
17.10
What operating system did you use?
Ubuntu 22.04 x64, as shipped inside the
timescale/timescaledb-ha:pg17image.What installation method did you use?
Docker
What platform did you run on?
On prem/Self-hosted
Relevant log output and stack trace
No error and no stack trace — that is the difficulty with this one. The full transcript
of the run described below, verbatim:
How can we reproduce the bug?
The public
timescale/timescaledb-ha:pg17image already carries everything needed —pgvector 0.8.5 and pgvectorscale 0.9.0 on PostgreSQL 17.10 — so the reproduction needs
no build and nothing else installed. It creates and drops a table called
probe, so donot point it at a server that matters:
docker run -d --name vs-repro --network none -e POSTGRES_PASSWORD=repro \ timescale/timescaledb-ha:pg17 until docker exec vs-repro pg_isready -q; do sleep 1; done docker cp heap-reltuples.sql vs-repro:/tmp/ docker exec -u postgres vs-repro psql -X -f /tmp/heap-reltuples.sql docker rm -f vs-reproheap-reltuples.sql:Proposed fix
Report the live heap tuples in
heap_tuplesand keep the indexed count inindex_tuples, which is the field for it. The value is the one the heap scan alreadycomputes, so nothing extra is scanned or counted:
src/util/ports.rsgains a serialIndexBuildHeapScanthat returns thef64thepgrx wrapper throws away (pgrx's own wrapper cannot be fixed from here), and
IndexBuildHeapScanParallelstops returning();ParallelBuildStatefield — the sameaccumulation nbtree performs on
btshared->reltuplesunder its spinlock, done herewith a compare-exchange on the
f64's bits so no lock is added to the struct;ambuild()assigns the two counts separately.The
Indexed N tuplesnotices and every index's ownpg_class.reltuplesare unchangedby this; only the heap's row moves. Re-running the script above against a build carrying
the patch gives
reltuples = 2000at every step — both partial diskann builds, serialand parallel —
rows=2000from the sameEXPLAIN, and the same per-index values0 / 1000 / 0 / 2000 / 2000 / 0.Applies cleanly to
0.9.0and tomainat9d9851ce(offset only, no conflicts).Built and exercised on
0.9.0with pgrx 0.16.1 — the versionCargo.tomlpins — underPostgreSQL 17.10 and pgvector 0.8.5.
The patch —
git format-patchoutput, sogit amtakes it directlyAre you going to work on the bugfix?
The fix is above and it is yours to take as-is. I am opening this as an issue rather than
a pull request deliberately, on three things
CONTRIBUTING.mdsays: that the issues pageis "the best place to discuss your proposed improvement (and its implementation) with the
core development team"; that a code contribution needs a signed CLA; and that a
non-trivial change should come with a test-suite addition and a passing full suite, which
this patch does not yet have — I have exercised it against a real server rather than
through
cargo pgrx test.Happy to open the PR with a
#[pg_test]covering the partial-index case if you wouldlike it in that form — just say so. Equally happy for you to write it differently; the
part I would ask you not to lose is that
heap_tuplesandindex_tuplesare twodifferent quantities.