Hi Ke, > This problem occurs because in reconstruct_from_incremental_file(), > pg_combinebackup determines which blocks require reconstruction based only on > latest_source->truncation_block_length, without considering the > truncation_block_length of intermediate backups.
Thanks for the report and reproducer. While walking backward through the backup chain in reconstruct_from_incremental_file(), we only check b < latest_source->truncation_block_length when deciding whether an unassigned block in sourcemap[] can be taken from an earlier backup. If an intermediate backup truncated the relation and a later backup extended it again without writing every block in that range (e.g., unwritten pages at the end of a relation after bulk extension in COPY), those blocks remain NULL in sourcemap[] and end up being copied from a backup prior to the truncation. Attached is a patch that tracks the current truncation_block_length upper bound as we step backward through the chain and clamps it whenever an incremental backup has a smaller s->truncation_block_length. Any remaining unassigned blocks at or above that limit stay NULL in sourcemap[] and are written as zeroes. I also extended 011_ib_truncation.pl to cover an intermediate truncation followed by COPY bulk extension. Regards, Rajesh Kumar Thandapani
From 9c8937ac6ce822b13fbe0903eaca33625990e819 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar Thandapani <[email protected]> Date: Wed, 28 Oct 2026 10:08:53 +0000 Subject: [PATCH v1] Fix pg_combinebackup with truncated intermediate backups. When reconstructing a relation from a chain of backups, reconstruct_from_incremental_file() walks backward from the latest incremental backup to the full backup, filling sourcemap[] for any block b < latest_source->truncation_block_length that has not yet been assigned a source. However, if an intermediate incremental backup truncated the relation (s->truncation_block_length < latest_source->truncation_block_length) and a later backup extended the relation again without writing every block in the truncated range (for example, via bulk relation extension), those unwritten blocks still had sourcemap[b] == NULL when the loop moved past the intermediate backup. As a result, they were sourced from an earlier backup taken before the truncation, resurrecting stale rows. Fix by tracking the current truncation_block_length upper bound as we walk backward through the backup chain and tightening it whenever an incremental backup has a smaller s->truncation_block_length. Author: Rajesh Kumar Thandapani <[email protected]> Bug: #19776 Reported-by: Ke <[email protected]> Discussion: https://postgr.es/m/[email protected] --- src/bin/pg_combinebackup/reconstruct.c | 17 +++++- .../pg_combinebackup/t/011_ib_truncation.pl | 59 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/bin/pg_combinebackup/reconstruct.c b/src/bin/pg_combinebackup/reconstruct.c index 38e756b..eb98455 100644 --- a/src/bin/pg_combinebackup/reconstruct.c +++ b/src/bin/pg_combinebackup/reconstruct.c @@ -105,6 +105,7 @@ reconstruct_from_incremental_file(char *input_filename, rfile **sourcemap; off_t *offsetmap; unsigned block_length; + unsigned truncation_block_length; unsigned i; unsigned sidx = n_prior_backups; bool full_copy_possible = true; @@ -129,6 +130,7 @@ reconstruct_from_incremental_file(char *input_filename, latest_source = make_incremental_rfile(input_filename); source[n_prior_backups] = latest_source; block_length = find_reconstructed_block_length(latest_source); + truncation_block_length = latest_source->truncation_block_length; /* * For each block in the output file, we need to know from which file we @@ -225,7 +227,7 @@ reconstruct_from_incremental_file(char *input_filename, * later backup is one that we can't validate. */ blocklength = sb.st_size / BLCKSZ; - for (b = 0; b < latest_source->truncation_block_length; ++b) + for (b = 0; b < truncation_block_length; ++b) { if (sourcemap[b] == NULL && b < blocklength) { @@ -265,7 +267,7 @@ reconstruct_from_incremental_file(char *input_filename, { BlockNumber b = s->relative_block_numbers[i]; - if (b < latest_source->truncation_block_length && + if (b < truncation_block_length && sourcemap[b] == NULL) { sourcemap[b] = s; @@ -279,6 +281,17 @@ reconstruct_from_incremental_file(char *input_filename, full_copy_possible = false; } } + + /* + * Any blocks at or beyond this incremental file's + * truncation_block_length did not exist at the time of this backup, + * so we must not source them from any earlier backup. + */ + if (s->truncation_block_length < truncation_block_length) + { + truncation_block_length = s->truncation_block_length; + full_copy_possible = false; + } } /* diff --git a/src/bin/pg_combinebackup/t/011_ib_truncation.pl b/src/bin/pg_combinebackup/t/011_ib_truncation.pl index 1174928..d6d6427 100644 --- a/src/bin/pg_combinebackup/t/011_ib_truncation.pl +++ b/src/bin/pg_combinebackup/t/011_ib_truncation.pl @@ -115,7 +115,64 @@ my $restored_count = is($restored_count, $rows_after_truncation, 'Restored backup has correct row count'); -$primary->stop; $restored->stop; +# Test that blocks truncated in an intermediate backup are not resurrected +# when a later backup bulk-extends the relation with unwritten zero-filled +# pages. Increase shared_buffers so COPY can bulk-extend by multiple blocks +# at once, grow the table to 30 blocks, truncate it in an intermediate +# backup, and then bulk-extend it with COPY. +$primary->append_conf('postgresql.conf', 'shared_buffers = 8MB'); +$primary->restart; + +my $full_rows = 30; +$primary->safe_psql( + 'postgres', + "INSERT INTO t + SELECT i, + repeat('0123456789ABCDEF0123456789ABCDEF', ($block_size / (2*32))) + FROM generate_series($rows_after_truncation + 1, $full_rows) i; + VACUUM t;" +); + +my $incr_backup = "$backup_path/incr"; +$primary->backup('incr2', + backup_options => [ '--incremental', "$incr_backup/backup_manifest" ]); + +$primary->safe_psql('postgres', + "DELETE FROM t WHERE id > $rows_after_truncation; + VACUUM (TRUNCATE) t;"); + +my $incr2_backup = "$backup_path/incr2"; +$primary->backup('incr3', + backup_options => [ '--incremental', "$incr2_backup/backup_manifest" ]); + +my $copy_rows = 17; +my $row_data = '0123456789ABCDEF0123456789ABCDEF' x ($block_size / (2 * 32)); +my $copy_input = "COPY t FROM STDIN;\n"; +for my $i (1 .. $copy_rows) +{ + $copy_input .= ($full_rows + $i) . "\t$row_data\n"; +} +$copy_input .= "\\.\n"; +$primary->safe_psql('postgres', $copy_input); + +my $incr3_backup = "$backup_path/incr3"; +$primary->backup('incr4', + backup_options => [ '--incremental', "$incr3_backup/backup_manifest" ]); + +my $restored2 = PostgreSQL::Test::Cluster->new('node3'); +$restored2->init_from_backup( + $primary, 'incr4', + combine_with_prior => [ 'full', 'incr', 'incr2', 'incr3' ]); +$restored2->start(); + +$restored_count = + $restored2->safe_psql('postgres', "SELECT count(*) FROM t;"); +is($restored_count, $rows_after_truncation + $copy_rows, + 'Restored backup after intermediate truncation and bulk extension has correct row count'); + +$primary->stop; +$restored2->stop; + done_testing(); -- 2.56.0.307.g5fe7bd680c-goog
