Skip to content

feat(whpg19): support whpg19 - #52

Merged
Bonartze merged 11 commits into
mainfrom
whpg19-port
Sep 18, 2026
Merged

Bonartze merged 11 commits into
mainfrom
whpg19-port

Conversation

@Bonartze

@Bonartze Bonartze commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Why

WHPG19 is based on PostgreSQL 19, so a WHPG19 server reports major version 19 and every version gate in this repo puts it on the >= 7 path — paths written against GPDB7, which is PG12. Seven PostgreSQL majors of catalog churn sit in that gap, and a good deal of it is visible to gpbackup.

Nothing is needed for version detection: warehouse-pg/common-go-libs v1.1.0, which main already uses, parses the WHPG19 banner (... (Greenplum Database) 19.0.0 build dev ... WarehousePG) and derives major 19 from it. ValidateGPDBVersionCompatibility only enforces a floor, so there is no ceiling to lift either. The work is entirely in the catalog queries and the DDL they feed.

What's here

Change Why Since
coalesce(a.attstattarget, -1) attstattarget became nullable, failing the scan for every table backed up PG17
contype NOT IN ('t','n') a column NOT NULL is now also a pg_constraint row, duplicating what the column definition carries PG18
recover opclass members from the operator family a GiST opclass's OPERATOR members are recorded against the family, so the pg_depend join alone returned nothing and those operators were lost (refined in the review round below) PG13
exclude internally-dependent casts CREATE TYPE ... AS RANGE creates a range→multirange cast that is not independently creatable; restoring it errored PG14
typsubscript → SUBSCRIPT = PG14+ rejects ELEMENT without SUBSCRIPT, so such a type could not be restored at all PG14

Four further gaps found while reviewing the rest of the PG12→PG19 delta against the warehouse-pg-next catalog headers, each its own commit:

  • SQL-standard function bodies. PG14+ keeps a pre-parsed BEGIN ATOMIC ... END body in pg_proc.prosqlbody and stores prosrc as the empty string for those functions (see the sql_body branch of interpret_AS_clause), so backing up prosrc alone produced a function with no body at all. Now deparsed with pg_get_function_sqlbody(); such a body takes no AS and follows the modifiers, mirroring dumpFunc().
  • Virtual generated columns. PG18's attgenerated = 'v' was unmapped, which reads everywhere as "not generated" — the column came out as a plain DEFAULT and went into the COPY attribute list despite having no stored value.
  • ICU and builtin collations. PG17 moved a non-libc collation's locale to colllocale and leaves collcollate/collctype NULL, so GetCollations failed outright on any such collation in a backed-up schema rather than merely losing the locale. Also accepts the new 'b' (builtin) provider, which the printer previously treated as fatal, and carries collicurules.
  • Database locale provider. PG15+ allows a non-libc provider, with the governing locale in datlocale. datcollate/datctype stay populated regardless (BKI_FORCE_NOT_NULL), so nothing failed — an ICU database just came back as libc, quietly. Now emits LOCALE_PROVIDER with ICU_LOCALE / BUILTIN_LOCALE / ICU_RULES, spelled out only when it diverges from template0.

Second review round

Reviewing the rest of the PG12→PG19 delta against WHPG19's own pg_dump.c turned up one restore-breaking gap and four silent ones, all fixed here:

  • Partition trigger clones (blocking). trigger.c stores tgisinternal verbatim and records the clone's parent in tgparentid, so on WHPG19 a user row trigger's child clones are not internal. NOT tgisinternal let every clone through, each child got its own CREATE TRIGGER, and since the parent's statement already recreates the clones the restore failed with "trigger ... already exists". Now matches on the parent link, as getTriggers() does.
  • attcompression (PG14): an explicit COMPRESSION lz4 column came back as pglz. Emitted as its own ALTER TABLE ... SET COMPRESSION, where pg_dump puts it.
  • MAINTAIN (PG17, ACL character m): skipped by ParseACL, so an explicit grant was lost — and because arwdDxt was still treated as a relation's full set, a grantee holding only those seven was written as GRANT ALL and came back holding MAINTAIN too.
  • pg_auth_members.inherit_option / set_option (PG16): GRANT ... WITH INHERIT FALSE / SET FALSE restored with the defaults. Emitted as pg_dumpall does — INHERIT always spelled out, SET only when false.
  • MULTIRANGE_TYPE_NAME (PG14): a custom multirange name restored as the generated default, breaking anything that referred to it. Spelled out unconditionally, as dumpRangeType() does.

