Repository navigation
Conversation
Adds two independent flags to gpbackup that let incremental backups
detect table changes by file content rather than by AO modcount alone:
--heap-file-hash
Hashes each heap table's data-file mtime+size on every segment via
pg_stat_file() (CHECKPOINT first), so unchanged heap tables can be
skipped in incremental. Default behavior (always include heap) is
preserved when the flag is absent.
--ao-file-hash
Hashes (segno, eof, tupcount) of each AO table's aoseg rows --
deliberately excluding modcount, which on GP5 propagates across
sibling partitions when only one leaf is modified. AOCS tables use
a Cloudberry-aware column set: segno + tupcount + vpinfo when
!IsGPDB(), or GP7+ schema otherwise. With the flag, partition-level
change detection works correctly even on GP5-style modcount leaks.
Both flags require --leaf-partition-data or --incremental.
Implementation:
toc/toc.go
Extends IncrementalEntries with optional Heap map[string]HeapEntry,
and AOEntry with optional FileHashMD5. Both yaml-omitempty so TOCs
written without the flags remain bytewise identical to the old
format and are readable by older binaries.
backup/queries_incremental.go
Adds ensureFileStatFunction (installs a plpgsql wrapper around
pg_stat_file in gp_toolkit; pure SQL, no plpython), getTableFileHash,
getHeapTableFQNs, GetAOContentHashes, getAOSegContentHash. Uses a
dedicated dbconn so per-table query failures do not abort the backup
transaction.
backup/wrappers.go
backupIncrementalMetadata: when --ao-file-hash is set, merges
aoseg content hashes into the existing AOEntry map; when
--heap-file-hash is set, CHECKPOINTs, then collects per-segment file
hashes for heap tables into a new HeapEntry map.
backup/incremental.go
FilterTablesForIncremental now branches independently on the two
flags. When neither is set, behavior is identical to the prior
version (AO compared by modcount+DDL, heap unconditionally included).
backup/validate.go
Rejects --*-file-hash without --leaf-partition-data or --incremental.
backup/incremental_test.go
Moves the FilterTablesForIncremental call into JustBeforeEach so the
new MustGetFlagBool reads happen after BeforeSuite initializes
cmdFlags. (Previously the call ran during spec-tree construction.)
Verification:
- make build / make lint / make unit: pass on the server
(no new lint findings; 13 ginkgo unit suites green).
- Targeted live-cluster e2e on Cloudberry 2.5.0: 4-binary install,
full + incremental cycle with both flags, mixed schema (heap/ao_row/
aocs/partitioned AO). Confirmed unchanged tables skipped, partition-
level granularity (one leaf included, sibling skipped), restore
round-trip row counts match across all tables.
- make end_to_end (1232s, 196 of 221 specs ran): no regressions
attributable to this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to e24bc69. Two real bugs and two minor cleanups found in code review of that commit: 1. (data loss) gp_toolkit.gpbackup_file_info hand-built the tablespace path as 'pg_tblspc/<tsp>/<dboid>/<rfn>', missing the mandatory 'PG_<major>_<catver>' directory that PostgreSQL/Cloudberry put in the middle. pg_stat_file would fail for every heap table living in a custom tablespace; the EXCEPTION branch swallowed the error and returned the literal string '|0' (not empty). Because '|0' is not filtered by 'WHERE info <> '''', that constant became the table's hash on every backup, so any custom-tablespace heap table would appear unchanged forever and be silently skipped from every incremental backup -- losing any new rows. 2. (SQL injection / correctness) getTableFileHash interpolated FQN parts straight into the SQL via fmt.Sprintf('%s', '%s'). splitFQN stripped double quotes but left single quotes intact, so a table name with an embedded single quote (a legal pg identifier, e.g. "o'reilly") would break out of the string literal. 3. (clarity) getAOSegContentHash's default case said "GP7+ AOCS" but actually fires for GP6+. Comment fixed. 4. (consistency) HeapEntry.FileHashMD5 missing 'omitempty' that AOEntry.FileHashMD5 already had. Fix for apache#1 and apache#2 is the same change: pass the table's OID through to the plpgsql function, and let pg_relation_filepath() compute the on-disk path. The built-in already handles all tablespace layouts correctly across PG/Cloudberry versions, and oid interpolation is just an integer literal so the SQL-string-escaping concern disappears. The EXCEPTION block now returns true empty-string, so a hash failure on a single table falls through to "include in incremental" instead of poisoning the hash with a constant. Implementation notes: backup/queries_incremental.go - gp_toolkit.gpbackup_file_info now takes (p_oid oid) and uses pg_relation_filepath(p_oid). The setup path drops any older (text, text) signature first so an in-place upgrade against a previous gpbackup installation cleans up cleanly. The duplicate- check query gained 'pronargs = 1' to distinguish from the old signature. - getHeapTableFQNs -> getHeapTables, returning []heapTable{Oid,FQN}. - getTableFileHash(hashConn, oid, fqn) -- oid interpolated as integer literal, fqn used only for log messages. - getFileHashesForTables takes []heapTable. - splitFQN removed. backup/wrappers.go - One-line callsite update. toc/toc.go - HeapEntry.FileHashMD5 gets omitempty for symmetry with AOEntry. Verification: - make build / make unit on the server: green. - Targeted live cluster e2e (heap + ao_row + ao_column + partitioned AO, full + mutate + incremental + restore): every expected inclusion/exclusion matches, restored row counts match source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
talmacschen-arch
left a comment
There was a problem hiding this comment.
Thanks for this — thoughtful PR and the overall design is sound: with both flags off the path is logically identical to the previous FilterTablesForIncremental, the TOC additions are all omitempty so backward compatibility holds, using a dedicated dbconn to avoid transaction-abort propagation is the right call, and excluding modcount from the AO content hash to dodge GP5's cross-partition modcount propagation addresses a real pain point.
I checked the two issues below against the Cloudberry kernel source and reproduced both on a live cluster, so they are not speculation — details are in the inline comments:
- [correctness]
--heap-file-hashcan silently miss changes on heap tables larger than 1GB — inline ongpbackup_file_info. - [gap]
--heap-file-hashdoes not cover leaf partitions of partitioned heap tables — inline ongetHeapTables.
Two more points:
Test coverage: the new decision logic in FilterTablesForIncremental (AO-hash / heap-hash branches: prev vs current equal → skip, differ → include, either empty → include, Heap == nil → include) has no unit test — the test change only moves the existing flags-off call into JustBeforeEach. These branches are pure map logic over constructed TOCs and can be unit-tested without a live DB; worth adding since this is the heart of the feature.
nits:
ensureFileStatFunctiononlyCREATE OR REPLACEs when the function is absent; an older same-signature(oid)body would not be refreshed.- The plpgsql
EXCEPTION WHEN OTHERS THEN RETURN ''swallows every error (including permission failures) — safe-by-degradation, but hides real errors when debugging. CHECKPOINTis a cluster-wide heavy operation; frequent incrementals add I/O — worth a note in the docs.
Nice work overall — physical-state hashing for change detection is the right direction; both issues are about which physical state is being hashed.
| v_mod text; | ||
| v_size text; | ||
| BEGIN | ||
| v_path := pg_relation_filepath(p_oid); |
There was a problem hiding this comment.
pg_relation_filepath(oid) returns only the first 1GB segment, so this hash can silently miss changes on heap tables larger than 1GB.
Checked against the kernel source and reproduced on a live cluster.
Source: a heap fork is split into RELSEG_SIZE-block segments (storage/smgr/md.c: sprintf(segpath, "%s.%u", path, segno), with the comment "Zero or more full segments of exactly RELSEG_SIZE blocks each"), RELSEG_SIZE defaulting to 1GB. But GetRelationPath (common/relpath.c) never appends a .N suffix — the segment suffix is a storage-layer detail in md.c only. So pg_relation_filepath always yields the base segment, and pg_stat_file only stats that one file.
Live reproduction (single-segment heap table, all rows hashed to one segment):
- After loading 1.5GB: main segment =
1073741824 | 23:51:12(exactly 1GB, full);.1segment =511836160 | 23:51:13. - After appending 0.5GB more (lands in the tail segment, since the main one is already full) +
CHECKPOINT: main segment =1073741824 | 23:51:12— byte-for-byte identical mtime and size — while.1grew to1040351232 | 23:53:03.
Since gpbackup_file_info hashes only the main segment, its hash is unchanged across those two states, so a >1GB append-mostly table is judged "unchanged" and skipped from the incremental, losing the new rows. The size fallback does not help, because a full segment's size is pinned at exactly 1GB.
The window is narrow (table >1GB, changes land only in tail segments, main segment untouched by UPDATE/VACUUM/hint-bits), but that is exactly the append-only large-table pattern incremental backup most wants to optimize.
Suggestion: stat every segment of the relation (path, path.1, path.2, … until pg_stat_file returns ENOENT, the way md.c walks segments) and fold each segment's mtime+size into the hash — or document the limitation / skip --heap-file-hash for tables over 1GB.
There was a problem hiding this comment.
Thanks for tracking this down to md.c / relpath.c. Fixed in fbd2786: gpbackup_file_info now walks path, path.1, path.2, ... until pg_stat_file raises undefined_file, the same way md.c does, and folds each segment's mtime and size into the result. The relation path is part of the hash too, so a relfilenode change (TRUNCATE / VACUUM FULL / CLUSTER) is also detected.
I reproduced your scenario end to end: after a full backup, I appended to a >1GB table so that only .1 grew, while the first segment kept its size (exactly 1GB) and mtime. The table is now included in the incremental, and the restored row count matches the source.
| JOIN pg_am a ON c.relam = a.oid | ||
| WHERE a.amname = 'heap' | ||
| AND c.relkind = 'r' | ||
| AND c.oid NOT IN (SELECT inhrelid FROM pg_inherits) |
There was a problem hiding this comment.
NOT IN (SELECT inhrelid FROM pg_inherits) excludes the leaf partitions of partitioned heap tables, so --heap-file-hash is effectively a no-op for them. (Same clause in the pre-GP7 branch above.)
Verified against source and on a live cluster.
Partition children — whether created via CREATE … PARTITION OF or ATTACH PARTITION — are registered in pg_inherits as inhrelid by design (commands/tablecmds.c: StoreCatalogInheritance(…, stmt->partbound != NULL), and StoreCatalogInheritance1 on the attach path). So every heap leaf partition is an inhrelid and is filtered out here.
Live reproduction: a range-partitioned heap table with 3 leaves — the leaves are relkind='r', amname='heap', and all present in pg_inherits.inhrelid. Running this exact getHeapTables query returns only a non-partitioned control table; all 3 leaves are excluded (the root is already excluded by relkind='r').
With leaves absent from IncrementalMetadata.Heap, FilterTablesForIncremental sees isHeap=false and falls back to "always include", so leaf partitions are always re-backed up. This is safe (over-backup, not data loss), but it means the feature does not apply to partitioned heap tables. Worth calling out — note the root partition was already excluded by relkind='r', so this clause's net effect is to drop the leaves, which are the tables that actually carry data under --leaf-partition-data.
There was a problem hiding this comment.
Agreed. The NOT IN (SELECT inhrelid FROM pg_inherits) clause is removed from both branches, so leaf partitions are hashed like any other heap table. Verified with a range-partitioned heap table: after modifying one leaf, only that leaf was included in the incremental, and its siblings were skipped.
talmacschen-arch
left a comment
There was a problem hiding this comment.
Requesting changes on the strength of the correctness issue in my detailed review above (F1).
On heap tables larger than 1GB, --heap-file-hash stats only the first 1GB segment (pg_relation_filepath never returns the .N tail segments — confirmed in md.c/relpath.c), so append-only changes that land in tail segments are silently missed and dropped from the incremental. I reproduced this on a live cluster: after appending 0.5GB to a >1GB table, the main segment's size|mtime was byte-for-byte unchanged while the data went to .1. That is a data-loss path for the feature.
Please either hash all segments of the relation (walk path, path.1, path.2, … like md.c does) or gate/document the flag for tables over 1GB before merge. F2 (leaf partitions excluded) and the missing unit tests are non-blocking. Happy to re-review once segment handling is addressed — the overall approach is good.
|
@liang8283 heads-up — I've reviewed this PR and left a Request Changes. The one blocker is a correctness issue (F1): on heap tables larger than 1GB, F2 (leaf partitions of partitioned heap tables are excluded via |
woblerr
left a comment
There was a problem hiding this comment.
Preferably splitting --heap-file-hash and --ao-file-hash into two separate PRs. They are independent features, so each can be reviewed and merged on its own.
The --heap-file-hash functionality looks like the most useful change.
| filteredTables = append(filteredTables, table) | ||
| continue | ||
| } | ||
| if prevHeap.FileHashMD5 != currentHeap.FileHashMD5 { |
There was a problem hiding this comment.
The heap filter only compares file hashes, so it misses schema changes that do not rewrite the table. After a full backup with --heap-file-hash, dropping a non-key column left the heap hash unchanged in my test.
The incremental skipped the table and reused the previous data backup, whose COPY column list still included the dropped column.
Restore failed with column "payload" of relation "t" does not exist.
There was a problem hiding this comment.
Thanks for the repro. HeapEntry now records the table's last DDL timestamp from pg_stat_last_operation (read inside the backup snapshot), and a heap table is skipped only if both the file hash and the DDL timestamp match the previous backup, the same rule AO tables already follow. With your DROP COLUMN scenario, the table is now included in the incremental and the restore succeeds with the new column list.
| hashConn.MustConnect(1) | ||
| defer hashConn.Close() | ||
|
|
||
| // CHECKPOINT flushes dirty pages so pg_stat_file sees up-to-date mtime/size. |
There was a problem hiding this comment.
The heap hash can include writes that are not visible to the backup snapshot.
Holding a table lock, starting a full backup, and committing an insert after the backup established its snapshot but before its checkpoint and hash collection.
The full backup omitted the inserted row, while the next incremental saw the same hash and skipped the table.
Restore completed successfully but returned only id = 1, although the source contained both id = 1 and id = 2.
- Setup in a fresh test database
CREATE TABLE public.t(id int)
USING heap WITH (autovacuum_enabled=false)
DISTRIBUTED BY (id);
INSERT INTO public.t VALUES (1);
SELECT * FROM public.t;
CHECKPOINT;- Session A — hold the table lock
BEGIN;
LOCK TABLE public.t IN ACCESS EXCLUSIVE MODE;- Another terminal — start the full backup
gpbackup --dbname test \
--leaf-partition-data --heap-file-hash
...- Session A — after backup is waiting at Acquiring
ACCESS SHARElocks on tables
INSERT INTO public.t VALUES (2);
COMMIT;- After the full backup finishes — incremental using its printed timestamp
gpbackup
--leaf-partition-data --heap-file-hash \
--incremental --from-timestamp '<FULL_TIMESTAMP>' \
...- Restore using the incremental timestamp
gprestore --backup-dir /tmp/review98-backups \
--timestamp '<INCREMENTAL_TIMESTAMP>' \
...There was a problem hiding this comment.
Good catch. This was a real ordering bug: the hash was read after the backup snapshot was established. Heap file hashes are now collected in DoSetup on a separate connection (CHECKPOINT, then hash) before initializeConnectionPool begins the transactions and takes the snapshot. Any change committed after the hash is read moves the data files on, so the next incremental sees a different hash. At worst that is an extra copy, never a skipped change.
I ran your exact sequence (lock held by session A, full backup waiting on ACCESS SHARE, insert committed in the meantime): the incremental now includes the table, and the restore returns both id = 1 and id = 2.
While looking at this I also tightened two related cases. If CHECKPOINT fails, no hashes are recorded. And because pg_stat_file reports mtime in whole seconds, a table whose file was modified in the same second as the hash read gets no hash. In both cases the table is included in the next incremental.
Address review feedback on the file-hash incremental feature: - Hash every segment file of a heap relation (path, path.1, ...), not just the first 1GB one that pg_relation_filepath returns, so appends to large tables are no longer missed. The path itself is hashed too, which catches relfilenode changes from TRUNCATE/VACUUM FULL/CLUSTER. - Collect heap file hashes before the backup snapshot is taken. Hashing after it let changes committed in between enter the hash without entering the backup, and the next incremental skipped them. - Compare the last DDL timestamp of heap tables as well, so a DDL that does not rewrite the table (e.g. DROP COLUMN) is not restored with data dumped under the old column list. - Include leaf partitions of partitioned heap tables. - Record no hashes when CHECKPOINT fails, and none for a table whose file was modified in the current second (pg_stat_file mtime has one-second granularity). - Recreate gpbackup_file_info on every run; let unexpected errors surface instead of being swallowed. Hash tables in batches. - Add unit tests for the heap branches of FilterTablesForIncremental. --ao-file-hash is removed from this change and will be proposed separately, as suggested in review.
|
Thanks both for the careful reviews and the reproductions. I pushed fbd2786, which addresses all of them; details are in the inline replies, and the PR description is updated.
Could you take another look when you have a moment? |
…-hash # Conflicts: # options/flag.go
|
A follow-up on
Making it correct would mean adding |
|
@liang8283 The full backup hashes the heap file after CHECKPOINT flushes an uncommitted insert, but COPY cannot see that row. Committing the transaction later leaves the hash unchanged, so the next incremental skips the table. In psql, create a heap table with one committed row, then leave the second insert uncommitted. CREATE TABLE t(id int) USING heap DISTRIBUTED BY (id);
INSERT INTO t VALUES (1);
BEGIN;
INSERT INTO t VALUES (2);In another terminal, run a full backup with --leaf-partition-data --heap-file-hash. Once it finishes, run COMMIT; in the original psql session. Run an incremental from that full backup with --heap-file-hash and restore it. The restore succeeds, but Repeating the incremental from the same full backup with --heap-file-hash disabled restores both 1 and 2. |
…riters A heap data file also holds rows of transactions that have not committed yet, and their COMMIT only updates the commit log, not the file. If the full backup hashed such a file, the later commit left the hash unchanged and the next incremental skipped the table, losing the committed rows. After reading the hashes, and before the backup snapshot is taken, drop the hash of every table on which another transaction holds a lock taken by data-modifying commands. A writer that commits in between is visible to the backup; one still in progress still holds its lock at the check.
|
@woblerr Thanks, confirmed and fixed in b60ef8f. The heap files also contain rows of transactions that are still in progress, and their COMMIT only updates the commit log, so the hash never changed after the commit. The fix: after reading the file hashes, and before the backup snapshot is taken, gpbackup now checks
If the lock check itself fails, no heap hashes are recorded at all. With your exact steps (uncommitted insert during the full backup, committed after it), the incremental now includes |
Summary
Adds an opt-in
--heap-file-hashflag togpbackupso that--incrementalcan skip heap tables whose data has not changed. Today every heap table is re-copied by every incremental backup; with this flag, a heap table is skipped when both its on-disk file state and its last DDL timestamp match the previous backup.Without the flag, behavior is unchanged: heap tables are always included, and AO tables keep using
modcount+ DDL timestamp.--ao-file-hashfrom the first revision has been dropped: the aoseg content hash does not change on DELETE, so it is not a safe replacement formodcount(see the discussion below).How it works
gp_toolkit.gpbackup_file_info(oid), runs on every segment viagp_dist_random. It resolves the relation's path withpg_relation_filepath()and stats every segment file of the main fork (path,path.1,path.2, ... until one is missing, the same waymd.cwalks them), so tables larger thanRELSEG_SIZEare fully covered. The path itself is part of the hash, so a new relfilenode (TRUNCATE, VACUUM FULL, CLUSTER) is detected as a change.CHECKPOINTso thatpg_stat_filesees flushed data. Any change committed after the hash is read moves the data files on, so the next incremental sees a different hash and includes the table. (Hashing after the snapshot would let changes the backup cannot see enter the hash.)pg_stat_last_operation, read inside the backup snapshot. A DDL that does not rewrite the table, such asDROP COLUMN, therefore still forces the table into the incremental.CHECKPOINTfails;pg_stat_filereports mtime in whole seconds, so a later write in the same second would not move it);FilterTablesForIncrementalskips a non-AO table only when both the previous and the current TOC carry a hash for it and both the hash and the DDL timestamp match.Files changed
toc/toc.goHeapEntry{FileHashMD5, LastDDLTimestamp}andIncrementalEntries.Heap(omitempty).options/flag.go--heap-file-hash.backup/backup.goDoSetup, before the connection pool takes the snapshot.backup/queries_incremental.goCollectHeapFileHashes,ensureFileStatFunction,getHeapTables,getFileHashesForTables(batched),getHeapLastDDLTimestamps.backup/wrappers.gobackupIncrementalMetadatawrites heap entries (hash + DDL timestamp) into the TOC.backup/incremental.goFilterTablesForIncrementalskips heap tables viaheapTableUnchanged.backup/validate.go--heap-file-hashrequires--leaf-partition-data.backup/incremental_test.goBackward compatibility
omitempty; older TOCs load fine, and oldergprestoreignores the new fields.gp_toolkit.gpbackup_file_infois created only when the flag is used, and is replaced on each run so a stale body is never reused. The(text, text)signature from the first revision of this PR is dropped.Operational notes
CHECKPOINTat the start of each backup, plus a one-second wait so files flushed by it are not treated as "modified in the current second".--include-table/--exclude-tableare applied afterwards.How tested
make unit: all suites pass, including the newFilterTablesForIncrementalheap tests.--heap-file-hash, then mutations, then an incremental, then a restore of the incremental. Every table's row count matches the source, and:.1grows)ALTER TABLE ... DROP COLUMN