Hi,

While CREATE INDEX CONCURRENTLY waits in "waiting for old snapshots",
pg_stat_progress_create_index shows blocks_done one short of
blocks_total for the whole wait: 442 of 443 on a 443-block table, with
a REPEATABLE READ transaction opened before the command and left open.
REINDEX CONCURRENTLY shows the same 442 of 443 in "waiting for old
snapshots" and in both "waiting for readers" phases.

heapam_index_validate_scan() reports each block when the scan starts
on it and nothing when the scan ends, so the last block is never
counted, and the phases that follow do not touch the block counters.

The attached patch reports rs_nblocks once the scan ends, the same as
the build scan's final report in heapam_index_build_range_scan().  The
validation scan does not use syncscan, so that is the total it
reported at its start.  With it, the four waits show 443 of 443.

repro-f3.sh.txt reproduces it (bash repro-f3.sh.txt <bindir>); its
output on master and with the patch is attached.  With the patch,
make check, the isolation suite, the injection_points tests,
test_misc's 010 and reindexdb's 090 pass (cassert build, no warnings).

I think this is for master only, like 3be29d9346d.  It came up while
sweeping progress reporting for the framework in CF 7331.  Mihail's
CIC series (v40-0004) moves this scan to tuples_done, so the fix is
for the code as it is today.

Regards,
Manu
From 9b84c51678e2406eed092d56d8fbdfbb08a164a5 Mon Sep 17 00:00:00 2001
From: Manu <[email protected]>
Date: Thu, 15 Oct 2026 23:55:41 -0300
Subject: [PATCH] Count the last block of the validation scan of a concurrent
 index build

heapam_index_validate_scan() reports PROGRESS_SCAN_BLOCKS_DONE as the
number of the block that the scan starts on, and never again once the
scan ends.  So for CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY,
blocks_done in pg_stat_progress_create_index stops one block short of
blocks_total in the phase "index validation: scanning table".  The
phases that follow ("waiting for old snapshots", and for REINDEX
CONCURRENTLY "waiting for readers before marking dead" and "waiting for
readers before dropping") do not touch the block counters, so the view
keeps showing that, e.g. 789 of 790 blocks, for as long as they wait.

Report the scan's total as done when it ends, as
heapam_index_build_range_scan() already does for the build scan.  The
validation scan does not use syncscan, so it starts at block 0 and
rs_nblocks is the total it reported at its start.

This is the same kind of blip as the one fixed for the build scan in
3be29d9346d.
---
 src/backend/access/heap/heapam_handler.c | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index 7d2065dec37..5cfdbcd4468 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -1931,6 +1931,13 @@ heapam_index_validate_scan(Relation heapRelation,
 		}
 	}
 
+	/*
+	 * Report scan progress one last time.  Each block is reported when the
+	 * scan starts on it, so the last one would not be counted as done.
+	 */
+	pgstat_progress_update_param(PROGRESS_SCAN_BLOCKS_DONE,
+								 hscan->rs_nblocks);
+
 	table_endscan(scan);
 
 	ExecDropSingleTupleTableSlot(slot);
-- 
2.56.0

#!/usr/bin/env bash
# Show blocks_done / blocks_total of pg_stat_progress_create_index while
# CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY wait after their
# validation scan.
#
# usage: bash repro-f3.sh.txt <bindir> [port]
#   <bindir> holds initdb, pg_ctl and psql of the build under test.
#
# A throwaway cluster is created in a temporary directory and removed at
# the end.  The waits are made by plain sessions:
#   CIC:     a REPEATABLE READ transaction opened before the command
#            ("waiting for old snapshots").
#   REINDEX: the same, then two transactions that read the table, each
#            opened while the previous wait is in progress ("waiting for
#            readers before marking dead", "... before dropping").
# Each wait is sampled 5 times, 1 second apart; it lasts as long as the
# session that causes it stays open.
set -u
BIN=${1:?usage: repro-f3.sh.txt <bindir> [port]}
PORT=${2:-54329}
D=$(mktemp -d "${TMPDIR:-/tmp}/repro-f3.XXXXXX")
trap 'touch "$D"/release-h1 "$D"/release-h2 "$D"/release-h3; sleep 0.5;
      "$BIN"/pg_ctl -D "$D/data" -m immediate stop >/dev/null 2>&1; rm -rf 