Also corrected, in the comment and in the two commit messages that said otherwise: the opclass-member dependency change landed in PG14, not PG13 — amadjustmembers (9f9682783) is reachable from REL_14_0 and not from REL_13_0.

Still outstanding: #53 — named / NO INHERIT / NOT VALID not-null constraints (PG18) are not reproduced once contype 'n' is excluded, and NOT VALID is the one that can fail a restore. Left out because it changes how every column's NOT NULL is rendered.

adam8157 and others added 6 commits September 10, 2026 11:08
WHPG19 is based on PostgreSQL 19, so a WHPG19 server reports major
version 19 and falls into gpbackup's ">= 7" query paths -- which were
written against GPDB7/PG12. Seven PostgreSQL majors of catalog churn sit
in between, and five of those changes are visible to gpbackup:

  * attstattarget became nullable (PG17), which failed the scan for
    every table backed up;
  * a column NOT NULL is now also a pg_constraint row of contype 'n'
    (PG18), duplicating what the column definition already carries;
  * operator class members are recorded as depending on the operator
    family rather than the class (PG14), so the pg_depend join found
    nothing and classes came back empty;
  * "CREATE TYPE ... AS RANGE" creates an internal range->multirange
    cast (PG14) that is not independently creatable, so restoring it
    errored;
  * a type may declare a subscript function (PG14), without which its
    ELEMENT cannot be restored at all.

Ported from the WHPG19 catalog-query work on whpg19-catalog-queries
(PR #42, closed unmerged), which was validated against a live WHPG19
cluster. Rebased onto main, which since then has moved to
warehouse-pg/common-go-libs v1.1.0 -- that release already parses the
WHPG19 version banner, so no dependency change is needed here.
PG14+ lets a SQL function carry a pre-parsed, SQL-standard body -- BEGIN
ATOMIC ... END, or RETURN expr -- in pg_proc.prosqlbody, and for those
functions the server deliberately stores prosrc as the empty string (see
the sql_body branch of interpret_AS_clause). Backing up prosrc alone
therefore produced a function with no body whatsoever.

Deparse the real body with pg_get_function_sqlbody() the way pg_dump
does, and prefer it over prosrc wherever it is present. Such a body is
not introduced by AS and has to follow the modifiers rather than precede
LANGUAGE, so the statement is laid out differently in that case, again
mirroring dumpFunc() in pg_dump.c.
PG18 adds virtual generated columns, whose attgenerated code is 'v'.
Only 's' was mapped, so a virtual column's code became the empty string
-- which reads throughout as "not a generated column". That went wrong
twice over: the column definition came out as a plain DEFAULT rather
than GENERATED ALWAYS AS ... VIRTUAL, and the column was put into the
COPY attribute list even though it has no stored value to copy.

Mapping the code fixes both, since ConstructTableAttributesList already
excludes every column with a generated code.
PG17 moved a non-libc collation's locale into pg_collation.colllocale
and leaves collcollate/collctype NULL, so GetCollations did not merely
lose the locale -- selecting those two columns into strings failed
outright on any ICU or builtin collation in a backed-up schema. PG17
also adds the 'b' (builtin) provider, which the printer rejected with a
fatal error, and ICU collations may carry custom tailoring in
collicurules.

Select the locale and rules on WHPG19, spell the locale as LOCALE (which
is what CREATE COLLATION calls it) when one is present, and accept the
builtin provider.
PG15+ lets a database pick a locale provider other than libc, in which
case the locale that actually governs it lives in datlocale (plus
daticurules for ICU tailoring) rather than in datcollate/datctype. Those
two stay populated regardless -- they are BKI_FORCE_NOT_NULL -- so
nothing failed; the database just came back as libc under a different
collation, quietly.

Dump the provider and its locale, and emit LOCALE_PROVIDER with the
matching ICU_LOCALE / BUILTIN_LOCALE / ICU_RULES. template0's own
provider is now read alongside its encoding, so this is only spelled out
for a database that actually diverges from it.
The version-gated code paths are what this port changes, so the unit
suites need to run with the connection reporting 19, alongside 5, 6 and
7. All four majors pass.
@Bonartze Bonartze changed the title Whpg19 port feat(whpg19): support WarehousePG 19 Sep 10, 2026
@Bonartze Bonartze changed the title feat(whpg19): support WarehousePG 19 feat(whpg19): support whpg19 Sep 10, 2026
Operator class members: the family lookup replaced the pg_depend one
outright, on the strength of a comment that turns out to be wrong -- PG14
did not move all members onto the family, and WHPG19's own pg_dump still
joins pg_depend on the opclass. What actually changed is narrower:
gistvalidate() puts a GiST opclass's OPERATOR members (and its optional
support functions) on the family even when they were declared inline in
CREATE OPERATOR CLASS, while btree and hash keep the opclass dependency,
as do GiST's required support functions.

