|
| 1 | +use strict; |
| 2 | +use warnings; |
| 3 | +use Test::More; |
| 4 | +use lib '.'; |
| 5 | +use lib 't'; |
| 6 | +use SpockTest qw(create_cluster destroy_cluster system_or_bail system_maybe |
| 7 | + get_test_config cross_wire scalar_query ensure_lolor); |
| 8 | + |
| 9 | +# Zodan add_node with lolor large objects. The source cluster replicates the |
| 10 | +# lolor tables (lolor.pg_largeobject, lolor.pg_largeobject_metadata) in the |
| 11 | +# default replication set. Adding a node through zodan's add_node() must: |
| 12 | +# - reject a new node that lacks the lolor extension (data sync would fail), |
| 13 | +# - reject a new node whose lolor tables already contain data, |
| 14 | +# - accept a new node with lolor installed and empty, and copy the large |
| 15 | +# object data from the source during the initial data sync, |
| 16 | +# - stream large objects created after the join. |
| 17 | + |
| 18 | +my $cfg = get_test_config(); |
| 19 | +my $PG = $cfg->{pg_bin}; |
| 20 | +my $DB = $cfg->{db_name}; |
| 21 | +my $USER = $cfg->{db_user}; |
| 22 | +my $PASS = $cfg->{db_password}; |
| 23 | +my $HOST = $cfg->{host}; |
| 24 | + |
| 25 | +plan skip_all => "lolor extension unavailable (clone/build failed)" |
| 26 | + unless ensure_lolor(); |
| 27 | + |
| 28 | +# psql helper: 1-based node index, returns true on success (output to log). |
| 29 | +sub psql_ok { |
| 30 | + my ($node, $sql) = @_; |
| 31 | + my $port = $cfg->{node_ports}[$node - 1]; |
| 32 | + return system_maybe("$PG/psql", '-X', '-p', $port, '-d', $DB, |
| 33 | + '-v', 'ON_ERROR_STOP=1', '-c', $sql); |
| 34 | +} |
| 35 | + |
| 36 | +# Run SQL and capture combined stdout/stderr without going through a shell, |
| 37 | +# so quoting inside the SQL is preserved. Returns (exit_code, output). |
| 38 | +sub psql_capture { |
| 39 | + my ($node, $sql) = @_; |
| 40 | + my $port = $cfg->{node_ports}[$node - 1]; |
| 41 | + my $pid = open(my $fh, '-|'); |
| 42 | + die "fork failed: $!" unless defined $pid; |
| 43 | + if ($pid == 0) { |
| 44 | + open(STDERR, '>&', \*STDOUT) or die "cannot dup STDERR: $!"; |
| 45 | + exec("$PG/psql", '-X', '-p', $port, '-d', $DB, |
| 46 | + '-v', 'ON_ERROR_STOP=1', '-c', $sql); |
| 47 | + exit 127; |
| 48 | + } |
| 49 | + my $out = do { local $/; <$fh> } // ''; |
| 50 | + close($fh); |
| 51 | + return ($? >> 8, $out); |
| 52 | +} |
| 53 | + |
| 54 | +# Poll a scalar query on a node until it returns $expected. |
| 55 | +sub wait_for_scalar { |
| 56 | + my ($node, $sql, $expected, $timeout) = @_; |
| 57 | + $timeout //= 60; |
| 58 | + for (1 .. $timeout) { |
| 59 | + my $v = scalar_query($node, $sql); |
| 60 | + return 1 if defined $v && $v eq $expected; |
| 61 | + sleep(1); |
| 62 | + } |
| 63 | + return 0; |
| 64 | +} |
| 65 | + |
| 66 | +sub dsn { |
| 67 | + my ($node) = @_; |
| 68 | + my $port = $cfg->{node_ports}[$node - 1]; |
| 69 | + return "host=$HOST dbname=$DB port=$port user=$USER password=$PASS"; |
| 70 | +} |
| 71 | + |
| 72 | +# --- Cluster setup: n1/n2 cross-wired, n3 is a blank target for zodan ------- |
| 73 | + |
| 74 | +create_cluster(3, 'Create 3 instances for zodan lolor test'); |
| 75 | +cross_wire(2, ['n1', 'n2'], 'Cross-wire nodes n1 and n2'); |
| 76 | + |
| 77 | +# create_cluster registered a spock node on n3; drop it so n3 looks like a |
| 78 | +# freshly prepared instance (spock + dblink installed, no node/repsets). |
| 79 | +ok(psql_ok(3, "SELECT spock.node_drop('n3')"), 'n3 spock node registration dropped'); |
| 80 | +ok(psql_ok(3, "CREATE EXTENSION IF NOT EXISTS dblink"), 'dblink installed on n3'); |
| 81 | + |
| 82 | +# lolor on the source cluster, its tables in the default replication set. |
| 83 | +# n1 and n2 are cross-wired with automatic DDL replication, so CREATE |
| 84 | +# EXTENSION on n1 arrives on n2 by itself. |
| 85 | +ok(psql_ok(1, "CREATE EXTENSION lolor"), 'lolor installed on n1'); |
| 86 | +ok(wait_for_scalar(2, "SELECT count(*) FROM pg_extension WHERE extname = 'lolor'", '1'), |
| 87 | + 'lolor arrived on n2 via DDL replication'); |
| 88 | +for my $node (1, 2) { |
| 89 | + ok(psql_ok($node, "SELECT spock.repset_add_table('default', 'lolor.pg_largeobject')"), |
| 90 | + "lolor.pg_largeobject in default repset on n$node"); |
| 91 | + ok(psql_ok($node, "SELECT spock.repset_add_table('default', 'lolor.pg_largeobject_metadata')"), |
| 92 | + "lolor.pg_largeobject_metadata in default repset on n$node"); |
| 93 | +} |
| 94 | + |
| 95 | +# Large object on n1; sanity-check it reaches n2 before involving zodan. |
| 96 | +ok(psql_ok(1, "SET lolor.node=1; SELECT lo_from_bytea(0, '\\xdeadbeefcafe')"), |
| 97 | + 'large object created on n1'); |
| 98 | +ok(wait_for_scalar(2, "SELECT count(*) FROM lolor.pg_largeobject WHERE encode(data, 'hex') = 'deadbeefcafe'", '1'), |
| 99 | + 'large object replicated from n1 to n2'); |
| 100 | + |
| 101 | +# Load the zodan procedures on the node being added. |
| 102 | +system_or_bail("$PG/psql", '-X', '-p', $cfg->{node_ports}[2], '-d', $DB, |
| 103 | + '-v', 'ON_ERROR_STOP=1', '-f', '../../samples/Z0DAN/zodan.sql'); |
| 104 | +pass('zodan procedures loaded on n3'); |
| 105 | + |
| 106 | +my $add_node_sql = |
| 107 | + "CALL spock.add_node('n1', '" . dsn(1) . "', 'n3', '" . dsn(3) . "', " . |
| 108 | + "true, 'CA', 'USA', '{}'::jsonb)"; |
| 109 | + |
| 110 | +# --- Negative: source replicates lolor but n3 has no lolor extension -------- |
| 111 | + |
| 112 | +my ($rc, $out) = psql_capture(3, $add_node_sql); |
| 113 | +ok($rc != 0, 'add_node rejected while n3 lacks the lolor extension'); |
| 114 | +like($out, qr/does not have the lolor extension installed/, |
| 115 | + 'rejection message asks for CREATE EXTENSION lolor'); |
| 116 | + |
| 117 | +# --- Negative: n3 has lolor installed but with pre-existing data ------------ |
| 118 | + |
| 119 | +ok(psql_ok(3, "CREATE EXTENSION lolor"), 'lolor installed on n3'); |
| 120 | +ok(psql_ok(3, "SET lolor.node=3; SELECT lo_from_bytea(0, '\\x0bad0bad')"), |
| 121 | + 'pre-existing large object created on n3'); |
| 122 | + |
| 123 | +($rc, $out) = psql_capture(3, $add_node_sql); |
| 124 | +ok($rc != 0, 'add_node rejected while n3 has pre-existing lolor data'); |
| 125 | +like($out, qr/pre-existing large object data/, |
| 126 | + 'rejection message mentions pre-existing large object data'); |
| 127 | + |
| 128 | +# health_check 'pre' must report the same problem without raising. |
| 129 | +($rc, $out) = psql_capture(3, |
| 130 | + "CALL spock.health_check('n1', '" . dsn(1) . "', 'n3', '" . dsn(3) . "', 'pre', false)"); |
| 131 | +like($out, qr/FAIL: Destination database has pre-existing large object data/, |
| 132 | + 'health_check pre-check flags pre-existing lolor data'); |
| 133 | + |
| 134 | +# --- Positive: empty lolor tables, add_node copies the data ----------------- |
| 135 | + |
| 136 | +ok(psql_ok(3, "DELETE FROM lolor.pg_largeobject; DELETE FROM lolor.pg_largeobject_metadata"), |
| 137 | + 'pre-existing lolor data cleared on n3'); |
| 138 | + |
| 139 | +($rc, $out) = psql_capture(3, $add_node_sql); |
| 140 | +is($rc, 0, 'add_node succeeded with lolor installed and empty') or diag($out); |
| 141 | + |
| 142 | +ok(wait_for_scalar(3, "SELECT count(*) FROM lolor.pg_largeobject WHERE encode(data, 'hex') = 'deadbeefcafe'", '1'), |
| 143 | + 'existing large object data copied to n3 by initial sync'); |
| 144 | +ok(wait_for_scalar(3, "SELECT count(*) FROM lolor.pg_largeobject_metadata", '1'), |
| 145 | + 'large object metadata copied to n3'); |
| 146 | + |
| 147 | +# --- Streaming: a large object created after the join reaches n3 ------------ |
| 148 | + |
| 149 | +ok(psql_ok(1, "SET lolor.node=1; SELECT lo_from_bytea(0, '\\xfeedface')"), |
| 150 | + 'second large object created on n1 after join'); |
| 151 | +ok(wait_for_scalar(3, "SELECT count(*) FROM lolor.pg_largeobject WHERE encode(data, 'hex') = 'feedface'", '1'), |
| 152 | + 'post-join large object streamed to n3'); |
| 153 | + |
| 154 | +destroy_cluster('Destroy zodan lolor test cluster'); |
| 155 | + |
| 156 | +done_testing(); |
0 commit comments