Hi,

Neither 4b445479f9e (TOAST index creation writing into
index_rebuild_count) nor 0765b48874a (the concurrent path not counting
its index builds) added a test, and both are easy to break again
without anyone noticing, since nothing checks the values the progress
views report.

repack_toast.spec already has what is needed: it stops REPACK
(CONCURRENTLY) at repack-concurrently-before-lock, which comes right
after build_new_indexes(), on a table that has a TOAST table and one
index.  The attached patch adds one step there, in the other session:

    SELECT phase, index_rebuild_count FROM pg_stat_progress_repack;

To check that it catches both problems, I ran the spec on master
(0765b48874a) and with each fix reverted, rebuilding only the touched
file each time:

    master, both fixes              rebuilding index | 1
    4b445479f9e reverted            rebuilding index | 3
    0765b48874a reverted            rebuilding index | 0

    git show 4b445479f9e -- src/backend/catalog/toasting.c | git apply -R
    git show 0765b48874a -- src/backend/commands/repack.c | git apply -R
    make -C src/test/modules/injection_points check \
        ISOLATION=repack_toast REGRESS= TAP_TESTS=

On master the whole injection_points module passes with it (4 regress
and 14 isolation tests), built with --enable-cassert and
--enable-injection-points.  The patch also applies to REL_19_STABLE,
where both fixes were backpatched, but I have only checked that it
applies there, not run it.

The non-concurrent path has no injection point in the middle, so the
test covers only REPACK (CONCURRENTLY).  I watched the other commands
live instead, polling pg_stat_progress_repack from a second connection
without pause on a table with 1.5M rows, a 39 MB TOAST table and three
indexes.  Each line is a change seen in the view: time in ms, phase,
and index_rebuild_count after the arrow.

19beta2, VACUUM FULL:
    3 ms, initializing -> 2
    3 ms, seq scanning heap -> 2
  241 ms, rebuilding index -> 2
  364 ms, rebuilding index -> 1
  521 ms, rebuilding index -> 2
 1898 ms, performing final cleanup -> 3

master, VACUUM FULL:
    3 ms, seq scanning heap -> 0
  765 ms, rebuilding index -> 0
  962 ms, rebuilding index -> 1
 1191 ms, rebuilding index -> 2
 2589 ms, performing final cleanup -> 3

19beta2, REPACK (CONCURRENTLY):
    5 ms, initializing -> 2
  342 ms, rebuilding index -> 2
 2042 ms, catch-up -> 2
 2044 ms, performing final cleanup -> 2

master, REPACK (CONCURRENTLY):
    5 ms, seq scanning heap -> 0
 1027 ms, rebuilding index -> 0
 1180 ms, rebuilding index -> 1
 1416 ms, rebuilding index -> 2
 2735 ms, rebuilding index -> 3
 2736 ms, catch-up -> 3

On 19beta2 the count jumps to 2 while the new heap is created, goes
backwards to 1 when the first index is rebuilt, and in concurrent mode
stays at 2 to the end.  On master CLUSTER and REPACK look exactly like
VACUUM FULL above, so the non-concurrent path is right as well.  (The
script is attached too; the master build has --enable-cassert, which is
why it is slower.)

Regards,
Manu

El mié, 16 sept 2026 a las 6:40, Fujii Masao (<[email protected]>) escribió:
>
> Fix index rebuild progress reporting for REPACK (CONCURRENTLY)
>
> Previously, during REPACK (CONCURRENTLY), index_rebuild_count in
> pg_stat_progress_repack and pg_stat_progress_cluster did not advance
> as indexes were rebuilt. This made it impossible for users monitoring the
> operation to tell how many indexes had been completed.
>
> Fix this by incrementing the count after each index is built on the new
> heap, so that both views report the number of completed index builds.
>
> Author: Fujii Masao <[email protected]>
> Reviewed-by: Álvaro Herrera <[email protected]>
> Discussion: 
> https://postgr.es/m/CAHGQGwFUsrBvTurkSU8TEc=dztunqteuxrasseqnmt7ambz...@mail.gmail.com
> Backpatch-through: 19
>
> Branch
> ------
> REL_19_STABLE
>
> Details
> -------
> https://git.postgresql.org/pg/commitdiff/ccbea44294bb3813882052d14a6158302c4b19aa
>
> Modified Files
> --------------
> src/backend/commands/repack.c | 2 ++
> 1 file changed, 2 insertions(+)
>