So merge the two rather than swapping one for the other, and keep the
pg_depend attribution wherever it exists, since it is exact. The family
lookup now covers only families holding a single opclass -- the shape
CREATE OPERATOR CLASS produces when no FAMILY is named. In a shared
family nothing in the catalog says which class a family-level member was
declared with, and guessing folds in members belonging to a sibling class
or added by ALTER OPERATOR FAMILY; those come back as class members on
restore, and if the family already held a matching member the restore
fails outright. It also gains the schema and extension filters
GetOperatorClasses already uses: built-in members are pinned and have no
pg_depend rows, so the query this supplements never returned them, and
without the filters every backup fetched the whole catalog only to
discard it.

Casts: the exclusion was applied on every version, where pg_dump gates
the equivalent on PG14+, and the comment claimed an anti-join pg_dump
does not use. Adopt pg_dump's actual pg_range form and gate it on 19.

Databases: the locale-provider guard ignored ICU rules, so a database
matching template0's provider and locale but carrying its own tailoring
lost ICU_RULES silently.

Collation and database locales are interpolated into single-quoted
literals, and ICU tailoring uses the apostrophe as its own quoting
character, so they need EscapeSingleQuotes like the rest of the package.

A table whose every column is generated -- newly constructible with
PG18's virtual columns -- yielded an empty COPY column list, which means
no list at all, which covers the generated column and fails the restore.
Skip its data instead; it has none. A table with no columns is untouched.

Also: order GetForeignDataWrappers by name, so two backups of an
unchanged database emit its wrappers in the same order; fold the nine
copies of the WHPG19 skip into testutils helpers and the duplicated
fixture selection into one; and record in the forked e2e fixture that it
must be kept in step with gpdb4_objects.sql.
@Bonartze
Bonartze requested a review from a team September 10, 2026 11:27
@linwen
linwen requested a review from huiliang-liu September 16, 2026 06:01

@huiliang-liu huiliang-liu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the WHPG19 sources at origin/WHPG-master (19beta1) and WHPG19's own pg_dump.c version gates as a checklist. All nine changes in the PR check out against the catalog headers and backend code, and the unit suites pass locally at both TEST_GPDB_VERSION=19.999.0 and 7.999.0. Nice work on the commit messages and comments.

Cross-checking the rest of the PG12→PG19 delta against pg_dump.c turned up one gap that fails a restore, plus a few that silently drop information. Short list; the ones on files this PR touches are inline, the rest are spelled out below since GitHub only allows inline comments on changed lines.

Blocking

  1. Partition trigger clones are no longer tgisinternal on WHPG19, so they get dumped per child and the restore fails with "trigger already exists" (backup/queries_postdata.go:438, details below).

