diff options
Diffstat (limited to 'src/bin/pg_amcheck/t')
-rw-r--r-- | src/bin/pg_amcheck/t/001_basic.pl | 12 | ||||
-rw-r--r-- | src/bin/pg_amcheck/t/002_nonesuch.pl | 359 | ||||
-rw-r--r-- | src/bin/pg_amcheck/t/003_check.pl | 518 | ||||
-rw-r--r-- | src/bin/pg_amcheck/t/004_verify_heapam.pl | 529 | ||||
-rw-r--r-- | src/bin/pg_amcheck/t/005_opclass_damage.pl | 59 |
5 files changed, 1477 insertions, 0 deletions
diff --git a/src/bin/pg_amcheck/t/001_basic.pl b/src/bin/pg_amcheck/t/001_basic.pl new file mode 100644 index 0000000..6f60e3e --- /dev/null +++ b/src/bin/pg_amcheck/t/001_basic.pl @@ -0,0 +1,12 @@ + +# Copyright (c) 2021, PostgreSQL Global Development Group + +use strict; +use warnings; + +use TestLib; +use Test::More tests => 8; + +program_help_ok('pg_amcheck'); +program_version_ok('pg_amcheck'); +program_options_handling_ok('pg_amcheck'); diff --git a/src/bin/pg_amcheck/t/002_nonesuch.pl b/src/bin/pg_amcheck/t/002_nonesuch.pl new file mode 100644 index 0000000..df0cb03 --- /dev/null +++ b/src/bin/pg_amcheck/t/002_nonesuch.pl @@ -0,0 +1,359 @@ + +# Copyright (c) 2021, PostgreSQL Global Development Group + +use strict; +use warnings; + +use PostgresNode; +use TestLib; +use Test::More tests => 100; + +# Test set-up +my ($node, $port); +$node = get_new_node('test'); +$node->init; +$node->start; +$port = $node->port; + +# Load the amcheck extension, upon which pg_amcheck depends +$node->safe_psql('postgres', q(CREATE EXTENSION amcheck)); + +######################################### +# Test non-existent databases + +# Failing to connect to the initial database is an error. +$node->command_checks_all( + [ 'pg_amcheck', 'qqq' ], + 1, [qr/^$/], + [qr/FATAL: database "qqq" does not exist/], + 'checking a non-existent database'); + +# Failing to resolve a database pattern is an error by default. +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'qqq', '-d', 'postgres' ], + 1, + [qr/^$/], + [qr/pg_amcheck: error: no connectable databases to check matching "qqq"/], + 'checking an unresolvable database pattern'); + +# But only a warning under --no-strict-names +$node->command_checks_all( + [ 'pg_amcheck', '--no-strict-names', '-d', 'qqq', '-d', 'postgres' ], + 0, + [qr/^$/], + [ + qr/pg_amcheck: warning: no connectable databases to check matching "qqq"/ + ], + 'checking an unresolvable database pattern under --no-strict-names'); + +# Check that a substring of an existent database name does not get interpreted +# as a matching pattern. +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'post', '-d', 'postgres' ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: error: no connectable databases to check matching "post"/ + ], + 'checking an unresolvable database pattern (substring of existent database)' +); + +# Check that a superstring of an existent database name does not get interpreted +# as a matching pattern. +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'postgresql', '-d', 'postgres' ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: error: no connectable databases to check matching "postgresql"/ + ], + 'checking an unresolvable database pattern (superstring of existent database)' +); + +######################################### +# Test connecting with a non-existent user + +# Failing to connect to the initial database due to bad username is an error. +$node->command_checks_all([ 'pg_amcheck', '-U', 'no_such_user', 'postgres' ], + 1, [qr/^$/], [], 'checking with a non-existent user'); + +######################################### +# Test checking databases without amcheck installed + +# Attempting to check a database by name where amcheck is not installed should +# raise a warning. If all databases are skipped, having no relations to check +# raises an error. +$node->command_checks_all( + [ 'pg_amcheck', 'template1' ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: warning: skipping database "template1": amcheck is not installed/, + qr/pg_amcheck: error: no relations to check/ + ], + 'checking a database by name without amcheck installed, no other databases' +); + +# Again, but this time with another database to check, so no error is raised. +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'template1', '-d', 'postgres' ], + 0, + [qr/^$/], + [ + qr/pg_amcheck: warning: skipping database "template1": amcheck is not installed/ + ], + 'checking a database by name without amcheck installed, with other databases' +); + +# Again, but by way of checking all databases +$node->command_checks_all( + [ 'pg_amcheck', '--all' ], + 0, + [qr/^$/], + [ + qr/pg_amcheck: warning: skipping database "template1": amcheck is not installed/ + ], + 'checking a database by pattern without amcheck installed, with other databases' +); + +######################################### +# Test unreasonable patterns + +# Check three-part unreasonable pattern that has zero-length names +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'postgres', '-t', '..' ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: error: no connectable databases to check matching "\.\."/ + ], + 'checking table pattern ".."'); + +# Again, but with non-trivial schema and relation parts +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'postgres', '-t', '.foo.bar' ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: error: no connectable databases to check matching "\.foo\.bar"/ + ], + 'checking table pattern ".foo.bar"'); + +# Check two-part unreasonable pattern that has zero-length names +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'postgres', '-t', '.' ], + 1, + [qr/^$/], + [qr/pg_amcheck: error: no heap tables to check matching "\."/], + 'checking table pattern "."'); + +# Check that a multipart database name is rejected +$node->command_checks_all( + [ 'pg_amcheck', '-d', 'localhost.postgres' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper qualified name \(too many dotted names\): localhost\.postgres/ + ], + 'multipart database patterns are rejected' +); + +# Check that a three-part schema name is rejected +$node->command_checks_all( + [ 'pg_amcheck', '-s', 'localhost.postgres.pg_catalog' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper qualified name \(too many dotted names\): localhost\.postgres\.pg_catalog/ + ], + 'three part schema patterns are rejected' +); + +# Check that a four-part table name is rejected +$node->command_checks_all( + [ 'pg_amcheck', '-t', 'localhost.postgres.pg_catalog.pg_class' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper relation name \(too many dotted names\): localhost\.postgres\.pg_catalog\.pg_class/ + ], + 'four part table patterns are rejected' +); + +# Check that too many dotted names still draws an error under --no-strict-names +# That flag means that it is ok for the object to be missing, not that it is ok +# for the object name to be ungrammatical +$node->command_checks_all( + [ 'pg_amcheck', '--no-strict-names', '-t', 'this.is.a.really.long.dotted.string' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper relation name \(too many dotted names\): this\.is\.a\.really\.long\.dotted\.string/ + ], + 'ungrammatical table names still draw errors under --no-strict-names' +); +$node->command_checks_all( + [ 'pg_amcheck', '--no-strict-names', '-s', 'postgres.long.dotted.string' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper qualified name \(too many dotted names\): postgres\.long\.dotted\.string/ + ], + 'ungrammatical schema names still draw errors under --no-strict-names' +); +$node->command_checks_all( + [ 'pg_amcheck', '--no-strict-names', '-d', 'postgres.long.dotted.string' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper qualified name \(too many dotted names\): postgres\.long\.dotted\.string/ + ], + 'ungrammatical database names still draw errors under --no-strict-names' +); + +# Likewise for exclusion patterns +$node->command_checks_all( + [ 'pg_amcheck', '--no-strict-names', '-T', 'a.b.c.d' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper relation name \(too many dotted names\): a\.b\.c\.d/ + ], + 'ungrammatical table exclusions still draw errors under --no-strict-names' +); +$node->command_checks_all( + [ 'pg_amcheck', '--no-strict-names', '-S', 'a.b.c' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper qualified name \(too many dotted names\): a\.b\.c/ + ], + 'ungrammatical schema exclusions still draw errors under --no-strict-names' +); +$node->command_checks_all( + [ 'pg_amcheck', '--no-strict-names', '-D', 'a.b' ], + 2, + [qr/^$/], + [ + qr/pg_amcheck: error: improper qualified name \(too many dotted names\): a\.b/ + ], + 'ungrammatical database exclusions still draw errors under --no-strict-names' +); + + +######################################### +# Test checking non-existent databases, schemas, tables, and indexes + +# Use --no-strict-names and a single existent table so we only get warnings +# about the failed pattern matches +$node->command_checks_all( + [ + 'pg_amcheck', '--no-strict-names', + '-t', 'no_such_table', + '-t', 'no*such*table', + '-i', 'no_such_index', + '-i', 'no*such*index', + '-r', 'no_such_relation', + '-r', 'no*such*relation', + '-d', 'no_such_database', + '-d', 'no*such*database', + '-r', 'none.none', + '-r', 'none.none.none', + '-r', 'postgres.none.none', + '-r', 'postgres.pg_catalog.none', + '-r', 'postgres.none.pg_class', + '-t', 'postgres.pg_catalog.pg_class', # This exists + ], + 0, + [qr/^$/], + [ + qr/pg_amcheck: warning: no heap tables to check matching "no_such_table"/, + qr/pg_amcheck: warning: no heap tables to check matching "no\*such\*table"/, + qr/pg_amcheck: warning: no btree indexes to check matching "no_such_index"/, + qr/pg_amcheck: warning: no btree indexes to check matching "no\*such\*index"/, + qr/pg_amcheck: warning: no relations to check matching "no_such_relation"/, + qr/pg_amcheck: warning: no relations to check matching "no\*such\*relation"/, + qr/pg_amcheck: warning: no heap tables to check matching "no\*such\*table"/, + qr/pg_amcheck: warning: no connectable databases to check matching "no_such_database"/, + qr/pg_amcheck: warning: no connectable databases to check matching "no\*such\*database"/, + qr/pg_amcheck: warning: no relations to check matching "none\.none"/, + qr/pg_amcheck: warning: no connectable databases to check matching "none\.none\.none"/, + qr/pg_amcheck: warning: no relations to check matching "postgres\.none\.none"/, + qr/pg_amcheck: warning: no relations to check matching "postgres\.pg_catalog\.none"/, + qr/pg_amcheck: warning: no relations to check matching "postgres\.none\.pg_class"/, + qr/pg_amcheck: warning: no connectable databases to check matching "no_such_database"/, + qr/pg_amcheck: warning: no connectable databases to check matching "no\*such\*database"/, + qr/pg_amcheck: warning: no connectable databases to check matching "none\.none\.none"/, + ], + 'many unmatched patterns and one matched pattern under --no-strict-names' +); + +######################################### +# Test checking otherwise existent objects but in databases where they do not exist + +$node->safe_psql( + 'postgres', q( + CREATE TABLE public.foo (f integer); + CREATE INDEX foo_idx ON foo(f); +)); +$node->safe_psql('postgres', q(CREATE DATABASE another_db)); + +$node->command_checks_all( + [ + 'pg_amcheck', '-d', + 'postgres', '--no-strict-names', + '-t', 'template1.public.foo', + '-t', 'another_db.public.foo', + '-t', 'no_such_database.public.foo', + '-i', 'template1.public.foo_idx', + '-i', 'another_db.public.foo_idx', + '-i', 'no_such_database.public.foo_idx', + ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: warning: skipping database "template1": amcheck is not installed/, + qr/pg_amcheck: warning: no heap tables to check matching "template1\.public\.foo"/, + qr/pg_amcheck: warning: no heap tables to check matching "another_db\.public\.foo"/, + qr/pg_amcheck: warning: no connectable databases to check matching "no_such_database\.public\.foo"/, + qr/pg_amcheck: warning: no btree indexes to check matching "template1\.public\.foo_idx"/, + qr/pg_amcheck: warning: no btree indexes to check matching "another_db\.public\.foo_idx"/, + qr/pg_amcheck: warning: no connectable databases to check matching "no_such_database\.public\.foo_idx"/, + qr/pg_amcheck: error: no relations to check/, + ], + 'checking otherwise existent objets in the wrong databases'); + + +######################################### +# Test schema exclusion patterns + +# Check with only schema exclusion patterns +$node->command_checks_all( + [ + 'pg_amcheck', '--all', '--no-strict-names', '-S', + 'public', '-S', 'pg_catalog', '-S', + 'pg_toast', '-S', 'information_schema', + ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: warning: skipping database "template1": amcheck is not installed/, + qr/pg_amcheck: error: no relations to check/ + ], + 'schema exclusion patterns exclude all relations'); + +# Check with schema exclusion patterns overriding relation and schema inclusion patterns +$node->command_checks_all( + [ + 'pg_amcheck', '--all', '--no-strict-names', '-s', + 'public', '-s', 'pg_catalog', '-s', + 'pg_toast', '-s', 'information_schema', '-t', + 'pg_catalog.pg_class', '-S*' + ], + 1, + [qr/^$/], + [ + qr/pg_amcheck: warning: skipping database "template1": amcheck is not installed/, + qr/pg_amcheck: error: no relations to check/ + ], + 'schema exclusion pattern overrides all inclusion patterns'); diff --git a/src/bin/pg_amcheck/t/003_check.pl b/src/bin/pg_amcheck/t/003_check.pl new file mode 100644 index 0000000..4122d72 --- /dev/null +++ b/src/bin/pg_amcheck/t/003_check.pl @@ -0,0 +1,518 @@ + +# Copyright (c) 2021, PostgreSQL Global Development Group + +use strict; +use warnings; + +use PostgresNode; +use TestLib; + +use Test::More tests => 63; + +my ($node, $port, %corrupt_page, %remove_relation); + +# Returns the filesystem path for the named relation. +# +# Assumes the test node is running +sub relation_filepath +{ + my ($dbname, $relname) = @_; + + my $pgdata = $node->data_dir; + my $rel = + $node->safe_psql($dbname, qq(SELECT pg_relation_filepath('$relname'))); + die "path not found for relation $relname" unless defined $rel; + return "$pgdata/$rel"; +} + +# Returns the name of the toast relation associated with the named relation. +# +# Assumes the test node is running +sub relation_toast +{ + my ($dbname, $relname) = @_; + + my $rel = $node->safe_psql( + $dbname, qq( + SELECT c.reltoastrelid::regclass + FROM pg_catalog.pg_class c + WHERE c.oid = '$relname'::regclass + AND c.reltoastrelid != 0 + )); + return $rel; +} + +# Adds the relation file for the given (dbname, relname) to the list +# to be corrupted by means of overwriting junk in the first page. +# +# Assumes the test node is running. +sub plan_to_corrupt_first_page +{ + my ($dbname, $relname) = @_; + my $relpath = relation_filepath($dbname, $relname); + $corrupt_page{$relpath} = 1; +} + +# Adds the relation file for the given (dbname, relname) to the list +# to be corrupted by means of removing the file.. +# +# Assumes the test node is running +sub plan_to_remove_relation_file +{ + my ($dbname, $relname) = @_; + my $relpath = relation_filepath($dbname, $relname); + $remove_relation{$relpath} = 1; +} + +# For the given (dbname, relname), if a corresponding toast table +# exists, adds that toast table's relation file to the list to be +# corrupted by means of removing the file. +# +# Assumes the test node is running. +sub plan_to_remove_toast_file +{ + my ($dbname, $relname) = @_; + my $toastname = relation_toast($dbname, $relname); + plan_to_remove_relation_file($dbname, $toastname) if ($toastname); +} + +# Corrupts the first page of the given file path +sub corrupt_first_page +{ + my ($relpath) = @_; + + my $fh; + open($fh, '+<', $relpath) + or BAIL_OUT("open failed: $!"); + binmode $fh; + + # Corrupt some line pointers. The values are chosen to hit the + # various line-pointer-corruption checks in verify_heapam.c + # on both little-endian and big-endian architectures. + sysseek($fh, 32, 0) + or BAIL_OUT("sysseek failed: $!"); + syswrite( + $fh, + pack("L*", + 0xAAA15550, 0xAAA0D550, 0x00010000, 0x00008000, + 0x0000800F, 0x001e8000, 0xFFFFFFFF) + ) or BAIL_OUT("syswrite failed: $!"); + close($fh) + or BAIL_OUT("close failed: $!"); +} + +# Stops the node, performs all the corruptions previously planned, and +# starts the node again. +# +sub perform_all_corruptions() +{ + $node->stop(); + for my $relpath (keys %corrupt_page) + { + corrupt_first_page($relpath); + } + for my $relpath (keys %remove_relation) + { + unlink($relpath); + } + $node->start; +} + +# Test set-up +$node = get_new_node('test'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum=off'); +$node->start; +$port = $node->port; + +for my $dbname (qw(db1 db2 db3)) +{ + # Create the database + $node->safe_psql('postgres', qq(CREATE DATABASE $dbname)); + + # Load the amcheck extension, upon which pg_amcheck depends. Put the + # extension in an unexpected location to test that pg_amcheck finds it + # correctly. Create tables with names that look like pg_catalog names to + # check that pg_amcheck does not get confused by them. Create functions in + # schema public that look like amcheck functions to check that pg_amcheck + # does not use them. + $node->safe_psql( + $dbname, q( + CREATE SCHEMA amcheck_schema; + CREATE EXTENSION amcheck WITH SCHEMA amcheck_schema; + CREATE TABLE amcheck_schema.pg_database (junk text); + CREATE TABLE amcheck_schema.pg_namespace (junk text); + CREATE TABLE amcheck_schema.pg_class (junk text); + CREATE TABLE amcheck_schema.pg_operator (junk text); + CREATE TABLE amcheck_schema.pg_proc (junk text); + CREATE TABLE amcheck_schema.pg_tablespace (junk text); + + CREATE FUNCTION public.bt_index_check(index regclass, + heapallindexed boolean default false) + RETURNS VOID AS $$ + BEGIN + RAISE EXCEPTION 'Invoked wrong bt_index_check!'; + END; + $$ LANGUAGE plpgsql; + + CREATE FUNCTION public.bt_index_parent_check(index regclass, + heapallindexed boolean default false, + rootdescend boolean default false) + RETURNS VOID AS $$ + BEGIN + RAISE EXCEPTION 'Invoked wrong bt_index_parent_check!'; + END; + $$ LANGUAGE plpgsql; + + CREATE FUNCTION public.verify_heapam(relation regclass, + on_error_stop boolean default false, + check_toast boolean default false, + skip text default 'none', + startblock bigint default null, + endblock bigint default null, + blkno OUT bigint, + offnum OUT integer, + attnum OUT integer, + msg OUT text) + RETURNS SETOF record AS $$ + BEGIN + RAISE EXCEPTION 'Invoked wrong verify_heapam!'; + END; + $$ LANGUAGE plpgsql; + )); + + # Create schemas, tables and indexes in five separate + # schemas. The schemas are all identical to start, but + # we will corrupt them differently later. + # + for my $schema (qw(s1 s2 s3 s4 s5)) + { + $node->safe_psql( + $dbname, qq( + CREATE SCHEMA $schema; + CREATE SEQUENCE $schema.seq1; + CREATE SEQUENCE $schema.seq2; + CREATE TABLE $schema.t1 ( + i INTEGER, + b BOX, + ia int4[], + ir int4range, + t TEXT + ); + CREATE TABLE $schema.t2 ( + i INTEGER, + b BOX, + ia int4[], + ir int4range, + t TEXT + ); + CREATE VIEW $schema.t2_view AS ( + SELECT i*2, t FROM $schema.t2 + ); + ALTER TABLE $schema.t2 + ALTER COLUMN t + SET STORAGE EXTERNAL; + + INSERT INTO $schema.t1 (i, b, ia, ir, t) + (SELECT gs::INTEGER AS i, + box(point(gs,gs+5),point(gs*2,gs*3)) AS b, + array[gs, gs + 1]::int4[] AS ia, + int4range(gs, gs+100) AS ir, + repeat('foo', gs) AS t + FROM generate_series(1,10000,3000) AS gs); + + INSERT INTO $schema.t2 (i, b, ia, ir, t) + (SELECT gs::INTEGER AS i, + box(point(gs,gs+5),point(gs*2,gs*3)) AS b, + array[gs, gs + 1]::int4[] AS ia, + int4range(gs, gs+100) AS ir, + repeat('foo', gs) AS t + FROM generate_series(1,10000,3000) AS gs); + + CREATE MATERIALIZED VIEW $schema.t1_mv AS SELECT * FROM $schema.t1; + CREATE MATERIALIZED VIEW $schema.t2_mv AS SELECT * FROM $schema.t2; + + create table $schema.p1 (a int, b int) PARTITION BY list (a); + create table $schema.p2 (a int, b int) PARTITION BY list (a); + + create table $schema.p1_1 partition of $schema.p1 for values in (1, 2, 3); + create table $schema.p1_2 partition of $schema.p1 for values in (4, 5, 6); + create table $schema.p2_1 partition of $schema.p2 for values in (1, 2, 3); + create table $schema.p2_2 partition of $schema.p2 for values in (4, 5, 6); + + CREATE INDEX t1_btree ON $schema.t1 USING BTREE (i); + CREATE INDEX t2_btree ON $schema.t2 USING BTREE (i); + + CREATE INDEX t1_hash ON $schema.t1 USING HASH (i); + CREATE INDEX t2_hash ON $schema.t2 USING HASH (i); + + CREATE INDEX t1_brin ON $schema.t1 USING BRIN (i); + CREATE INDEX t2_brin ON $schema.t2 USING BRIN (i); + + CREATE INDEX t1_gist ON $schema.t1 USING GIST (b); + CREATE INDEX t2_gist ON $schema.t2 USING GIST (b); + + CREATE INDEX t1_gin ON $schema.t1 USING GIN (ia); + CREATE INDEX t2_gin ON $schema.t2 USING GIN (ia); + + CREATE INDEX t1_spgist ON $schema.t1 USING SPGIST (ir); + CREATE INDEX t2_spgist ON $schema.t2 USING SPGIST (ir); + )); + } +} + +# Database 'db1' corruptions +# + +# Corrupt indexes in schema "s1" +plan_to_remove_relation_file('db1', 's1.t1_btree'); +plan_to_corrupt_first_page('db1', 's1.t2_btree'); + +# Corrupt tables in schema "s2" +plan_to_remove_relation_file('db1', 's2.t1'); +plan_to_corrupt_first_page('db1', 's2.t2'); + +# Corrupt tables, partitions, matviews, and btrees in schema "s3" +plan_to_remove_relation_file('db1', 's3.t1'); +plan_to_corrupt_first_page('db1', 's3.t2'); + +plan_to_remove_relation_file('db1', 's3.t1_mv'); +plan_to_remove_relation_file('db1', 's3.p1_1'); + +plan_to_corrupt_first_page('db1', 's3.t2_mv'); +plan_to_corrupt_first_page('db1', 's3.p2_1'); + +plan_to_remove_relation_file('db1', 's3.t1_btree'); +plan_to_corrupt_first_page('db1', 's3.t2_btree'); + +# Corrupt toast table, partitions, and materialized views in schema "s4" +plan_to_remove_toast_file('db1', 's4.t2'); + +# Corrupt all other object types in schema "s5". We don't have amcheck support +# for these types, but we check that their corruption does not trigger any +# errors in pg_amcheck +plan_to_remove_relation_file('db1', 's5.seq1'); +plan_to_remove_relation_file('db1', 's5.t1_hash'); +plan_to_remove_relation_file('db1', 's5.t1_gist'); +plan_to_remove_relation_file('db1', 's5.t1_gin'); +plan_to_remove_relation_file('db1', 's5.t1_brin'); +plan_to_remove_relation_file('db1', 's5.t1_spgist'); + +plan_to_corrupt_first_page('db1', 's5.seq2'); +plan_to_corrupt_first_page('db1', 's5.t2_hash'); +plan_to_corrupt_first_page('db1', 's5.t2_gist'); +plan_to_corrupt_first_page('db1', 's5.t2_gin'); +plan_to_corrupt_first_page('db1', 's5.t2_brin'); +plan_to_corrupt_first_page('db1', 's5.t2_spgist'); + + +# Database 'db2' corruptions +# +plan_to_remove_relation_file('db2', 's1.t1'); +plan_to_remove_relation_file('db2', 's1.t1_btree'); + + +# Leave 'db3' uncorrupted +# + +# Standard first arguments to TestLib functions +my @cmd = ('pg_amcheck', '-p', $port); + +# Regular expressions to match various expected output +my $no_output_re = qr/^$/; +my $line_pointer_corruption_re = qr/line pointer/; +my $missing_file_re = qr/could not open file ".*": No such file or directory/; +my $index_missing_relation_fork_re = + qr/index ".*" lacks a main relation fork/; + +# We have created test databases with tables populated with data, but have not +# yet corrupted anything. As such, we expect no corruption and verify that +# none is reported +# +$node->command_checks_all([ @cmd, '-d', 'db1', '-d', 'db2', '-d', 'db3' ], + 0, [$no_output_re], [$no_output_re], 'pg_amcheck prior to corruption'); + +# Perform the corruptions we planned above using only a single database restart. +# +perform_all_corruptions(); + + +# Checking databases with amcheck installed and corrupt relations, pg_amcheck +# command itself should return exit status = 2, because tables and indexes are +# corrupt, not exit status = 1, which would mean the pg_amcheck command itself +# failed. Corruption messages should go to stdout, and nothing to stderr. +# +$node->command_checks_all( + [ @cmd, 'db1' ], + 2, + [ + $index_missing_relation_fork_re, $line_pointer_corruption_re, + $missing_file_re, + ], + [$no_output_re], + 'pg_amcheck all schemas, tables and indexes in database db1'); + +$node->command_checks_all( + [ @cmd, '-d', 'db1', '-d', 'db2', '-d', 'db3' ], + 2, + [ + $index_missing_relation_fork_re, $line_pointer_corruption_re, + $missing_file_re, + ], + [$no_output_re], + 'pg_amcheck all schemas, tables and indexes in databases db1, db2, and db3' +); + +# Scans of indexes in s1 should detect the specific corruption that we created +# above. For missing relation forks, we know what the error message looks +# like. For corrupted index pages, the error might vary depending on how the +# page was formatted on disk, including variations due to alignment differences +# between platforms, so we accept any non-empty error message. +# +# If we don't limit the check to databases with amcheck installed, we expect +# complaint on stderr, but otherwise stderr should be quiet. +# +$node->command_checks_all( + [ @cmd, '--all', '-s', 's1', '-i', 't1_btree' ], + 2, + [$index_missing_relation_fork_re], + [ + qr/pg_amcheck: warning: skipping database "postgres": amcheck is not installed/ + ], + 'pg_amcheck index s1.t1_btree reports missing main relation fork'); + +$node->command_checks_all( + [ @cmd, '-d', 'db1', '-s', 's1', '-i', 't2_btree' ], + 2, + [qr/.+/], # Any non-empty error message is acceptable + [$no_output_re], + 'pg_amcheck index s1.s2 reports index corruption'); + +# Checking db1.s1 with indexes excluded should show no corruptions because we +# did not corrupt any tables in db1.s1. Verify that both stdout and stderr +# are quiet. +# +$node->command_checks_all( + [ @cmd, '-t', 's1.*', '--no-dependent-indexes', 'db1' ], + 0, [$no_output_re], [$no_output_re], + 'pg_amcheck of db1.s1 excluding indexes'); + +# Checking db2.s1 should show table corruptions if indexes are excluded +# +$node->command_checks_all( + [ @cmd, '-t', 's1.*', '--no-dependent-indexes', 'db2' ], + 2, [$missing_file_re], [$no_output_re], + 'pg_amcheck of db2.s1 excluding indexes'); + +# In schema db1.s3, the tables and indexes are both corrupt. We should see +# corruption messages on stdout, and nothing on stderr. +# +$node->command_checks_all( + [ @cmd, '-s', 's3', 'db1' ], + 2, + [ + $index_missing_relation_fork_re, $line_pointer_corruption_re, + $missing_file_re, + ], + [$no_output_re], + 'pg_amcheck schema s3 reports table and index errors'); + +# In schema db1.s4, only toast tables are corrupt. Check that under default +# options the toast corruption is reported, but when excluding toast we get no +# error reports. +$node->command_checks_all([ @cmd, '-s', 's4', 'db1' ], + 2, [$missing_file_re], [$no_output_re], + 'pg_amcheck in schema s4 reports toast corruption'); + +$node->command_checks_all( + [ + @cmd, '--no-dependent-toast', '--exclude-toast-pointers', '-s', 's4', + 'db1' + ], + 0, + [$no_output_re], + [$no_output_re], + 'pg_amcheck in schema s4 excluding toast reports no corruption'); + +# Check that no corruption is reported in schema db1.s5 +$node->command_checks_all([ @cmd, '-s', 's5', 'db1' ], + 0, [$no_output_re], [$no_output_re], + 'pg_amcheck over schema s5 reports no corruption'); + +# In schema db1.s1, only indexes are corrupt. Verify that when we exclude +# the indexes, no corruption is reported about the schema. +# +$node->command_checks_all( + [ @cmd, '-s', 's1', '-I', 't1_btree', '-I', 't2_btree', 'db1' ], + 0, + [$no_output_re], + [$no_output_re], + 'pg_amcheck over schema s1 with corrupt indexes excluded reports no corruption' +); + +# In schema db1.s1, only indexes are corrupt. Verify that when we provide only +# table inclusions, and disable index expansion, no corruption is reported +# about the schema. +# +$node->command_checks_all( + [ @cmd, '-t', 's1.*', '--no-dependent-indexes', 'db1' ], + 0, + [$no_output_re], + [$no_output_re], + 'pg_amcheck over schema s1 with all indexes excluded reports no corruption' +); + +# In schema db1.s2, only tables are corrupt. Verify that when we exclude those +# tables that no corruption is reported. +# +$node->command_checks_all( + [ @cmd, '-s', 's2', '-T', 't1', '-T', 't2', 'db1' ], + 0, + [$no_output_re], + [$no_output_re], + 'pg_amcheck over schema s2 with corrupt tables excluded reports no corruption' +); + +# Check errors about bad block range command line arguments. We use schema s5 +# to avoid getting messages about corrupt tables or indexes. +# +command_fails_like( + [ @cmd, '-s', 's5', '--startblock', 'junk', 'db1' ], + qr/invalid start block/, + 'pg_amcheck rejects garbage startblock'); + +command_fails_like( + [ @cmd, '-s', 's5', '--endblock', '1234junk', 'db1' ], + qr/invalid end block/, + 'pg_amcheck rejects garbage endblock'); + +command_fails_like( + [ @cmd, '-s', 's5', '--startblock', '5', '--endblock', '4', 'db1' ], + qr/end block precedes start block/, + 'pg_amcheck rejects invalid block range'); + +# Check bt_index_parent_check alternates. We don't create any index corruption +# that would behave differently under these modes, so just smoke test that the +# arguments are handled sensibly. +# +$node->command_checks_all( + [ @cmd, '-s', 's1', '-i', 't1_btree', '--parent-check', 'db1' ], + 2, + [$index_missing_relation_fork_re], + [$no_output_re], + 'pg_amcheck smoke test --parent-check'); + +$node->command_checks_all( + [ + @cmd, '-s', 's1', '-i', 't1_btree', '--heapallindexed', + '--rootdescend', 'db1' + ], + 2, + [$index_missing_relation_fork_re], + [$no_output_re], + 'pg_amcheck smoke test --heapallindexed --rootdescend'); + +$node->command_checks_all( + [ @cmd, '-d', 'db1', '-d', 'db2', '-d', 'db3', '-S', 's*' ], + 0, [$no_output_re], [$no_output_re], + 'pg_amcheck excluding all corrupt schemas'); diff --git a/src/bin/pg_amcheck/t/004_verify_heapam.pl b/src/bin/pg_amcheck/t/004_verify_heapam.pl new file mode 100644 index 0000000..b603efa --- /dev/null +++ b/src/bin/pg_amcheck/t/004_verify_heapam.pl @@ -0,0 +1,529 @@ + +# Copyright (c) 2021, PostgreSQL Global Development Group + +use strict; +use warnings; + +use PostgresNode; +use TestLib; + +use Test::More; + +# This regression test demonstrates that the pg_amcheck binary correctly +# identifies specific kinds of corruption within pages. To test this, we need +# a mechanism to create corrupt pages with predictable, repeatable corruption. +# The postgres backend cannot be expected to help us with this, as its design +# is not consistent with the goal of intentionally corrupting pages. +# +# Instead, we create a table to corrupt, and with careful consideration of how +# postgresql lays out heap pages, we seek to offsets within the page and +# overwrite deliberately chosen bytes with specific values calculated to +# corrupt the page in expected ways. We then verify that pg_amcheck reports +# the corruption, and that it runs without crashing. Note that the backend +# cannot simply be started to run queries against the corrupt table, as the +# backend will crash, at least for some of the corruption types we generate. +# +# Autovacuum potentially touching the table in the background makes the exact +# behavior of this test harder to reason about. We turn it off to keep things +# simpler. We use a "belt and suspenders" approach, turning it off for the +# system generally in postgresql.conf, and turning it off specifically for the +# test table. +# +# This test depends on the table being written to the heap file exactly as we +# expect it to be, so we take care to arrange the columns of the table, and +# insert rows of the table, that give predictable sizes and locations within +# the table page. +# +# The HeapTupleHeaderData has 23 bytes of fixed size fields before the variable +# length t_bits[] array. We have exactly 3 columns in the table, so natts = 3, +# t_bits is 1 byte long, and t_hoff = MAXALIGN(23 + 1) = 24. +# +# We're not too fussy about which datatypes we use for the test, but we do care +# about some specific properties. We'd like to test both fixed size and +# varlena types. We'd like some varlena data inline and some toasted. And +# we'd like the layout of the table such that the datums land at predictable +# offsets within the tuple. We choose a structure without padding on all +# supported architectures: +# +# a BIGINT +# b TEXT +# c TEXT +# +# We always insert a 7-ascii character string into field 'b', which with a +# 1-byte varlena header gives an 8 byte inline value. We always insert a long +# text string in field 'c', long enough to force toast storage. +# +# We choose to read and write binary copies of our table's tuples, using perl's +# pack() and unpack() functions. Perl uses a packing code system in which: +# +# l = "signed 32-bit Long", +# L = "Unsigned 32-bit Long", +# S = "Unsigned 16-bit Short", +# C = "Unsigned 8-bit Octet", +# +# Each tuple in our table has a layout as follows: +# +# xx xx xx xx t_xmin: xxxx offset = 0 L +# xx xx xx xx t_xmax: xxxx offset = 4 L +# xx xx xx xx t_field3: xxxx offset = 8 L +# xx xx bi_hi: xx offset = 12 S +# xx xx bi_lo: xx offset = 14 S +# xx xx ip_posid: xx offset = 16 S +# xx xx t_infomask2: xx offset = 18 S +# xx xx t_infomask: xx offset = 20 S +# xx t_hoff: x offset = 22 C +# xx t_bits: x offset = 23 C +# xx xx xx xx xx xx xx xx 'a': xxxxxxxx offset = 24 LL +# xx xx xx xx xx xx xx xx 'b': xxxxxxxx offset = 32 CCCCCCCC +# xx xx xx xx xx xx xx xx 'c': xxxxxxxx offset = 40 CCllLL +# xx xx xx xx xx xx xx xx : xxxxxxxx ...continued +# xx xx : xx ...continued +# +# We could choose to read and write columns 'b' and 'c' in other ways, but +# it is convenient enough to do it this way. We define packing code +# constants here, where they can be compared easily against the layout. + +use constant HEAPTUPLE_PACK_CODE => 'LLLSSSSSCCLLCCCCCCCCCCllLL'; +use constant HEAPTUPLE_PACK_LENGTH => 58; # Total size + +# Read a tuple of our table from a heap page. +# +# Takes an open filehandle to the heap file, and the offset of the tuple. +# +# Rather than returning the binary data from the file, unpacks the data into a +# perl hash with named fields. These fields exactly match the ones understood +# by write_tuple(), below. Returns a reference to this hash. +# +sub read_tuple +{ + my ($fh, $offset) = @_; + my ($buffer, %tup); + sysseek($fh, $offset, 0) + or BAIL_OUT("sysseek failed: $!"); + defined(sysread($fh, $buffer, HEAPTUPLE_PACK_LENGTH)) + or BAIL_OUT("sysread failed: $!"); + + @_ = unpack(HEAPTUPLE_PACK_CODE, $buffer); + %tup = ( + t_xmin => shift, + t_xmax => shift, + t_field3 => shift, + bi_hi => shift, + bi_lo => shift, + ip_posid => shift, + t_infomask2 => shift, + t_infomask => shift, + t_hoff => shift, + t_bits => shift, + a_1 => shift, + a_2 => shift, + b_header => shift, + b_body1 => shift, + b_body2 => shift, + b_body3 => shift, + b_body4 => shift, + b_body5 => shift, + b_body6 => shift, + b_body7 => shift, + c_va_header => shift, + c_va_vartag => shift, + c_va_rawsize => shift, + c_va_extinfo => shift, + c_va_valueid => shift, + c_va_toastrelid => shift); + # Stitch together the text for column 'b' + $tup{b} = join('', map { chr($tup{"b_body$_"}) } (1 .. 7)); + return \%tup; +} + +# Write a tuple of our table to a heap page. +# +# Takes an open filehandle to the heap file, the offset of the tuple, and a +# reference to a hash with the tuple values, as returned by read_tuple(). +# Writes the tuple fields from the hash into the heap file. +# +# The purpose of this function is to write a tuple back to disk with some +# subset of fields modified. The function does no error checking. Use +# cautiously. +# +sub write_tuple +{ + my ($fh, $offset, $tup) = @_; + my $buffer = pack( + HEAPTUPLE_PACK_CODE, + $tup->{t_xmin}, $tup->{t_xmax}, + $tup->{t_field3}, $tup->{bi_hi}, + $tup->{bi_lo}, $tup->{ip_posid}, + $tup->{t_infomask2}, $tup->{t_infomask}, + $tup->{t_hoff}, $tup->{t_bits}, + $tup->{a_1}, $tup->{a_2}, + $tup->{b_header}, $tup->{b_body1}, + $tup->{b_body2}, $tup->{b_body3}, + $tup->{b_body4}, $tup->{b_body5}, + $tup->{b_body6}, $tup->{b_body7}, + $tup->{c_va_header}, $tup->{c_va_vartag}, + $tup->{c_va_rawsize}, $tup->{c_va_extinfo}, + $tup->{c_va_valueid}, $tup->{c_va_toastrelid}); + sysseek($fh, $offset, 0) + or BAIL_OUT("sysseek failed: $!"); + defined(syswrite($fh, $buffer, HEAPTUPLE_PACK_LENGTH)) + or BAIL_OUT("syswrite failed: $!"); + return; +} + +# Set umask so test directories and files are created with default permissions +umask(0077); + +# Set up the node. Once we create and corrupt the table, +# autovacuum workers visiting the table could crash the backend. +# Disable autovacuum so that won't happen. +my $node = get_new_node('test'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum=off'); + +# Start the node and load the extensions. We depend on both +# amcheck and pageinspect for this test. +$node->start; +my $port = $node->port; +my $pgdata = $node->data_dir; +$node->safe_psql('postgres', "CREATE EXTENSION amcheck"); +$node->safe_psql('postgres', "CREATE EXTENSION pageinspect"); + +# Get a non-zero datfrozenxid +$node->safe_psql('postgres', qq(VACUUM FREEZE)); + +# Create the test table with precisely the schema that our corruption function +# expects. +$node->safe_psql( + 'postgres', qq( + CREATE TABLE public.test (a BIGINT, b TEXT, c TEXT); + ALTER TABLE public.test SET (autovacuum_enabled=false); + ALTER TABLE public.test ALTER COLUMN c SET STORAGE EXTERNAL; + CREATE INDEX test_idx ON public.test(a, b); + )); + +# We want (0 < datfrozenxid < test.relfrozenxid). To achieve this, we freeze +# an otherwise unused table, public.junk, prior to inserting data and freezing +# public.test +$node->safe_psql( + 'postgres', qq( + CREATE TABLE public.junk AS SELECT 'junk'::TEXT AS junk_column; + ALTER TABLE public.junk SET (autovacuum_enabled=false); + VACUUM FREEZE public.junk + )); + +my $rel = $node->safe_psql('postgres', + qq(SELECT pg_relation_filepath('public.test'))); +my $relpath = "$pgdata/$rel"; + +# Insert data and freeze public.test +use constant ROWCOUNT => 16; +$node->safe_psql( + 'postgres', qq( + INSERT INTO public.test (a, b, c) + VALUES ( + x'DEADF9F9DEADF9F9'::bigint, + 'abcdefg', + repeat('w', 10000) + ); + VACUUM FREEZE public.test + )) for (1 .. ROWCOUNT); + +my $relfrozenxid = $node->safe_psql('postgres', + q(select relfrozenxid from pg_class where relname = 'test')); +my $datfrozenxid = $node->safe_psql('postgres', + q(select datfrozenxid from pg_database where datname = 'postgres')); + +# Sanity check that our 'test' table has a relfrozenxid newer than the +# datfrozenxid for the database, and that the datfrozenxid is greater than the +# first normal xid. We rely on these invariants in some of our tests. +if ($datfrozenxid <= 3 || $datfrozenxid >= $relfrozenxid) +{ + $node->clean_node; + plan skip_all => + "Xid thresholds not as expected: got datfrozenxid = $datfrozenxid, relfrozenxid = $relfrozenxid"; + exit; +} + +# Find where each of the tuples is located on the page. +my @lp_off; +for my $tup (0 .. ROWCOUNT - 1) +{ + push( + @lp_off, + $node->safe_psql( + 'postgres', qq( +select lp_off from heap_page_items(get_raw_page('test', 'main', 0)) + offset $tup limit 1))); +} + +# Sanity check that our 'test' table on disk layout matches expectations. If +# this is not so, we will have to skip the test until somebody updates the test +# to work on this platform. +$node->stop; +my $file; +open($file, '+<', $relpath) + or BAIL_OUT("open failed: $!"); +binmode $file; + +my $ENDIANNESS; +for (my $tupidx = 0; $tupidx < ROWCOUNT; $tupidx++) +{ + my $offnum = $tupidx + 1; # offnum is 1-based, not zero-based + my $offset = $lp_off[$tupidx]; + my $tup = read_tuple($file, $offset); + + # Sanity-check that the data appears on the page where we expect. + my $a_1 = $tup->{a_1}; + my $a_2 = $tup->{a_2}; + my $b = $tup->{b}; + if ($a_1 != 0xDEADF9F9 || $a_2 != 0xDEADF9F9 || $b ne 'abcdefg') + { + close($file); # ignore errors on close; we're exiting anyway + $node->clean_node; + plan skip_all => + sprintf( + "Page layout differs from our expectations: expected (%x, %x, \"%s\"), got (%x, %x, \"%s\")", + 0xDEADF9F9, 0xDEADF9F9, "abcdefg", $a_1, $a_2, $b); + exit; + } + + # Determine endianness of current platform from the 1-byte varlena header + $ENDIANNESS = $tup->{b_header} == 0x11 ? "little" : "big"; +} +close($file) + or BAIL_OUT("close failed: $!"); +$node->start; + +# Ok, Xids and page layout look ok. We can run corruption tests. +plan tests => 19; + +# Check that pg_amcheck runs against the uncorrupted table without error. +$node->command_ok( + [ 'pg_amcheck', '-p', $port, 'postgres' ], + 'pg_amcheck test table, prior to corruption'); + +# Check that pg_amcheck runs against the uncorrupted table and index without error. +$node->command_ok([ 'pg_amcheck', '-p', $port, 'postgres' ], + 'pg_amcheck test table and index, prior to corruption'); + +$node->stop; + +# Some #define constants from access/htup_details.h for use while corrupting. +use constant HEAP_HASNULL => 0x0001; +use constant HEAP_XMAX_LOCK_ONLY => 0x0080; +use constant HEAP_XMIN_COMMITTED => 0x0100; +use constant HEAP_XMIN_INVALID => 0x0200; +use constant HEAP_XMAX_COMMITTED => 0x0400; +use constant HEAP_XMAX_INVALID => 0x0800; +use constant HEAP_NATTS_MASK => 0x07FF; +use constant HEAP_XMAX_IS_MULTI => 0x1000; +use constant HEAP_KEYS_UPDATED => 0x2000; + +# Helper function to generate a regular expression matching the header we +# expect verify_heapam() to return given which fields we expect to be non-null. +sub header +{ + my ($blkno, $offnum, $attnum) = @_; + return + qr/heap table "postgres\.public\.test", block $blkno, offset $offnum, attribute $attnum:\s+/ms + if (defined $attnum); + return + qr/heap table "postgres\.public\.test", block $blkno, offset $offnum:\s+/ms + if (defined $offnum); + return qr/heap table "postgres\.public\.test", block $blkno:\s+/ms + if (defined $blkno); + return qr/heap table "postgres\.public\.test":\s+/ms; +} + +# Corrupt the tuples, one type of corruption per tuple. Some types of +# corruption cause verify_heapam to skip to the next tuple without +# performing any remaining checks, so we can't exercise the system properly if +# we focus all our corruption on a single tuple. +# +my @expected; +open($file, '+<', $relpath) + or BAIL_OUT("open failed: $!"); +binmode $file; + +for (my $tupidx = 0; $tupidx < ROWCOUNT; $tupidx++) +{ + my $offnum = $tupidx + 1; # offnum is 1-based, not zero-based + my $offset = $lp_off[$tupidx]; + my $tup = read_tuple($file, $offset); + + my $header = header(0, $offnum, undef); + if ($offnum == 1) + { + # Corruptly set xmin < relfrozenxid + my $xmin = $relfrozenxid - 1; + $tup->{t_xmin} = $xmin; + $tup->{t_infomask} &= ~HEAP_XMIN_COMMITTED; + $tup->{t_infomask} &= ~HEAP_XMIN_INVALID; + + # Expected corruption report + push @expected, + qr/${header}xmin $xmin precedes relation freeze threshold 0:\d+/; + } + if ($offnum == 2) + { + # Corruptly set xmin < datfrozenxid + my $xmin = 3; + $tup->{t_xmin} = $xmin; + $tup->{t_infomask} &= ~HEAP_XMIN_COMMITTED; + $tup->{t_infomask} &= ~HEAP_XMIN_INVALID; + + push @expected, + qr/${$header}xmin $xmin precedes oldest valid transaction ID 0:\d+/; + } + elsif ($offnum == 3) + { + # Corruptly set xmin < datfrozenxid, further back, noting circularity + # of xid comparison. For a new cluster with epoch = 0, the corrupt + # xmin will be interpreted as in the future + $tup->{t_xmin} = 4026531839; + $tup->{t_infomask} &= ~HEAP_XMIN_COMMITTED; + $tup->{t_infomask} &= ~HEAP_XMIN_INVALID; + + push @expected, + qr/${$header}xmin 4026531839 equals or exceeds next valid transaction ID 0:\d+/; + } + elsif ($offnum == 4) + { + # Corruptly set xmax < relminmxid; + $tup->{t_xmax} = 4026531839; + $tup->{t_infomask} &= ~HEAP_XMAX_INVALID; + + push @expected, + qr/${$header}xmax 4026531839 equals or exceeds next valid transaction ID 0:\d+/; + } + elsif ($offnum == 5) + { + # Corrupt the tuple t_hoff, but keep it aligned properly + $tup->{t_hoff} += 128; + + push @expected, + qr/${$header}data begins at offset 152 beyond the tuple length 58/, + qr/${$header}tuple data should begin at byte 24, but actually begins at byte 152 \(3 attributes, no nulls\)/; + } + elsif ($offnum == 6) + { + # Corrupt the tuple t_hoff, wrong alignment + $tup->{t_hoff} += 3; + + push @expected, + qr/${$header}tuple data should begin at byte 24, but actually begins at byte 27 \(3 attributes, no nulls\)/; + } + elsif ($offnum == 7) + { + # Corrupt the tuple t_hoff, underflow but correct alignment + $tup->{t_hoff} -= 8; + + push @expected, + qr/${$header}tuple data should begin at byte 24, but actually begins at byte 16 \(3 attributes, no nulls\)/; + } + elsif ($offnum == 8) + { + # Corrupt the tuple t_hoff, underflow and wrong alignment + $tup->{t_hoff} -= 3; + + push @expected, + qr/${$header}tuple data should begin at byte 24, but actually begins at byte 21 \(3 attributes, no nulls\)/; + } + elsif ($offnum == 9) + { + # Corrupt the tuple to look like it has lots of attributes, not just 3 + $tup->{t_infomask2} |= HEAP_NATTS_MASK; + + push @expected, + qr/${$header}number of attributes 2047 exceeds maximum expected for table 3/; + } + elsif ($offnum == 10) + { + # Corrupt the tuple to look like it has lots of attributes, some of + # them null. This falsely creates the impression that the t_bits + # array is longer than just one byte, but t_hoff still says otherwise. + $tup->{t_infomask} |= HEAP_HASNULL; + $tup->{t_infomask2} |= HEAP_NATTS_MASK; + $tup->{t_bits} = 0xAA; + + push @expected, + qr/${$header}tuple data should begin at byte 280, but actually begins at byte 24 \(2047 attributes, has nulls\)/; + } + elsif ($offnum == 11) + { + # Same as above, but this time t_hoff plays along + $tup->{t_infomask} |= HEAP_HASNULL; + $tup->{t_infomask2} |= (HEAP_NATTS_MASK & 0x40); + $tup->{t_bits} = 0xAA; + $tup->{t_hoff} = 32; + + push @expected, + qr/${$header}number of attributes 67 exceeds maximum expected for table 3/; + } + elsif ($offnum == 12) + { + # Overwrite column 'b' 1-byte varlena header and initial characters to + # look like a long 4-byte varlena + # + # On little endian machines, bytes ending in two zero bits (xxxxxx00 bytes) + # are 4-byte length word, aligned, uncompressed data (up to 1G). We set the + # high six bits to 111111 and the lower two bits to 00, then the next three + # bytes with 0xFF using 0xFCFFFFFF. + # + # On big endian machines, bytes starting in two zero bits (00xxxxxx bytes) + # are 4-byte length word, aligned, uncompressed data (up to 1G). We set the + # low six bits to 111111 and the high two bits to 00, then the next three + # bytes with 0xFF using 0x3FFFFFFF. + # + $tup->{b_header} = $ENDIANNESS eq 'little' ? 0xFC : 0x3F; + $tup->{b_body1} = 0xFF; + $tup->{b_body2} = 0xFF; + $tup->{b_body3} = 0xFF; + + $header = header(0, $offnum, 1); + push @expected, + qr/${header}attribute with length \d+ ends at offset \d+ beyond total tuple length \d+/; + } + elsif ($offnum == 13) + { + # Corrupt the bits in column 'c' toast pointer + $tup->{c_va_valueid} = 0xFFFFFFFF; + + $header = header(0, $offnum, 2); + push @expected, qr/${header}toast value \d+ not found in toast table/; + } + elsif ($offnum == 14) + { + # Set both HEAP_XMAX_COMMITTED and HEAP_XMAX_IS_MULTI + $tup->{t_infomask} |= HEAP_XMAX_COMMITTED; + $tup->{t_infomask} |= HEAP_XMAX_IS_MULTI; + $tup->{t_xmax} = 4; + + push @expected, + qr/${header}multitransaction ID 4 equals or exceeds next valid multitransaction ID 1/; + } + elsif ($offnum == 15) # Last offnum must equal ROWCOUNT + { + # Set both HEAP_XMAX_COMMITTED and HEAP_XMAX_IS_MULTI + $tup->{t_infomask} |= HEAP_XMAX_COMMITTED; + $tup->{t_infomask} |= HEAP_XMAX_IS_MULTI; + $tup->{t_xmax} = 4000000000; + + push @expected, + qr/${header}multitransaction ID 4000000000 precedes relation minimum multitransaction ID threshold 1/; + } + write_tuple($file, $offset, $tup); +} +close($file) + or BAIL_OUT("close failed: $!"); +$node->start; + +# Run pg_amcheck against the corrupt table with epoch=0, comparing actual +# corruption messages against the expected messages +$node->command_checks_all( + [ 'pg_amcheck', '--no-dependent-indexes', '-p', $port, 'postgres' ], + 2, [@expected], [], 'Expected corruption message output'); + +$node->teardown_node; +$node->clean_node; diff --git a/src/bin/pg_amcheck/t/005_opclass_damage.pl b/src/bin/pg_amcheck/t/005_opclass_damage.pl new file mode 100644 index 0000000..30be684 --- /dev/null +++ b/src/bin/pg_amcheck/t/005_opclass_damage.pl @@ -0,0 +1,59 @@ + +# Copyright (c) 2021, PostgreSQL Global Development Group + +# This regression test checks the behavior of the btree validation in the +# presence of breaking sort order changes. +# +use strict; +use warnings; +use PostgresNode; +use TestLib; +use Test::More tests => 5; + +my $node = get_new_node('test'); +$node->init; +$node->start; + +# Create a custom operator class and an index which uses it. +$node->safe_psql( + 'postgres', q( + CREATE EXTENSION amcheck; + + CREATE FUNCTION int4_asc_cmp (a int4, b int4) RETURNS int LANGUAGE sql AS $$ + SELECT CASE WHEN $1 = $2 THEN 0 WHEN $1 > $2 THEN 1 ELSE -1 END; $$; + + CREATE OPERATOR CLASS int4_fickle_ops FOR TYPE int4 USING btree AS + OPERATOR 1 < (int4, int4), OPERATOR 2 <= (int4, int4), + OPERATOR 3 = (int4, int4), OPERATOR 4 >= (int4, int4), + OPERATOR 5 > (int4, int4), FUNCTION 1 int4_asc_cmp(int4, int4); + + CREATE TABLE int4tbl (i int4); + INSERT INTO int4tbl (SELECT * FROM generate_series(1,1000) gs); + CREATE INDEX fickleidx ON int4tbl USING btree (i int4_fickle_ops); +)); + +# We have not yet broken the index, so we should get no corruption +$node->command_like( + [ 'pg_amcheck', '-p', $node->port, 'postgres' ], + qr/^$/, + 'pg_amcheck all schemas, tables and indexes reports no corruption'); + +# Change the operator class to use a function which sorts in a different +# order to corrupt the btree index +$node->safe_psql( + 'postgres', q( + CREATE FUNCTION int4_desc_cmp (int4, int4) RETURNS int LANGUAGE sql AS $$ + SELECT CASE WHEN $1 = $2 THEN 0 WHEN $1 > $2 THEN -1 ELSE 1 END; $$; + UPDATE pg_catalog.pg_amproc + SET amproc = 'int4_desc_cmp'::regproc + WHERE amproc = 'int4_asc_cmp'::regproc +)); + +# Index corruption should now be reported +$node->command_checks_all( + [ 'pg_amcheck', '-p', $node->port, 'postgres' ], + 2, + [qr/item order invariant violated for index "fickleidx"/], + [], + 'pg_amcheck all schemas, tables and indexes reports fickleidx corruption' +); |