Attachment: progress_live.sh
Description: application/shellscript

"""Watch pg_stat_progress_repack live while VACUUM FULL / CLUSTER / REPACK run.

usage: progress_live.py <port> <label>

Creates a table with a TOAST table and three indexes, then for each command
polls the progress view from a second connection every few milliseconds and
prints every change of (phase, index_rebuild_count) with its time.
"""
import sys
import threading
import time

import psycopg

PORT, LABEL = sys.argv[1], sys.argv[2]
POLL = float(sys.argv[3]) if len(sys.argv) > 3 else 0.005
DSN = f"host=/tmp port={PORT} user=postgres dbname=postgres"
ROWS = 1_500_000

COMMANDS = [
    "VACUUM FULL progress_t",
    "CLUSTER progress_t USING progress_t_pkey",
    "REPACK progress_t",
    "REPACK (CONCURRENTLY) progress_t",
]


def setup():
    with psycopg.connect(DSN, autocommit=True) as c:
        c.execute("DROP TABLE IF EXISTS progress_t")
        c.execute("CREATE TABLE progress_t (id int PRIMARY KEY, a int, b text, t text)")
        c.execute(f"""
            INSERT INTO progress_t
            SELECT g, g % 9973, md5(g::text),
                   CASE WHEN g % 500 = 0
                        THEN (SELECT string_agg(md5((g * x)::text), '')
                              FROM generate_series(1, 400) x)
                   END
            FROM generate_series(1, {ROWS}) g""")
        c.execute("CREATE INDEX progress_t_a ON progress_t (a)")
        c.execute("CREATE INDEX progress_t_b ON progress_t (b)")
        c.execute("ANALYZE progress_t")
        n_idx = c.execute("SELECT count(*) FROM pg_index WHERE indrelid = 'progress_t'::regclass").fetchone()[0]
        toast = c.execute("""SELECT t.relname, pg_size_pretty(pg_relation_size(t.oid))
                             FROM pg_class c JOIN pg_class t ON t.oid = c.reltoastrelid
                             WHERE c.relname = 'progress_t'""").fetchone()
        ver = c.execute("SELECT version()").fetchone()[0].split(" on ")[0]
    print(f"### {LABEL}: {ver}")
    print(f"table progress_t: {ROWS} rows, {n_idx} indexes, TOAST {toast[0]} = {toast[1]}\n")
    return n_idx


def watch(command):
    done = threading.Event()
    error = []

    def run():
        try:
            with psycopg.connect(DSN, autocommit=True) as c:
                c.execute(command)
        except Exception as e:  # reported below, not swallowed
            error.append(e)
        finally:
            done.set()

    samples, last = 0, None
    timeline = []
    with psycopg.connect(DSN, autocommit=True) as mon:
        worker = threading.Thread(target=run)
        t0 = time.monotonic()
        worker.start()
        while not done.is_set():
            row = mon.execute(
                "SELECT phase, index_rebuild_count FROM pg_stat_progress_repack "
                "WHERE relid = 'progress_t'::regclass").fetchone()
            samples += 1
            if row is not None and row != last:
                timeline.append((int((time.monotonic() - t0) * 1000), row[0], row[1]))
                last = row
            time.sleep(POLL)
        worker.join()
    elapsed = int((time.monotonic() - t0) * 1000)

    print(f"-- {command}   ({elapsed} ms, {samples} samples)")
    if error:
        print(f"   ERROR: {error[0]}")
    for ms, phase, count in timeline:
        print(f"   {ms:>6} ms  {phase:<28} index_rebuild_count = {count}")
    print()


n_idx = setup()
for cmd in COMMANDS:
    watch(cmd)
print(f"(the table has {n_idx} indexes: a correct count never goes above {n_idx})")

Reply via email to