Silently lost on WHPG19, all handled by pg_dump
2. attcompression (PG14) is not backed up; COMPRESSION lz4 columns restore as pglz (inline at queries_table_defs.go).
3. The MAINTAIN privilege (PG17, ACL char m) is dropped by ParseACL, and grantees holding only the old seven privileges get promoted to ALL, which now includes MAINTAIN (backup/predata_acl.go:212, details below).
4. pg_auth_members.inherit_option / set_option (PG16) are not backed up (backup/queries_globals.go:559, details below).
5. Named / NO INHERIT / NOT VALID not-null constraints (PG18) lose those attributes once contype 'n' is excluded; NOT VALID is the one that can fail a restore (inline at queries_shared.go).
6. MULTIRANGE_TYPE_NAME (PG14) is not backed up; a custom multirange name restores as the default (backup/queries_types.go:435, details below).

Factual
7. The opclass-member dependency change landed in PG14, not PG13 (9f9682783 is reachable from REL_14_0 and not from REL_13_0). The comment in queries_operators.go and two commit messages say PG13 (inline).

I'd take (1) and (7) in this PR. (2)–(6) can be follow-up issues if you prefer, but please list them in the PR description so "reviewed the rest of the delta" is accurate about what remains.


backup/queries_postdata.go:438 — blocking

constraintClause := "NOT tgisinternal" is no longer enough on WHPG19. trigger.c creates partition clones with isInternal=false, in_partition=true (the CreateTriggerFiringOn call in the partition loop), so a clone has tgisinternal = false and tgparentid <> 0. On GP7 clones were internal and this filter dropped them; on WHPG19 every child clone is emitted as its own CREATE TRIGGER. On restore the parent's CREATE TRIGGER recreates the clones first, and the explicit child statement then fails with "trigger ... for relation ... already exists". WHPG19 allows row triggers on partitioned tables (only statement triggers and transition tables are blocked), so this is a live path.

pg_dump.c (PG15 branch of getTriggers) uses:

LEFT JOIN pg_catalog.pg_trigger u ON (u.oid = t.tgparentid)
WHERE ((NOT t.tgisinternal AND t.tgparentid = 0) OR t.tgenabled != u.tgenabled)

The simplest fix is AND t.tgparentid = 0 on the 19 path; the tgenabled != u.tgenabled half only matters if gpbackup wants to preserve a child that was enabled/disabled independently, which it doesn't do today.

backup/predata_acl.go:212

WHPG19's ACL_ALL_RIGHTS_STR is arwdDxtXUCTcsAm (acl.h:154); m is MAINTAIN (PG17). The for _, char := range permStr switch has no 'm' case and no default, so the character is skipped silently. Two effects: an explicit GRANT MAINTAIN ON t TO r is lost, and createPrivilegeStrings still treats arwdDxt as the full set, so a grantee holding only those seven gets GRANT ALL on restore and thereby gains MAINTAIN. Adding Maintain/MaintainWithGrant to the ACL struct and to the table branch of the "all privileges" check would fix both.

backup/queries_globals.go:559

pg_auth_members has inherit_option and set_option since PG16 (pg_auth_members.h:50,53). Only admin_option is read, so GRANT ... WITH INHERIT FALSE / SET FALSE restores with the defaults. The integration tests in this PR already adapt to the PG16 GRANTED BY semantics, so this seems like the right moment to pick up the other two options as well (pg_dumpall.c gates both on >= 160000).

backup/queries_types.go:435

pg_range.rngmultitypid (PG14, pg_range.h:40) is not read, so a CREATE TYPE ... AS RANGE (..., MULTIRANGE_TYPE_NAME = foo) restores with the default multirange name. pg_dump.c selects format_type(rngmultitypid, NULL) and emits MULTIRANGE_TYPE_NAME when it differs from the default. Functions or columns referring to the custom name will fail on restore.