"$D"' EXIT

"$BIN"/initdb -D "$D/data" -A trust --no-sync >"$D/initdb.log" 2>&1 ||
  { cat "$D/initdb.log"; exit 1; }
"$BIN"/pg_ctl -D "$D/data" -l "$D/server.log" -w \
  -o "-p $PORT -c listen_addresses='' -c unix_socket_directories='$D'" \
  start >/dev/null || { cat "$D/server.log"; exit 1; }

q() { "$BIN"/psql -X -At -h "$D" -p "$PORT" -d postgres -c "$1"; }

# hold <name> <sql>: run <sql> in a new transaction and keep it open until
# $D/release-<name> exists.
hold() {
  rm -f "$D/release-$1"
  { printf 'BEGIN ISOLATION LEVEL REPEATABLE READ;\n%s\n' "$2"
    while [ ! -e "$D/release-$1" ]; do sleep 0.1; done
    echo 'COMMIT;'
  } | PGAPPNAME=$1 "$BIN"/psql -X -q -h "$D" -p "$PORT" -d postgres \
      >/dev/null &
  for _ in $(seq 100); do
    [ "$(q "SELECT count(*) FROM pg_stat_activity
            WHERE application_name = '$1'
              AND state = 'idle in transaction'")" = 1 ] && return 0
    sleep 0.1
  done
  echo "session $1 did not start"; exit 1
}

PROGRESS="SELECT phase, blocks_done, blocks_total
          FROM pg_stat_progress_create_index WHERE relid = 't'::regclass"

wait_phase() {
  for _ in $(seq 600); do
    [ "$(q "$PROGRESS" | cut -d'|' -f1)" = "$1" ] && return 0
    sleep 0.1
  done
  echo "phase '$1' not reached"; q "$PROGRESS"; exit 1
}

SHORT=0; FULL=0
sample() {
  for i in 1 2 3 4 5; do
    IFS='|' read -r ph done total <<<"$(q "$PROGRESS")"
    echo "$1 sample $i: phase=\"$ph\" blocks_done=$done blocks_total=$total"
    if [ "$done" = "$total" ]; then FULL=$((FULL + 1)); else SHORT=$((SHORT + 
1)); fi
    [ $i -lt 5 ] && sleep 1
  done
}

q "CREATE TABLE t (a int) WITH (autovacuum_enabled = off);
   INSERT INTO t SELECT g FROM generate_series(1, 100000) g;" >/dev/null
echo "table t: $(q "SELECT pg_relation_size('t') / 
current_setting('block_size')::int") blocks"

# CREATE INDEX CONCURRENTLY
hold h1 'SELECT 1;'
q 'CREATE INDEX CONCURRENTLY t_a ON t (a);' >/dev/null &
CMD=$!
wait_phase 'waiting for old snapshots'
sample cic
touch "$D/release-h1"; wait $CMD

# REINDEX CONCURRENTLY
hold h1 'SELECT 1;'
q 'REINDEX INDEX CONCURRENTLY t_a;' >/dev/null &
CMD=$!
wait_phase 'waiting for old snapshots'
sample reindex
hold h2 'SELECT count(*) FROM t;'
touch "$D/release-h1"
wait_phase 'waiting for readers before marking dead'
sample reindex
hold h3 'SELECT count(*) FROM t;'
touch "$D/release-h2"
wait_phase 'waiting for readers before dropping'
sample reindex
touch "$D/release-h3"; wait $CMD

echo "RESULT: $SHORT samples with blocks_done < blocks_total," \
     "$FULL with blocks_done = blocks_total"
