From 3f8c92d801d7cbca9a4f2faa2113cad1ea6a26cc Mon Sep 17 00:00:00 2001 From: Zhijie Hou Date: Sun, 11 Oct 2026 22:47:04 +0800 Subject: [PATCH v3 3/3] Add test for replica identity recheck with non-default index equality A new test module, test_lossy_gist_ops, provides a lossy GiST opclass for text whose equality operator (===) is case-insensitive, i.e. different from the type's default equality, so that the divergence between the index's and the type's notions of equality can be exercised. The test uses a WITHOUT OVERLAPS primary key over (k, mr), which picks up the lossy opclass for k. The subscriber's row matches the publisher's key only under the index's equality. If the apply worker rechecks the candidate with the type's default equality instead of the index's, it rejects the row and loses the update. --- src/test/modules/Makefile | 1 + src/test/modules/meson.build | 1 + src/test/modules/test_lossy_gist_ops/Makefile | 21 ++++ .../modules/test_lossy_gist_ops/meson.build | 22 ++++ .../test_lossy_gist_ops--1.0.sql | 53 ++++++++ .../test_lossy_gist_ops/test_lossy_gist_ops.c | 113 ++++++++++++++++++ .../test_lossy_gist_ops.control | 4 + src/test/subscription/meson.build | 1 + .../t/040_wo_recheck_index_equality.pl | 82 +++++++++++++ 9 files changed, 298 insertions(+) create mode 100644 src/test/modules/test_lossy_gist_ops/Makefile create mode 100644 src/test/modules/test_lossy_gist_ops/meson.build create mode 100644 src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops--1.0.sql create mode 100644 src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.c create mode 100644 src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.control create mode 100644 src/test/subscription/t/040_wo_recheck_index_equality.pl diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 6c1504ac932..c6a8034db64 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -37,6 +37,7 @@ SUBDIRS = \ test_integerset \ test_json_parser \ test_lfind \ + test_lossy_gist_ops \ test_lwlock_tranches \ test_misc \ test_oat_hooks \ diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index ef3cdb8faf9..e8b95c0a799 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -39,6 +39,7 @@ subdir('test_int128') subdir('test_integerset') subdir('test_json_parser') subdir('test_lfind') +subdir('test_lossy_gist_ops') subdir('test_lwlock_tranches') subdir('test_misc') subdir('test_oat_hooks') diff --git a/src/test/modules/test_lossy_gist_ops/Makefile b/src/test/modules/test_lossy_gist_ops/Makefile new file mode 100644 index 00000000000..ced0ce6bf6f --- /dev/null +++ b/src/test/modules/test_lossy_gist_ops/Makefile @@ -0,0 +1,21 @@ +# src/test/modules/test_lossy_gist_ops/Makefile + +MODULE_big = test_lossy_gist_ops +OBJS = \ + $(WIN32RES) \ + test_lossy_gist_ops.o +PGFILEDESC = "test_lossy_gist_ops - test opclass for a lossy GiST index" + +EXTENSION = test_lossy_gist_ops +DATA = test_lossy_gist_ops--1.0.sql + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_lossy_gist_ops +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_lossy_gist_ops/meson.build b/src/test/modules/test_lossy_gist_ops/meson.build new file mode 100644 index 00000000000..ac245503695 --- /dev/null +++ b/src/test/modules/test_lossy_gist_ops/meson.build @@ -0,0 +1,22 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +test_lossy_gist_ops_sources = files( + 'test_lossy_gist_ops.c', +) + +if host_system == 'windows' + test_lossy_gist_ops_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_lossy_gist_ops', + '--FILEDESC', 'test_lossy_gist_ops - test opclass for a lossy GiST index',]) +endif + +test_lossy_gist_ops = shared_module('test_lossy_gist_ops', + test_lossy_gist_ops_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_lossy_gist_ops + +test_install_data += files( + 'test_lossy_gist_ops.control', + 'test_lossy_gist_ops--1.0.sql', +) diff --git a/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops--1.0.sql b/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops--1.0.sql new file mode 100644 index 00000000000..8647e649bd2 --- /dev/null +++ b/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops--1.0.sql @@ -0,0 +1,53 @@ +/* src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_lossy_gist_ops" to load this file. \quit + +CREATE FUNCTION lossy_text_eq_ci(text, text) +RETURNS boolean +AS 'MODULE_PATHNAME' +LANGUAGE C IMMUTABLE STRICT; + +CREATE OPERATOR === ( + LEFTARG = text, + RIGHTARG = text, + FUNCTION = lossy_text_eq_ci +); + +CREATE FUNCTION lossy_text_consistent(internal, text, smallint, oid, internal) +RETURNS boolean +AS 'MODULE_PATHNAME' +LANGUAGE C IMMUTABLE STRICT; + +CREATE FUNCTION lossy_text_union(internal, internal) +RETURNS text +AS 'MODULE_PATHNAME' +LANGUAGE C IMMUTABLE STRICT; + +CREATE FUNCTION lossy_text_penalty(internal, internal, internal) +RETURNS internal +AS 'MODULE_PATHNAME' +LANGUAGE C IMMUTABLE STRICT; + +CREATE FUNCTION lossy_text_picksplit(internal, internal) +RETURNS internal +AS 'MODULE_PATHNAME' +LANGUAGE C IMMUTABLE STRICT; + +CREATE FUNCTION lossy_text_same(text, text, internal) +RETURNS internal +AS 'MODULE_PATHNAME' +LANGUAGE C IMMUTABLE STRICT; + +-- The equality strategy is RTEqualStrategyNumber (18) for GiST. The +-- translate function is needed so that constraint creation can map equality +-- to the operator class's strategy. +CREATE OPERATOR CLASS lossy_text_ops + DEFAULT FOR TYPE text USING gist AS + OPERATOR 18 === (text, text), + FUNCTION 1 lossy_text_consistent(internal, text, smallint, oid, internal), + FUNCTION 2 lossy_text_union(internal, internal), + FUNCTION 5 lossy_text_penalty(internal, internal, internal), + FUNCTION 6 lossy_text_picksplit(internal, internal), + FUNCTION 7 lossy_text_same(text, text, internal), + FUNCTION 12 ("any", "any") gist_translate_cmptype_common(int4); diff --git a/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.c b/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.c new file mode 100644 index 00000000000..c61571d5389 --- /dev/null +++ b/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.c @@ -0,0 +1,113 @@ +/*------------------------------------------------------------------------- + * + * test_lossy_gist_ops.c + * Test GiST operator class for text that is lossy and whose equality + * operator differs from the type's default equality. + * + * The consistent function matches everything and always asks for a recheck, + * modelling a lossy index (like the GiST opclass for multiranges, which + * stores only the bounding range). The equality operator === is + * case-insensitive, unlike the type's default equality (texteq). + * + * This is used by the logical replication tests to verify that when the + * apply worker rechecks a candidate found through such an index, it compares + * using the index's own equality semantics (the opclass's equality operator + * and the index column's collation), not the type's default equality. + * + * IDENTIFICATION + * src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/gist.h" +#include "fmgr.h" +#include "port.h" +#include "utils/builtins.h" + +PG_MODULE_MAGIC; + +/* case-insensitive equality */ +PG_FUNCTION_INFO_V1(lossy_text_eq_ci); +Datum +lossy_text_eq_ci(PG_FUNCTION_ARGS) +{ + text *a = PG_GETARG_TEXT_PP(0); + text *b = PG_GETARG_TEXT_PP(1); + bool result = pg_strcasecmp(text_to_cstring(a), + text_to_cstring(b)) == 0; + + PG_RETURN_BOOL(result); +} + +/* lossy: everything is a possible match, always recheck */ +PG_FUNCTION_INFO_V1(lossy_text_consistent); +Datum +lossy_text_consistent(PG_FUNCTION_ARGS) +{ + bool *recheck = (bool *) PG_GETARG_POINTER(4); + + *recheck = true; + PG_RETURN_BOOL(true); +} + +/* any child key will do: consistent() never prunes anything */ +PG_FUNCTION_INFO_V1(lossy_text_union); +Datum +lossy_text_union(PG_FUNCTION_ARGS) +{ + GistEntryVector *entryvec = (GistEntryVector *) PG_GETARG_POINTER(0); + + PG_RETURN_TEXT_P(DatumGetTextPCopy(entryvec->vector[0].key)); +} + +PG_FUNCTION_INFO_V1(lossy_text_penalty); +Datum +lossy_text_penalty(PG_FUNCTION_ARGS) +{ + float *result = (float *) PG_GETARG_POINTER(2); + + *result = 0.0; + PG_RETURN_POINTER(result); +} + +PG_FUNCTION_INFO_V1(lossy_text_picksplit); +Datum +lossy_text_picksplit(PG_FUNCTION_ARGS) +{ + GistEntryVector *entryvec = (GistEntryVector *) PG_GETARG_POINTER(0); + GIST_SPLITVEC *v = (GIST_SPLITVEC *) PG_GETARG_POINTER(1); + OffsetNumber i; + OffsetNumber maxoff = entryvec->n - 1; + + v->spl_left = (OffsetNumber *) palloc0(sizeof(OffsetNumber) * entryvec->n); + v->spl_right = (OffsetNumber *) palloc0(sizeof(OffsetNumber) * entryvec->n); + v->spl_nleft = v->spl_nright = 0; + + for (i = 0; i <= maxoff; i++) + { + if (i % 2 == 0) + v->spl_left[v->spl_nleft++] = i; + else + v->spl_right[v->spl_nright++] = i; + } + + v->spl_ldatum = entryvec->vector[0].key; + v->spl_ldatum_exists = false; + v->spl_rdatum = entryvec->vector[maxoff].key; + v->spl_rdatum_exists = false; + + PG_RETURN_POINTER(v); +} + +/* never identical: no deduplication */ +PG_FUNCTION_INFO_V1(lossy_text_same); +Datum +lossy_text_same(PG_FUNCTION_ARGS) +{ + bool *result = (bool *) PG_GETARG_POINTER(2); + + *result = false; + PG_RETURN_POINTER(result); +} diff --git a/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.control b/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.control new file mode 100644 index 00000000000..5981c5abea8 --- /dev/null +++ b/src/test/modules/test_lossy_gist_ops/test_lossy_gist_ops.control @@ -0,0 +1,4 @@ +comment = 'Test opclass for a lossy GiST index with non-default equality' +default_version = '1.0' +module_pathname = '$libdir/test_lossy_gist_ops' +relocatable = true diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build index e71e95c6297..d01bc5805c0 100644 --- a/src/test/subscription/meson.build +++ b/src/test/subscription/meson.build @@ -48,6 +48,7 @@ tests += { 't/036_sequences.pl', 't/037_except.pl', 't/038_walsnd_shutdown_timeout.pl', + 't/040_wo_recheck_index_equality.pl', 't/100_bugs.pl', ], }, diff --git a/src/test/subscription/t/040_wo_recheck_index_equality.pl b/src/test/subscription/t/040_wo_recheck_index_equality.pl new file mode 100644 index 00000000000..63b9f89af76 --- /dev/null +++ b/src/test/subscription/t/040_wo_recheck_index_equality.pl @@ -0,0 +1,82 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test with a lossy REPLICA IDENTITY index whose equality operator differs +# from the key type's default equality +# +# test_lossy_gist_ops provides a lossy GiST opclass for text whose equality +# (===) is case-insensitive, unlike the type's default equality. The +# subscriber's row is found through the index because the operator class +# considers 'abc' and 'ABC' equal. If the apply worker rechecked the +# candidate with the type's default equality instead of the index's, it would +# reject the row and lose the update. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node_publisher = PostgreSQL::Test::Cluster->new('publisher'); +$node_publisher->init(allows_streaming => 'logical'); +$node_publisher->start; + +my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber'); +$node_subscriber->init(allows_streaming => 'logical'); +$node_subscriber->start; + +if ($node_subscriber->check_extension('test_lossy_gist_ops') == 0) +{ + plan skip_all => 'test_lossy_gist_ops extension not available'; +} + +# Publisher: simple table; REPLICA IDENTITY FULL so that the whole old row is +# sent as the key. +$node_publisher->safe_psql( + 'postgres', " + CREATE TABLE wo_ci (k text, mr int4multirange, v int); + ALTER TABLE wo_ci REPLICA IDENTITY FULL; + INSERT INTO wo_ci VALUES ('abc', '{[1,5)}', 1); + CREATE PUBLICATION pub FOR TABLE wo_ci; +"); + +# Subscriber: a WITHOUT OVERLAPS primary key over (k, mr), which picks up the +# lossy opclass for k. The local row matches the publisher's key only under +# the index's equality. +$node_subscriber->safe_psql( + 'postgres', " + CREATE EXTENSION test_lossy_gist_ops; + CREATE TABLE wo_ci (k text NOT NULL, mr int4multirange NOT NULL, v int, + PRIMARY KEY (k, mr WITHOUT OVERLAPS)); + INSERT INTO wo_ci VALUES ('ABC', '{[1,5)}', 0); +"); + +# The opclass equality is case-insensitive, the type default is not. +is( $node_subscriber->safe_psql('postgres', + "SELECT 'abc' === 'ABC', 'abc' = 'ABC'"), + 't|f', + 'opclass equality is case-insensitive, type equality is not'); + +my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres'; +$node_subscriber->safe_psql('postgres', + "CREATE SUBSCRIPTION sub CONNECTION '$publisher_connstr' PUBLICATION pub WITH (copy_data = false)" +); +$node_subscriber->wait_for_subscription_sync($node_publisher, 'sub'); + +my $log_offset = -s $node_subscriber->logfile; + +# The publisher updates its row; the old key sent is 'abc'. +$node_publisher->safe_psql('postgres', + "UPDATE wo_ci SET v = 2 WHERE k = 'abc'"); +$node_publisher->wait_for_catchup('sub'); + +# The update must have been applied. The key now carries the publisher's +# spelling (REPLICA IDENTITY FULL sends the whole new row), so check by value. +is( $node_subscriber->safe_psql('postgres', "SELECT v FROM wo_ci"), + '2', + 'update applied to the row the identity index considers a match'); + +my $logfile = slurp_file($node_subscriber->logfile(), $log_offset); +ok( $logfile !~ /conflict detected on relation "public.wo_ci"/, + 'no spurious update_missing conflict'); + +done_testing(); -- 2.34.1