Comment thread backup/queries_operators.go Outdated
Comment thread backup/queries_table_defs.go
Comment thread backup/queries_shared.go
Comment thread end_to_end/end_to_end_suite_test.go Outdated
Partition trigger clones (restore-breaking). trigger.c stores tgisinternal
verbatim -- CreateTriggerFiringOn passes isInternal through when it
recurses onto a partition child, and records the clone's parent in
tgparentid. So on WHPG19 a user row trigger's clones are not internal,
NOT tgisinternal let every one of them through, and each child got its
own CREATE TRIGGER. The parent's statement already recreates the clones,
so the restore failed with "trigger ... already exists". Match on the
parent link instead, as getTriggers() does; tgparentid postdates GPDB7,
hence the version gate.

attcompression (PG14): an explicit COMPRESSION lz4 column silently came
back as pglz. Emitted as its own ALTER TABLE ... SET COMPRESSION, which
is where pg_dump puts it rather than inline in the column definition.

MAINTAIN (PG17, ACL character 'm'): ParseACL skipped the character
entirely, so an explicit GRANT MAINTAIN was lost -- and, worse,
createPrivilegeStrings still treated arwdDxt as a relation's full set, so
a grantee holding only those seven was written as GRANT ALL and came back
holding MAINTAIN as well. Both the parse and the "all privileges" check
now know about it. The emission is version-gated because the character
cannot appear before PG17, which also keeps older majors byte-identical.

pg_auth_members.inherit_option / set_option (PG16): GRANT ... WITH
INHERIT FALSE / SET FALSE restored with the defaults, quietly widening
what the member could do. The options are now built as a list under a
single WITH, mirroring pg_dumpall: INHERIT always spelled out, SET only
when false.

MULTIRANGE_TYPE_NAME (PG14): a custom multirange name restored as the
generated default, so anything referring to it failed. Spelled out
unconditionally, as dumpRangeType() does, rather than reimplementing
makeMultirangeTypeName()'s truncation rules to detect the default.

Factual correction: the opclass-member dependency change landed in PG14,
not PG13 -- amadjustmembers (9f9682783) is reachable from REL_14_0 and
not from REL_13_0.

Also folds the one remaining "GPDB7 or later" predicate into testutils
alongside the skip helpers, and orders GetForeignDataWrappers by name.

Named / NO INHERIT / NOT VALID not-null constraints (PG18) are left to
issue #53: reproducing them changes how every column's NOT NULL is
rendered, which is a heavily tested path and a poor fit for this PR.
@Bonartze

Copy link
Copy Markdown
Contributor Author

Thanks — that was a genuinely useful pass, and the blocking one was real.

(1) Partition trigger clones — fixed. Confirmed in trigger.c: tgisinternal is stored verbatim (values[Anum_pg_trigger_tgisinternal - 1] = BoolGetDatum(isInternal)) and the partition recursion passes isInternal through with in_partition = true and parentTriggerOid = trigoid, so a user trigger's clones are non-internal with a parent link, exactly as you described. The 19 path now carries AND t.tgparentid = 0; version-gated because tgparentid postdates GPDB7.

(2), (3), (4), (6) — all taken in this PR rather than deferred; they were each contained enough. attcompression, MAINTAIN (both the ParseACL gap and the GRANT ALL promotion), inherit_option/set_option, and MULTIRANGE_TYPE_NAME. Each has unit coverage, and the emissions follow pg_dump/pg_dumpall rather than inventing a shape — INHERIT always spelled out with SET only when false, and the multirange name spelled out unconditionally instead of trying to detect the default.

(5) — filed as #53 and listed in the PR description, per your offer. It changes how every column's NOT NULL is rendered, which felt like the wrong thing to rush in alongside the rest.

(7) — corrected in the comment and in both commit messages. The branch was force-pushed for that reword; the trees are byte-identical, only the messages changed, so your inline comments should still line up.

Description now has a "Second review round" section listing all of the above and what remains. make unit_all_gpdb_versions passes at 5 / 6 / 7 / 19.

The MAINTAIN work broke the WHPG6 and WHPG7 live suites: 13 specs in each,
all "Mismatch on field Privileges[N].Maintain". The integration suite
compares DefaultACLForType against the ACL a real server hands back, and
the fixture was claiming MAINTAIN unconditionally while a GPDB6/7 server
has no such privilege to report.