table t: 443 blocks
cic sample 1: phase="waiting for old snapshots" blocks_done=442 blocks_total=443
cic sample 2: phase="waiting for old snapshots" blocks_done=442 blocks_total=443
cic sample 3: phase="waiting for old snapshots" blocks_done=442 blocks_total=443
cic sample 4: phase="waiting for old snapshots" blocks_done=442 blocks_total=443
cic sample 5: phase="waiting for old snapshots" blocks_done=442 blocks_total=443
reindex sample 1: phase="waiting for old snapshots" blocks_done=442 
blocks_total=443
reindex sample 2: phase="waiting for old snapshots" blocks_done=442 
blocks_total=443
reindex sample 3: phase="waiting for old snapshots" blocks_done=442 
blocks_total=443
reindex sample 4: phase="waiting for old snapshots" blocks_done=442 
blocks_total=443
reindex sample 5: phase="waiting for old snapshots" blocks_done=442 
blocks_total=443
reindex sample 1: phase="waiting for readers before marking dead" 
blocks_done=442 blocks_total=443
reindex sample 2: phase="waiting for readers before marking dead" 
blocks_done=442 blocks_total=443
reindex sample 3: phase="waiting for readers before marking dead" 
blocks_done=442 blocks_total=443
reindex sample 4: phase="waiting for readers before marking dead" 
blocks_done=442 blocks_total=443
reindex sample 5: phase="waiting for readers before marking dead" 
blocks_done=442 blocks_total=443
reindex sample 1: phase="waiting for readers before dropping" blocks_done=442 
blocks_total=443
reindex sample 2: phase="waiting for readers before dropping" blocks_done=442 
blocks_total=443
reindex sample 3: phase="waiting for readers before dropping" blocks_done=442 
blocks_total=443
reindex sample 4: phase="waiting for readers before dropping" blocks_done=442 
blocks_total=443
reindex sample 5: phase="waiting for readers before dropping" blocks_done=442 
blocks_total=443
RESULT: 20 samples with blocks_done < blocks_total, 0 with blocks_done = 
blocks_total
table t: 443 blocks
cic sample 1: phase="waiting for old snapshots" blocks_done=443 blocks_total=443
cic sample 2: phase="waiting for old snapshots" blocks_done=443 blocks_total=443
cic sample 3: phase="waiting for old snapshots" blocks_done=443 blocks_total=443
cic sample 4: phase="waiting for old snapshots" blocks_done=443 blocks_total=443
cic sample 5: phase="waiting for old snapshots" blocks_done=443 blocks_total=443
reindex sample 1: phase="waiting for old snapshots" blocks_done=443 
blocks_total=443
reindex sample 2: phase="waiting for old snapshots" blocks_done=443 
blocks_total=443
reindex sample 3: phase="waiting for old snapshots" blocks_done=443 
blocks_total=443
reindex sample 4: phase="waiting for old snapshots" blocks_done=443 
blocks_total=443
reindex sample 5: phase="waiting for old snapshots" blocks_done=443 
blocks_total=443
reindex sample 1: phase="waiting for readers before marking dead" 
blocks_done=443 blocks_total=443
reindex sample 2: phase="waiting for readers before marking dead" 
blocks_done=443 blocks_total=443
reindex sample 3: phase="waiting for readers before marking dead" 
blocks_done=443 blocks_total=443
reindex sample 4: phase="waiting for readers before marking dead" 
blocks_done=443 blocks_total=443
reindex sample 5: phase="waiting for readers before marking dead" 
blocks_done=443 blocks_total=443
reindex sample 1: phase="waiting for readers before dropping" blocks_done=443 
blocks_total=443
reindex sample 2: phase="waiting for readers before dropping" blocks_done=443 
blocks_total=443
reindex sample 3: phase="waiting for readers before dropping" blocks_done=443 
blocks_total=443
reindex sample 4: phase="waiting for readers before dropping" blocks_done=443 
blocks_total=443
reindex sample 5: phase="waiting for readers before dropping" blocks_done=443 
blocks_total=443
RESULT: 0 samples with blocks_done < blocks_total, 20 with blocks_done = 
blocks_total

Reply via email to