From ca84a369ab3b76edaca98d1c9d07c3be1c011664 Mon Sep 17 00:00:00 2001 From: Bharath Rupireddy Date: Mon, 14 Sep 2026 18:43:33 +0000 Subject: [PATCH v1 1/3] pg_dump: Preserve the chunk_id type of a TOAST relation. toast_value_type is read only when a TOAST relation is created, so a relation comes to use oid8 for its chunk_id by being created with the option set. Previously, pg_dump built the WITH clause of a relation from its reloptions alone. A reset of toast_value_type removes the entry from the reloptions of a relation but leaves its TOAST relation alone, which keeps the oid8 chunk_id it was created with, and pg_attribute is then the only place recording the type in use. The dump of such a relation carried no type, and the restore created its TOAST relation with oid instead. Nothing reported an error or a warning, as the chunk identifiers are assigned afresh by the restore, but the relation was limited to 2^32 out-of-line values again. Fix this by reporting the type of chunk_id in use when the reloptions do not mention it, read from pg_attribute, so that a plain dump carries it. A value present in the reloptions is a request to change the type, and takes precedence. Note that nothing is reported in binary upgrade mode. There the type is carried by binary_upgrade_set_next_toast_chunk_id_typoid and that path does not read the reloption at all, so pg_upgrade preserved the type all along, and a WITH clause would only add an entry the reloptions of the old cluster do not have. The test sets the OID counter of the cluster past 2^32 before its first start, so that losing oid8 is visible in the chunk identifiers themselves and not only in the catalogs. Author: Bharath Rupireddy Discussion: https://postgr.es/m/aqd_gYs35v--sVlR%40paquier.xyz --- src/bin/pg_dump/meson.build | 1 + src/bin/pg_dump/pg_dump.c | 47 ++++++- src/bin/pg_dump/pg_dump.h | 2 + src/bin/pg_dump/t/007_toast_value_type.pl | 150 ++++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/bin/pg_dump/t/007_toast_value_type.pl diff --git a/src/bin/pg_dump/meson.build b/src/bin/pg_dump/meson.build index 79bd5036841..40521027576 100644 --- a/src/bin/pg_dump/meson.build +++ b/src/bin/pg_dump/meson.build @@ -103,6 +103,7 @@ tests += { 't/004_pg_dump_parallel.pl', 't/005_pg_dump_filterfile.pl', 't/006_pg_dump_compress.pl', + 't/007_toast_value_type.pl', 't/010_dump_connstr.pl', ], }, diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c index 388c3b9c346..268a3440106 100644 --- a/src/bin/pg_dump/pg_dump.c +++ b/src/bin/pg_dump/pg_dump.c @@ -7187,6 +7187,7 @@ getTables(Archive *fout, int *numTables) int i_reloptions; int i_checkoption; int i_toastreloptions; + int i_toastvaluetype; int i_reloftype; int i_foreignserver; int i_amname; @@ -7266,6 +7267,36 @@ getTables(Archive *fout, int *numTables) "CASE WHEN 'check_option=local' = ANY (c.reloptions) THEN 'LOCAL'::text " "WHEN 'check_option=cascaded' = ANY (c.reloptions) THEN 'CASCADED'::text ELSE NULL END AS checkoption, "); + /* + * A reset of toast_value_type removes the entry from the reloptions of a + * relation but leaves its TOAST relation alone, which keeps the chunk_id + * type it was created with. The reloptions then say nothing about the + * type in use, and a WITH clause built from the reloptions alone would + * have the restore create the TOAST relation with the default type + * instead. Report the type of chunk_id for such a relation. Only oid8 + * needs to be reported, as a relation using oid matches the default. A + * value present in the reloptions is a request to change the type, and + * takes precedence. + * + * Nothing is reported in binary upgrade mode. There the type is carried + * by binary_upgrade_set_next_toast_chunk_id_typoid and the reloption is + * not read at all, so a WITH clause would only add an entry the + * reloptions of the old cluster do not have. + */ + if (fout->remoteVersion >= 190000 && !dopt->binary_upgrade) + appendPQExpBufferStr(query, + "CASE WHEN (SELECT a.atttypid FROM pg_attribute AS a " + " WHERE a.attrelid = c.reltoastrelid AND " + "attname = 'chunk_id'::text) = " + CppAsString2(OID8OID) " AND " + "NOT EXISTS (SELECT 1 FROM " + "unnest(coalesce(c.reloptions, '{}')) AS o " + "WHERE split_part(o, '=', 1) = 'toast_value_type') " + "THEN 'oid8' ELSE NULL END AS toast_value_type, "); + else + appendPQExpBufferStr(query, + "NULL AS toast_value_type, "); + appendPQExpBufferStr(query, "am.amname, "); @@ -7377,6 +7408,7 @@ getTables(Archive *fout, int *numTables) i_reloptions = PQfnumber(res, "reloptions"); i_checkoption = PQfnumber(res, "checkoption"); i_toastreloptions = PQfnumber(res, "toast_reloptions"); + i_toastvaluetype = PQfnumber(res, "toast_value_type"); i_reloftype = PQfnumber(res, "reloftype"); i_foreignserver = PQfnumber(res, "foreignserver"); i_amname = PQfnumber(res, "amname"); @@ -7458,6 +7490,10 @@ getTables(Archive *fout, int *numTables) else tblinfo[i].checkoption = pg_strdup(PQgetvalue(res, i, i_checkoption)); tblinfo[i].toast_reloptions = pg_strdup(PQgetvalue(res, i, i_toastreloptions)); + if (PQgetisnull(res, i, i_toastvaluetype)) + tblinfo[i].toast_value_type = NULL; + else + tblinfo[i].toast_value_type = pg_strdup(PQgetvalue(res, i, i_toastvaluetype)); tblinfo[i].reloftype = atooid(PQgetvalue(res, i, i_reloftype)); tblinfo[i].foreign_server = atooid(PQgetvalue(res, i, i_foreignserver)); if (PQgetisnull(res, i, i_amname)) @@ -17390,7 +17426,8 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo) } if (nonemptyReloptions(tbinfo->reloptions) || - nonemptyReloptions(tbinfo->toast_reloptions)) + nonemptyReloptions(tbinfo->toast_reloptions) || + tbinfo->toast_value_type != NULL) { bool addcomma = false; @@ -17400,6 +17437,14 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo) addcomma = true; appendReloptionsArrayAH(q, tbinfo->reloptions, "", fout); } + if (tbinfo->toast_value_type != NULL) + { + if (addcomma) + appendPQExpBufferStr(q, ", "); + addcomma = true; + appendPQExpBuffer(q, "toast_value_type=%s", + tbinfo->toast_value_type); + } if (nonemptyReloptions(tbinfo->toast_reloptions)) { if (addcomma) diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h index 2bbb5d5773b..d3e6c1603bc 100644 --- a/src/bin/pg_dump/pg_dump.h +++ b/src/bin/pg_dump/pg_dump.h @@ -315,6 +315,8 @@ typedef struct _tableInfo char *reloptions; /* options specified by WITH (...) */ char *checkoption; /* WITH CHECK OPTION, if any */ char *toast_reloptions; /* WITH options for the TOAST table */ + char *toast_value_type; /* type of chunk_id in the TOAST relation, + * when not implied by reloptions */ bool hasindex; /* does it have any indexes? */ bool hasrules; /* does it have any rules? */ bool hastriggers; /* does it have any triggers? */ diff --git a/src/bin/pg_dump/t/007_toast_value_type.pl b/src/bin/pg_dump/t/007_toast_value_type.pl new file mode 100644 index 00000000000..ecec20f3201 --- /dev/null +++ b/src/bin/pg_dump/t/007_toast_value_type.pl @@ -0,0 +1,150 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Tests how a dump and restore treats the type a TOAST relation uses for its +# chunk_id attribute. That type comes from the toast_value_type reloption of +# the relation owning it, read only when the TOAST relation is created, so the +# reloptions of a relation and the type its TOAST relation uses can differ. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# 2^32 + 100000. +my $big_next_oid = '4295067296'; + +# Type of the chunk_id attribute of the TOAST relation of a relation. +sub chunk_id_type +{ + my ($node, $dbname, $relname) = @_; + + return $node->safe_psql( + $dbname, + "SELECT a.atttypid::regtype FROM pg_class AS c, pg_attribute AS a + WHERE c.oid = '$relname'::regclass AND + a.attrelid = c.reltoastrelid AND a.attname = 'chunk_id'"); +} + +# Whether all the out-of-line values of a relation have a chunk_id that does +# not fit in an oid. The threshold compared against is the OID counter set +# below, which sits above the largest oid. +sub chunk_ids_past_oid_max +{ + my ($node, $dbname, $relname) = @_; + + return $node->safe_psql( + $dbname, + "SELECT bool_and(pg_column_toast_chunk_id(val) > '$big_next_oid'::oid8) + FROM $relname"); +} + +# Reloptions of a relation, as a string, or an empty string when it has none. +sub reloptions +{ + my ($node, $dbname, $relname) = @_; + + return $node->safe_psql( + $dbname, + "SELECT coalesce(array_to_string(reloptions, ','), '') + FROM pg_class WHERE oid = '$relname'::regclass"); +} + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; + +# Push the OID counter past 2^32 before the first start, so that the chunk +# identifiers handed out in an oid8 TOAST relation do not fit in an oid. A +# relation that loses oid8 along the way is then visible in the values +# themselves and not just in the catalogs. +command_ok([ 'pg_resetwal', '--next-oid' => $big_next_oid, $node->data_dir ], + 'set an OID counter past 2^32'); + +$node->start; + +$node->safe_psql('postgres', 'CREATE DATABASE src'); +$node->safe_psql('postgres', 'CREATE DATABASE dst'); + +# Test case 1: reloptions that say nothing about the type in use. +# +# A reset of toast_value_type removes the entry from the reloptions of a +# relation but leaves its TOAST relation alone, which keeps the chunk_id type +# it was created with. +# +# EXTERNAL storage keeps the values out of line without compressing them +# first. +$node->safe_psql( + 'src', qq{ +CREATE TABLE t_oid8_reset (id int, val text) + WITH (toast_value_type = 'oid8'); +ALTER TABLE t_oid8_reset ALTER COLUMN val SET STORAGE EXTERNAL; +INSERT INTO t_oid8_reset + SELECT g, repeat('a', 5000) FROM generate_series(1, 3) g; +}); + +is(chunk_id_type($node, 'src', 't_oid8_reset'), + 'oid8', 't_oid8_reset: chunk_id is an oid8 when asked for at creation'); +is(chunk_ids_past_oid_max($node, 'src', 't_oid8_reset'), + 't', 't_oid8_reset: chunk identifiers do not fit in an oid'); + +$node->safe_psql('src', 'ALTER TABLE t_oid8_reset RESET (toast_value_type)'); + +is(reloptions($node, 'src', 't_oid8_reset'), + '', 't_oid8_reset: reloptions are empty after the reset'); +is(chunk_id_type($node, 'src', 't_oid8_reset'), + 'oid8', 't_oid8_reset: the reset does not change the TOAST relation'); + +# Dump and restore. +my $dumpfile = $node->basedir . '/toast_value_type.dump'; +my $textfile = $node->basedir . '/toast_value_type.sql'; + +$node->command_ok( + [ 'pg_dump', '--format' => 'custom', '--file' => $dumpfile, 'src' ], + 'dump the source database'); +$node->command_ok([ 'pg_restore', '--file' => $textfile, $dumpfile ], + 'convert the dump to text'); + +my $dump = slurp_file($textfile); + +like( + $dump, + qr/CREATE TABLE public\.t_oid8_reset \(\n[^)]*\)\nWITH \(toast_value_type=oid8\);/, + 't_oid8_reset: the dump asks for oid8'); + +$node->command_ok( + [ 'pg_restore', '--dbname' => 'dst', $dumpfile ], + 'restore into the destination database'); + +is(chunk_id_type($node, 'dst', 't_oid8_reset'), + 'oid8', 't_oid8_reset: the restored TOAST relation still uses oid8'); +is(chunk_ids_past_oid_max($node, 'dst', 't_oid8_reset'), + 't', 't_oid8_reset: the restored chunk identifiers do not fit in an oid'); +is( $node->safe_psql( + 'dst', 'SELECT count(*), min(length(val)) FROM t_oid8_reset'), + '3|5000', + 't_oid8_reset: the out-of-line values are restored intact'); + +# A binary upgrade dump carries the type in a separate call, not a reloption. +my $oid8_typoid = $node->safe_psql('src', "SELECT 'oid8'::regtype::oid"); +my $bufile = $node->basedir . '/toast_value_type_binary_upgrade.sql'; + +$node->command_ok( + [ + 'pg_dump', '--binary-upgrade', '--schema-only', + '--table' => 't_oid8_reset', + '--file' => $bufile, + 'src' + ], + 'dump the source database for a binary upgrade'); + +my $budump = slurp_file($bufile); + +like( + $budump, + qr/binary_upgrade_set_next_toast_chunk_id_typoid\('$oid8_typoid'/, + 't_oid8_reset: a binary upgrade dump carries the type separately'); +unlike($budump, qr/toast_value_type/, + 't_oid8_reset: a binary upgrade dump reports no reloption for the type'); + +done_testing(); -- 2.47.3