It cannot simply be dropped again either: on WHPG19 the server does return
'm' for a relation's full privilege set, so the fixture would then be wrong
in the other direction -- and the unit suites need it present at major 19,
since createPrivilegeStrings now requires MAINTAIN before it will write
GRANT ALL for a relation.

So decide it per run. The live connection answers it where there is one,
which is the integration case; the unit suites fall through to the
environment variable that drives them, because their fixtures are built
while Ginkgo is still constructing the spec tree, before any BeforeEach has
run and so before a connection exists. The integration suite builds its own
connection rather than going through SetupTestEnvironment, so it hands the
version over in BeforeSuite, after MustConnect has populated it.

The one spec that pairs the fixture with a literal ACL string keeps the
seven pre-PG17 privileges and clears Maintain explicitly, so it asserts the
same thing on every major.

@huiliang-liu huiliang-liu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two more from re-reading the second round; the rest of it checks out against the WHPG19 sources (SET COMPRESSION as its own ALTER matches pg_dump.c:20133, INHERIT/SET matches dumpRoleMembership(), the fixture version plumbing is sound). Both below are inline.

Comment thread backup/queries_types.go Outdated
Comment thread backup/queries_globals.go
…play

Build MULTIRANGE_TYPE_NAME from pg_type/pg_namespace rather than
format_type(). format_type() only schema-qualifies a type the current
search_path cannot see, so the qualified output depended on
SetSessionGUCs() having set search_path to pg_catalog -- which it does
(backup/wrappers.go), but nothing at the query said so, and every other
FQN in the package is built from an explicit join. It matters because
DefineRange() resolves an unqualified MULTIRANGE_TYPE_NAME against the
restore session's search_path rather than the range type's schema.

Order role grants so the restore can replay them. PG16+ requires the role
named by GRANTED BY to hold ADMIN OPTION on the role being granted at the
moment the grant is replayed, so a grant attributed to a non-superuser
grantor has to follow that grantor's own admin grant. ORDER BY roleid,
member says nothing about that: both rows share the roleid, so it falls to
the members' OIDs, and a member that predates its grantor restores first
and fails. Reordered the way pg_dumpall's dumpRoleMembership() does it --
repeatedly emit whatever has become replayable, and emit the remainder in
catalog order if a pass makes no progress, so nothing is dropped. Gated on
19, where the enforcement starts, leaving older majors byte-identical.

The range-type integration specs compare the whole struct, so they now
expect the multirange name on 19: PG derives the default by replacing
"range" in the range type's name with "multirange" (makeMultirangeTypeName),
which is also why the name is spelled out rather than detected.

@huiliang-liu huiliang-liu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking both. The multirange change is fine (and see my correction on that thread). One problem with the ordering, inline.

Comment thread backup/queries_globals.go Outdated
rolsuper was the wrong test. check_role_grantor() (user.c:3306) skips the
ADMIN OPTION requirement for BOOTSTRAP_SUPERUSERID alone:

    if (grantorId != BOOTSTRAP_SUPERUSERID &&
        select_best_admin(grantorId, roleid) != grantorId)
        ereport(ERROR, ... "The grantor must have the ADMIN option on role")

and select_best_admin() is documented as finding an admin "ignoring
super-userness", so a superuser that is not the bootstrap superuser still
needs its own ADMIN OPTION row replayed first. pg_dumpall compares the
recorded grantor against BOOTSTRAP_SUPERUSERID for exactly this reason.

The integration suite was the counter-example: testrole is created
SUPERUSER, yet the BeforeEach in metadata_globals_create_test.go has to
grant it ADMIN OPTION before GRANTED BY testrole will replay. With rolsuper
that grant was waved through and the order fell back to member OID -- the
failure the ordering exists to prevent.

So test the grantor against OID 10 instead, and rename the field to say
what it means. A spec covers the case the old test got wrong; reverting the
predicate fails it.

@huiliang-liu huiliang-liu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, Thanks!

@Bonartze
Bonartze merged commit 048b919 into main Sep 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants