From db0e6cdf267d44583880e52ed4bdc930ff67cae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bj=C3=B8rge=20L=C3=B8vland?= Date: Thu, 22 Oct 2026 12:29:00 +0200 Subject: [PATCH v1] Add amnotifydelete index access method callback Index access methods learn of deleted tuples only when VACUUM calls ambulkdelete. An index that keeps aggregates over its rows, such as a count per time range, needs to know within the deleting transaction. Add an optional callback, amnotifydelete, which the executor calls with the index values of the old tuple version on DELETE, on an UPDATE that inserts new index entries, and on the matching paths in MERGE, ON CONFLICT, logical replication apply and REPACK (CONCURRENTLY). Tables without such an index pay one cached relcache check per row. dummy_index_am implements the callback, with tests. Discussion: https://postgr.es/m/CAA+RuFCYSbRr41E3Qt4cqKS++_OQFiA=QUyidXL4f0c7zUiyKQ@mail.gmail.com --- contrib/bloom/blutils.c | 1 + doc/src/sgml/indexam.sgml | 64 +++++++++++ src/backend/access/brin/brin.c | 1 + src/backend/access/gin/ginutil.c | 1 + src/backend/access/gist/gist.c | 1 + src/backend/access/hash/hash.c | 1 + src/backend/access/index/indexam.c | 20 ++++ src/backend/access/nbtree/nbtree.c | 1 + src/backend/access/spgist/spgutils.c | 1 + src/backend/commands/repack.c | 18 ++- src/backend/executor/execIndexing.c | 90 +++++++++++++++ src/backend/executor/execReplication.c | 4 + src/backend/executor/nodeModifyTable.c | 45 ++++++-- src/backend/utils/cache/relcache.c | 30 +++++ src/include/access/amapi.h | 9 ++ src/include/access/genam.h | 5 + src/include/executor/executor.h | 4 + src/include/nodes/execnodes.h | 3 + src/include/utils/rel.h | 1 + src/include/utils/relcache.h | 1 + src/test/modules/dummy_index_am/Makefile | 4 +- .../modules/dummy_index_am/dummy_index_am.c | 18 +++ .../dummy_index_am/expected/notify_delete.out | 77 +++++++++++++ .../dummy_index_am/expected/notify_repack.out | 46 ++++++++ .../expected/notify_speculative.out | 105 ++++++++++++++++++ src/test/modules/dummy_index_am/meson.build | 12 ++ .../dummy_index_am/specs/notify_repack.spec | 53 +++++++++ .../specs/notify_speculative.spec | 82 ++++++++++++++ .../dummy_index_am/sql/notify_delete.sql | 79 +++++++++++++ .../modules/dummy_index_am/t/001_apply.pl | 81 ++++++++++++++ src/tools/pgindent/typedefs.list | 1 + 31 files changed, 844 insertions(+), 15 deletions(-) create mode 100644 src/test/modules/dummy_index_am/expected/notify_delete.out create mode 100644 src/test/modules/dummy_index_am/expected/notify_repack.out create mode 100644 src/test/modules/dummy_index_am/expected/notify_speculative.out create mode 100644 src/test/modules/dummy_index_am/specs/notify_repack.spec create mode 100644 src/test/modules/dummy_index_am/specs/notify_speculative.spec create mode 100644 src/test/modules/dummy_index_am/sql/notify_delete.sql create mode 100644 src/test/modules/dummy_index_am/t/001_apply.pl diff --git a/contrib/bloom/blutils.c b/contrib/bloom/blutils.c index 5111cdc6dd6..fbc06a8b345 100644 --- a/contrib/bloom/blutils.c +++ b/contrib/bloom/blutils.c @@ -133,6 +133,7 @@ blhandler(PG_FUNCTION_ARGS) .ambuildempty = blbuildempty, .aminsert = blinsert, .aminsertcleanup = NULL, + .amnotifydelete = NULL, .ambulkdelete = blbulkdelete, .amvacuumcleanup = blvacuumcleanup, .amcanreturn = NULL, diff --git a/doc/src/sgml/indexam.sgml b/doc/src/sgml/indexam.sgml index f48da318530..cfb086530e5 100644 --- a/doc/src/sgml/indexam.sgml +++ b/doc/src/sgml/indexam.sgml @@ -154,6 +154,7 @@ typedef struct IndexAmRoutine ambuildempty_function ambuildempty; aminsert_function aminsert; aminsertcleanup_function aminsertcleanup; /* can be NULL */ + amnotifydelete_function amnotifydelete; /* can be NULL */ ambulkdelete_function ambulkdelete; amvacuumcleanup_function amvacuumcleanup; amcanreturn_function amcanreturn; /* can be NULL */ @@ -403,6 +404,69 @@ aminsertcleanup (Relation indexRelation, +void +amnotifydelete (Relation indexRelation, + Datum *values, + bool *isnull, + ItemPointer heap_tid, + Relation heapRelation, + IndexInfo *indexInfo); + + Tell the index that the current transaction deleted a table tuple, or + replaced it with a new version. values and + isnull are the index column values of the old tuple + version, and heap_tid is its TID. The values are + computed again from the old version, so they are those + aminsert received, as index expressions are + immutable. After HOT updates, heap_tid is not the TID + the index entry points to. The call is made after the tuple is deleted, + for DELETE, the delete action of + MERGE, a row moved to another partition, a delete + applied by logical replication or replayed by REPACK + (CONCURRENTLY), and a tuple that INSERT ... ON + CONFLICT inserted speculatively and removed again because a + concurrent insert conflicted. For an update, it is made when the update + adds new index entries, so HOT updates don't call it, and an update that + only needs new entries in summarizing indexes calls only those. A + partial index is called only if the old version satisfied its predicate. + + + + The callback is not made for TRUNCATE, which rebuilds + the index through ambuild, nor when the transaction + or subtransaction aborts. It can also come before the command fails, for + example when a unique index then rejects the new version of an updated + row. An index that changes its state in this callback or in + aminsert must therefore do so transactionally, so + that an abort undoes the change. An index that takes predicate locks + must check for serializable conflicts itself, as in + aminsert. + + + + While CREATE INDEX CONCURRENTLY or REINDEX + CONCURRENTLY builds an index, the index is called only once it + accepts inserts. It can therefore miss the delete of a tuple that its + initial build included, or hear of the delete of a tuple it never + received. An index that keeps summaries must be built without + CONCURRENTLY, or be able to correct them itself. To + refuse a concurrent build, ambuild can raise an + error when indexInfo->ii_Concurrent is set. + + + + Most index access methods don't need this callback, because + VACUUM finds and removes dead index entries through + ambulkdelete. It is for an index that stores + aggregates over its rows, such as a count per time range. Such an index + must hear of a delete in the deleting transaction, or its summary keeps + counting a row that later snapshots no longer see. The callback can be + NULL. When no index on a table provides it, deletes and updates on the + table pay one cached relcache check per row. + + + + IndexBulkDeleteResult * ambulkdelete (IndexVacuumInfo *info, IndexBulkDeleteResult *stats, diff --git a/src/backend/access/brin/brin.c b/src/backend/access/brin/brin.c index 5059da8dce7..82df561b276 100644 --- a/src/backend/access/brin/brin.c +++ b/src/backend/access/brin/brin.c @@ -285,6 +285,7 @@ brinhandler(PG_FUNCTION_ARGS) .ambuildempty = brinbuildempty, .aminsert = brininsert, .aminsertcleanup = brininsertcleanup, + .amnotifydelete = NULL, .ambulkdelete = brinbulkdelete, .amvacuumcleanup = brinvacuumcleanup, .amcanreturn = NULL, diff --git a/src/backend/access/gin/ginutil.c b/src/backend/access/gin/ginutil.c index e7cba81d477..edd3134e894 100644 --- a/src/backend/access/gin/ginutil.c +++ b/src/backend/access/gin/ginutil.c @@ -70,6 +70,7 @@ ginhandler(PG_FUNCTION_ARGS) .ambuildempty = ginbuildempty, .aminsert = gininsert, .aminsertcleanup = NULL, + .amnotifydelete = NULL, .ambulkdelete = ginbulkdelete, .amvacuumcleanup = ginvacuumcleanup, .amcanreturn = NULL, diff --git a/src/backend/access/gist/gist.c b/src/backend/access/gist/gist.c index 8565e225be7..fd155d5da5d 100644 --- a/src/backend/access/gist/gist.c +++ b/src/backend/access/gist/gist.c @@ -90,6 +90,7 @@ gisthandler(PG_FUNCTION_ARGS) .ambuildempty = gistbuildempty, .aminsert = gistinsert, .aminsertcleanup = NULL, + .amnotifydelete = NULL, .ambulkdelete = gistbulkdelete, .amvacuumcleanup = gistvacuumcleanup, .amcanreturn = gistcanreturn, diff --git a/src/backend/access/hash/hash.c b/src/backend/access/hash/hash.c index 63846c10840..7303ce24145 100644 --- a/src/backend/access/hash/hash.c +++ b/src/backend/access/hash/hash.c @@ -101,6 +101,7 @@ hashhandler(PG_FUNCTION_ARGS) .ambuildempty = hashbuildempty, .aminsert = hashinsert, .aminsertcleanup = NULL, + .amnotifydelete = NULL, .ambulkdelete = hashbulkdelete, .amvacuumcleanup = hashvacuumcleanup, .amcanreturn = NULL, diff --git a/src/backend/access/index/indexam.c b/src/backend/access/index/indexam.c index db811056991..51a1a0fd4d8 100644 --- a/src/backend/access/index/indexam.c +++ b/src/backend/access/index/indexam.c @@ -238,6 +238,26 @@ index_insert(Relation indexRelation, indexInfo); } +/* ---------------- + * index_notify_delete - tell an index that a heap tuple went away + * ---------------- + */ +void +index_notify_delete(Relation indexRelation, + Datum *values, + bool *isnull, + ItemPointer heap_t_ctid, + Relation heapRelation, + IndexInfo *indexInfo) +{ + RELATION_CHECKS; + CHECK_REL_PROCEDURE(amnotifydelete); + + indexRelation->rd_indam->amnotifydelete(indexRelation, values, isnull, + heap_t_ctid, heapRelation, + indexInfo); +} + /* ------------------------- * index_insert_cleanup - clean up after all index inserts are done * ------------------------- diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c index 0abdd7b49f5..ac6e45b20c2 100644 --- a/src/backend/access/nbtree/nbtree.c +++ b/src/backend/access/nbtree/nbtree.c @@ -149,6 +149,7 @@ bthandler(PG_FUNCTION_ARGS) .ambuildempty = btbuildempty, .aminsert = btinsert, .aminsertcleanup = NULL, + .amnotifydelete = NULL, .ambulkdelete = btbulkdelete, .amvacuumcleanup = btvacuumcleanup, .amcanreturn = btcanreturn, diff --git a/src/backend/access/spgist/spgutils.c b/src/backend/access/spgist/spgutils.c index f2ee333f60d..03dc16b2a00 100644 --- a/src/backend/access/spgist/spgutils.c +++ b/src/backend/access/spgist/spgutils.c @@ -75,6 +75,7 @@ spghandler(PG_FUNCTION_ARGS) .ambuildempty = spgbuildempty, .aminsert = spginsert, .aminsertcleanup = NULL, + .amnotifydelete = NULL, .ambulkdelete = spgbulkdelete, .amvacuumcleanup = spgvacuumcleanup, .amcanreturn = spgcanreturn, diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c index 1b1a8213358..949525522ee 100644 --- a/src/backend/commands/repack.c +++ b/src/backend/commands/repack.c @@ -189,7 +189,8 @@ static void apply_concurrent_insert(Relation rel, TupleTableSlot *slot, static void apply_concurrent_update(Relation rel, TupleTableSlot *spilled_tuple, TupleTableSlot *ondisk_tuple, ChangeContext *chgcxt); -static void apply_concurrent_delete(Relation rel, TupleTableSlot *slot); +static void apply_concurrent_delete(Relation rel, TupleTableSlot *slot, + ChangeContext *chgcxt); static void restore_tuple(BufFile *file, Relation relation, TupleTableSlot *slot); static void prepare_concurrent_update(TupleTableSlot *dest, @@ -2838,7 +2839,7 @@ apply_concurrent_changes(BufFile *file, ChangeContext *chgcxt) found = find_target_tuple(rel, chgcxt, spilled_tuple, ondisk_tuple); if (!found) elog(ERROR, "could not find target tuple"); - apply_concurrent_delete(rel, ondisk_tuple); + apply_concurrent_delete(rel, ondisk_tuple, chgcxt); } else if (kind == CHANGE_UPDATE_NEW) { @@ -2938,6 +2939,11 @@ apply_concurrent_update(Relation rel, TupleTableSlot *spilled_tuple, if (update_indexes == TU_Summarizing) flags |= EIIT_ONLY_SUMMARIZING; + ExecNotifyIndexesOfDelete(chgcxt->cc_rri, + chgcxt->cc_estate, + flags, + &(ondisk_tuple->tts_tid), + ondisk_tuple); ExecInsertIndexTuples(chgcxt->cc_rri, chgcxt->cc_estate, flags, @@ -2949,7 +2955,8 @@ apply_concurrent_update(Relation rel, TupleTableSlot *spilled_tuple, } static void -apply_concurrent_delete(Relation rel, TupleTableSlot *slot) +apply_concurrent_delete(Relation rel, TupleTableSlot *slot, + ChangeContext *chgcxt) { TM_Result res; TM_FailureData tmfd; @@ -2970,6 +2977,11 @@ apply_concurrent_delete(Relation rel, TupleTableSlot *slot) errmsg("could not apply concurrent %s on relation \"%s\"", "DELETE", RelationGetRelationName(rel))); + ExecNotifyIndexesOfDelete(chgcxt->cc_rri, + chgcxt->cc_estate, + 0, + &(slot->tts_tid), + slot); pgstat_progress_incr_param(PROGRESS_REPACK_HEAP_TUPLES_DELETED, 1); } diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c index d0a714a42f2..66686da3d0a 100644 --- a/src/backend/executor/execIndexing.c +++ b/src/backend/executor/execIndexing.c @@ -106,6 +106,7 @@ */ #include "postgres.h" +#include "access/amapi.h" #include "access/genam.h" #include "access/relscan.h" #include "access/tableam.h" @@ -518,6 +519,95 @@ ExecInsertIndexTuples(ResultRelInfo *resultRelInfo, return result; } +/* ---------------------------------------------------------------- + * ExecNotifyIndexesOfDelete + * + * Tell the indexes whose access method sets amnotifydelete that a + * heap tuple was deleted, or replaced by a new version. tupleid is + * the old version. If oldslot is given it already holds that + * version; otherwise it is fetched. flags are those of + * ExecInsertIndexTuples; only EIIT_ONLY_SUMMARIZING is looked at. + * + * The caller must have opened the indexes, as for + * ExecInsertIndexTuples, before deleting the tuple. A table with no + * index that wants the call pays one cached lookup per row. + * ---------------------------------------------------------------- + */ +void +ExecNotifyIndexesOfDelete(ResultRelInfo *resultRelInfo, + EState *estate, + uint32 flags, + ItemPointer tupleid, + TupleTableSlot *oldslot) +{ + Relation heapRelation = resultRelInfo->ri_RelationDesc; + ExprContext *econtext; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + + Assert(ItemPointerIsValid(tupleid)); + + if (!RelationHasDeleteNotifyIndex(heapRelation)) + return; + + Assert(resultRelInfo->ri_IndexRelationDescs != NULL); + + if (oldslot == NULL) + { + if (resultRelInfo->ri_DeleteNotifySlot == NULL) + resultRelInfo->ri_DeleteNotifySlot = + table_slot_create(heapRelation, &estate->es_tupleTable); + oldslot = resultRelInfo->ri_DeleteNotifySlot; + + /* our own transaction deleted it, so it can't be pruned yet */ + if (!table_tuple_fetch_row_version(heapRelation, tupleid, + SnapshotAny, oldslot)) + elog(ERROR, "failed to fetch old tuple version for index notification"); + } + + econtext = GetPerTupleExprContext(estate); + econtext->ecxt_scantuple = oldslot; + + for (int i = 0; i < resultRelInfo->ri_NumIndices; i++) + { + Relation indexRelation = resultRelInfo->ri_IndexRelationDescs[i]; + IndexInfo *indexInfo = resultRelInfo->ri_IndexRelationInfo[i]; + + if (indexRelation == NULL || + indexRelation->rd_indam->amnotifydelete == NULL) + continue; + + /* + * An index that isn't ready for inserts yet (CREATE INDEX + * CONCURRENTLY) didn't hear of the tuple either. + */ + if (!indexInfo->ii_ReadyForInserts) + continue; + + if ((flags & EIIT_ONLY_SUMMARIZING) && !indexInfo->ii_Summarizing) + continue; + + /* A partial index never held a version failing its predicate */ + if (indexInfo->ii_Predicate != NIL) + { + ExprState *predicate = indexInfo->ii_PredicateState; + + if (predicate == NULL) + { + predicate = ExecPrepareQual(indexInfo->ii_Predicate, estate); + indexInfo->ii_PredicateState = predicate; + } + if (!ExecQual(predicate, econtext)) + continue; + } + + FormIndexDatum(indexInfo, oldslot, estate, values, isnull); + + index_notify_delete(indexRelation, values, isnull, tupleid, + heapRelation, indexInfo); + } +} + /* ---------------------------------------------------------------- * ExecCheckIndexConstraints * diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c index 13644328164..f3cb1041428 100644 --- a/src/backend/executor/execReplication.c +++ b/src/backend/executor/execReplication.c @@ -1040,6 +1040,8 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo, flags |= EIIT_NO_DUPE_ERROR; if (update_indexes == TU_Summarizing) flags |= EIIT_ONLY_SUMMARIZING; + ExecNotifyIndexesOfDelete(resultRelInfo, estate, flags, tid, + searchslot); recheckIndexes = ExecInsertIndexTuples(resultRelInfo, estate, flags, slot, conflictindexes, @@ -1095,6 +1097,8 @@ ExecSimpleRelationDelete(ResultRelInfo *resultRelInfo, /* OK, delete the tuple */ simple_table_tuple_delete(rel, tid, estate->es_snapshot); + ExecNotifyIndexesOfDelete(resultRelInfo, estate, 0, tid, searchslot); + /* AFTER ROW DELETE Triggers */ ExecARDeleteTriggers(estate, resultRelInfo, tid, NULL, NULL, false); diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index 558ceece9bb..7094d5247b0 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -1238,6 +1238,11 @@ ExecInsert(ModifyTableContext *context, table_tuple_complete_speculative(resultRelationDesc, slot, specToken, !specConflict); + /* The indexes were told of the tuple we just killed */ + if (specConflict) + ExecNotifyIndexesOfDelete(resultRelInfo, estate, 0, + &slot->tts_tid, slot); + /* * Wake up anyone waiting for our decision. They will re-check * the tuple, see that it's no longer speculative, and wait on our @@ -1483,9 +1488,9 @@ ExecPendingInserts(EState *estate) /* * ExecDeletePrologue -- subroutine for ExecDelete * - * Prepare executor state for DELETE. Actually, the only thing we have to do - * here is execute BEFORE ROW triggers. We return false if one of them makes - * the delete a no-op; otherwise, return true. + * Prepare executor state for DELETE: open the indexes if one wants to hear + * of the delete, and execute BEFORE ROW triggers. We return false if one of + * them makes the delete a no-op; otherwise, return true. */ static bool ExecDeletePrologue(ModifyTableContext *context, ResultRelInfo *resultRelInfo, @@ -1495,6 +1500,15 @@ ExecDeletePrologue(ModifyTableContext *context, ResultRelInfo *resultRelInfo, if (result) *result = TM_Ok; + /* + * Open the table's indexes, if we have not done so already, so that we + * can tell them of the deleted tuple. Take their locks before the tuple + * is deleted, as an INSERT or UPDATE does. + */ + if (RelationHasDeleteNotifyIndex(resultRelInfo->ri_RelationDesc) && + resultRelInfo->ri_IndexRelationDescs == NULL) + ExecOpenIndices(resultRelInfo, false); + /* BEFORE ROW DELETE triggers */ if (resultRelInfo->ri_TrigDesc && resultRelInfo->ri_TrigDesc->trig_delete_before_row) @@ -1553,6 +1567,10 @@ ExecDeleteEpilogue(ModifyTableContext *context, ResultRelInfo *resultRelInfo, EState *estate = context->estate; TransitionCaptureState *ar_delete_trig_tcs; + /* Foreign tables and views have no tuple ID */ + if (ItemPointerIsValid(tupleid)) + ExecNotifyIndexesOfDelete(resultRelInfo, estate, 0, tupleid, NULL); + /* * If this delete is the result of a partition key update that moved the * tuple to a new partition, put this row into the transition OLD TABLE, @@ -1585,7 +1603,7 @@ ExecDeleteEpilogue(ModifyTableContext *context, ResultRelInfo *resultRelInfo, * ExecDelete * * DELETE is like UPDATE, except that we delete the tuple and no - * index modifications are needed. + * index entries are added. * * When deleting from a table, tupleid identifies the tuple to delete and * oldtuple is NULL. When deleting through a view INSTEAD OF trigger, @@ -2356,7 +2374,8 @@ lreplace: static void ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt, ResultRelInfo *resultRelInfo, ItemPointer tupleid, - HeapTuple oldtuple, TupleTableSlot *slot) + HeapTuple oldtuple, TupleTableSlot *oldSlot, + TupleTableSlot *slot) { ModifyTableState *mtstate = context->mtstate; List *recheckIndexes = NIL; @@ -2368,6 +2387,8 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt, if (updateCxt->updateIndexes == TU_Summarizing) flags |= EIIT_ONLY_SUMMARIZING; + ExecNotifyIndexesOfDelete(resultRelInfo, context->estate, flags, + tupleid, oldSlot); recheckIndexes = ExecInsertIndexTuples(resultRelInfo, context->estate, flags, slot, NIL, NULL); @@ -2525,10 +2546,10 @@ ExecUpdate(ModifyTableContext *context, ResultRelInfo *resultRelInfo, * If the target tuple was concurrently updated, the trigger code will * have done EPQ and updated tupleid, following the update chain. In this * case, we must fetch the most recent version of old tuple for the - * benefit of RETURNING. Technically, we could get away with not doing - * this, if there is no RETURNING clause, or it doesn't refer to OLD, but - * it seems preferable to always ensure that the contents of oldSlot are - * correct. + * benefit of RETURNING, and of indexes told of the old version. + * Technically, we could get away with not doing this, if there is no + * RETURNING clause, or it doesn't refer to OLD, but it seems preferable + * to always ensure that the contents of oldSlot are correct. */ if (context->tmfd.traversed) { @@ -2747,7 +2768,7 @@ redo_act: (estate->es_processed)++; ExecUpdateEpilogue(context, &updateCxt, resultRelInfo, tupleid, oldtuple, - slot); + oldSlot, slot); /* Process RETURNING if present */ if (resultRelInfo->ri_projectReturning) @@ -3458,7 +3479,9 @@ lmerge_matched: if (result == TM_Ok) { ExecUpdateEpilogue(context, &updateCxt, resultRelInfo, - tupleid, NULL, newslot); + tupleid, NULL, + resultRelInfo->ri_oldTupleSlot, + newslot); mtstate->mt_merge_updated += 1; } break; diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c index d8f04a05309..d9e0d557dbf 100644 --- a/src/backend/utils/cache/relcache.c +++ b/src/backend/utils/cache/relcache.c @@ -5349,6 +5349,7 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) Bitmapset *idindexattrs; /* columns in the replica identity */ Bitmapset *hotblockingattrs; /* columns with HOT blocking indexes */ Bitmapset *summarizedattrs; /* columns with summarizing indexes */ + bool hasdeletenotify; /* an index wants amnotifydelete calls */ List *indexoidlist; List *newindexoidlist; Oid relpkindex; @@ -5415,6 +5416,7 @@ restart: idindexattrs = NULL; hotblockingattrs = NULL; summarizedattrs = NULL; + hasdeletenotify = false; foreach(l, indexoidlist) { Oid indexOid = lfirst_oid(l); @@ -5519,6 +5521,13 @@ restart: /* Collect all attributes in the index predicate, too */ pull_varattnos(indexPredicate, 1, attrs); + /* + * Remember whether any index wants to hear of deletes, so that DELETE + * can skip opening indexes when none does. + */ + if (indexDesc->rd_indam->amnotifydelete != NULL) + hasdeletenotify = true; + index_close(indexDesc, AccessShareLock); } @@ -5577,6 +5586,7 @@ restart: relation->rd_idattr = bms_copy(idindexattrs); relation->rd_hotblockingattr = bms_copy(hotblockingattrs); relation->rd_summarizedattr = bms_copy(summarizedattrs); + relation->rd_hasdeletenotify = hasdeletenotify; relation->rd_attrsvalid = true; MemoryContextSwitchTo(oldcxt); @@ -5599,6 +5609,26 @@ restart: } } +/* + * RelationHasDeleteNotifyIndex -- whether any index on the relation has an + * access method that wants amnotifydelete calls + * + * Computed along with the bitmaps of RelationGetIndexAttrBitmap(), so the + * first call after an invalidation builds them, as heap_update() would. + */ +bool +RelationHasDeleteNotifyIndex(Relation relation) +{ + if (!relation->rd_rel->relhasindex) + return false; + + if (!relation->rd_attrsvalid) + bms_free(RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_KEY)); + + /* not computed if the last index was dropped since relhasindex was set */ + return relation->rd_attrsvalid && relation->rd_hasdeletenotify; +} + /* * RelationGetIdentityKeyBitmap -- get a bitmap of replica identity attribute * numbers diff --git a/src/include/access/amapi.h b/src/include/access/amapi.h index 79240333530..88d14b2416e 100644 --- a/src/include/access/amapi.h +++ b/src/include/access/amapi.h @@ -131,6 +131,14 @@ typedef bool (*aminsert_function) (Relation indexRelation, typedef void (*aminsertcleanup_function) (Relation indexRelation, IndexInfo *indexInfo); +/* a heap tuple was deleted, or replaced by a new version */ +typedef void (*amnotifydelete_function) (Relation indexRelation, + Datum *values, + bool *isnull, + ItemPointer heap_tid, + Relation heapRelation, + IndexInfo *indexInfo); + /* bulk delete */ typedef IndexBulkDeleteResult *(*ambulkdelete_function) (IndexVacuumInfo *info, IndexBulkDeleteResult *stats, @@ -297,6 +305,7 @@ typedef struct IndexAmRoutine ambuildempty_function ambuildempty; aminsert_function aminsert; aminsertcleanup_function aminsertcleanup; /* can be NULL */ + amnotifydelete_function amnotifydelete; /* can be NULL */ ambulkdelete_function ambulkdelete; amvacuumcleanup_function amvacuumcleanup; amcanreturn_function amcanreturn; /* can be NULL */ diff --git a/src/include/access/genam.h b/src/include/access/genam.h index 1bfdc1ef6b1..fb2b22e63a2 100644 --- a/src/include/access/genam.h +++ b/src/include/access/genam.h @@ -152,6 +152,11 @@ extern bool index_insert(Relation indexRelation, IndexUniqueCheck checkUnique, bool indexUnchanged, IndexInfo *indexInfo); +extern void index_notify_delete(Relation indexRelation, + Datum *values, bool *isnull, + ItemPointer heap_t_ctid, + Relation heapRelation, + IndexInfo *indexInfo); extern void index_insert_cleanup(Relation indexRelation, IndexInfo *indexInfo); diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h index 152a1dfa568..02a390dcfe0 100644 --- a/src/include/executor/executor.h +++ b/src/include/executor/executor.h @@ -760,6 +760,10 @@ extern List *ExecInsertIndexTuples(ResultRelInfo *resultRelInfo, EState *estate, uint32 flags, TupleTableSlot *slot, List *arbiterIndexes, bool *specConflict); +extern void ExecNotifyIndexesOfDelete(ResultRelInfo *resultRelInfo, + EState *estate, uint32 flags, + ItemPointer tupleid, + TupleTableSlot *oldslot); extern bool ExecCheckIndexConstraints(ResultRelInfo *resultRelInfo, TupleTableSlot *slot, EState *estate, ItemPointer conflictTid, diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h index e3cea52e7df..1c01a22680a 100644 --- a/src/include/nodes/execnodes.h +++ b/src/include/nodes/execnodes.h @@ -502,6 +502,9 @@ typedef struct ResultRelInfo /* array of key/attr info for indices */ IndexInfo **ri_IndexRelationInfo; + /* old tuple version, for indexes that want amnotifydelete calls */ + TupleTableSlot *ri_DeleteNotifySlot; + /* * For UPDATE/DELETE/MERGE result relations, the attribute number of the * row identity junk attribute in the source plan's output tuples diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h index 7b8c2b1e362..b757cb9ec65 100644 --- a/src/include/utils/rel.h +++ b/src/include/utils/rel.h @@ -164,6 +164,7 @@ typedef struct RelationData Bitmapset *rd_idattr; /* included in replica identity index */ Bitmapset *rd_hotblockingattr; /* cols blocking HOT update */ Bitmapset *rd_summarizedattr; /* cols indexed by summarizing indexes */ + bool rd_hasdeletenotify; /* an index wants amnotifydelete calls */ PublicationDesc *rd_pubdesc; /* publication descriptor, or NULL */ diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h index 3e97da6a538..32c024c3da0 100644 --- a/src/include/utils/relcache.h +++ b/src/include/utils/relcache.h @@ -76,6 +76,7 @@ typedef enum IndexAttrBitmapKind extern Bitmapset *RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind); +extern bool RelationHasDeleteNotifyIndex(Relation relation); extern Bitmapset *RelationGetIdentityKeyBitmap(Relation relation); diff --git a/src/test/modules/dummy_index_am/Makefile b/src/test/modules/dummy_index_am/Makefile index aaf544a3854..5a34b687c92 100644 --- a/src/test/modules/dummy_index_am/Makefile +++ b/src/test/modules/dummy_index_am/Makefile @@ -6,7 +6,9 @@ EXTENSION = dummy_index_am DATA = dummy_index_am--1.0.sql PGFILEDESC = "dummy_index_am - index access method template" -REGRESS = reloptions +REGRESS = reloptions notify_delete +ISOLATION = notify_speculative notify_repack +TAP_TESTS = 1 ifdef USE_PGXS PG_CONFIG = pg_config diff --git a/src/test/modules/dummy_index_am/dummy_index_am.c b/src/test/modules/dummy_index_am/dummy_index_am.c index 31f8d2b8161..b3bc72a3f10 100644 --- a/src/test/modules/dummy_index_am/dummy_index_am.c +++ b/src/test/modules/dummy_index_am/dummy_index_am.c @@ -18,6 +18,7 @@ #include "catalog/index.h" #include "commands/vacuum.h" #include "nodes/pathnodes.h" +#include "utils/rel.h" PG_MODULE_MAGIC; @@ -188,6 +189,22 @@ diinsert(Relation index, Datum *values, bool *isnull, return false; } +/* + * A table tuple was deleted, or replaced by a new version. Report its old + * value, so that tests can see which paths reach the index. + */ +static void +dinotifydelete(Relation index, Datum *values, bool *isnull, + ItemPointer ht_ctid, Relation heapRel, + IndexInfo *indexInfo) +{ + if (isnull[0]) + elog(NOTICE, "%s: deleted NULL", RelationGetRelationName(index)); + else + elog(NOTICE, "%s: deleted %d", RelationGetRelationName(index), + DatumGetInt32(values[0])); +} + /* * Bulk deletion of all index entries pointing to a set of table tuples. */ @@ -322,6 +339,7 @@ dihandler(PG_FUNCTION_ARGS) .ambuild = dibuild, .ambuildempty = dibuildempty, .aminsert = diinsert, + .amnotifydelete = dinotifydelete, .ambulkdelete = dibulkdelete, .amvacuumcleanup = divacuumcleanup, .amcanreturn = NULL, diff --git a/src/test/modules/dummy_index_am/expected/notify_delete.out b/src/test/modules/dummy_index_am/expected/notify_delete.out new file mode 100644 index 00000000000..87bb9f50e6c --- /dev/null +++ b/src/test/modules/dummy_index_am/expected/notify_delete.out @@ -0,0 +1,77 @@ +-- Tests for amnotifydelete: every path that deletes a row, or replaces it +-- with a new version, must tell the index the old value. +-- Load the library quietly first: its string reloption validator prints +-- notices when it is loaded. +SET client_min_messages TO 'warning'; +CREATE EXTENSION IF NOT EXISTS dummy_index_am; +LOAD 'dummy_index_am'; +RESET client_min_messages; +CREATE TABLE notify_tab (k int4 PRIMARY KEY, v int4, w int4); +CREATE INDEX notify_idx ON notify_tab USING dummy_index_am (v); +INSERT INTO notify_tab VALUES (1, 10, 0), (2, 20, 0), (3, 30, 0), (4, 40, 0); +-- DELETE reports the old value +DELETE FROM notify_tab WHERE k = 1; +NOTICE: notify_idx: deleted 10 +-- UPDATE of the indexed column reports the old value +UPDATE notify_tab SET v = 21 WHERE k = 2; +NOTICE: notify_idx: deleted 20 +-- UPDATE of no indexed column is HOT: the index is not called +UPDATE notify_tab SET w = 1 WHERE k = 3; +-- UPDATE of another index's column gives the row a new TID, so the index +-- hears of the old version, as it hears of the new one +UPDATE notify_tab SET k = 33 WHERE k = 3; +NOTICE: notify_idx: deleted 30 +-- MERGE, both actions +MERGE INTO notify_tab t USING (VALUES (2), (4)) s(k) ON t.k = s.k + WHEN MATCHED AND t.k = 2 THEN UPDATE SET v = 22 + WHEN MATCHED THEN DELETE; +NOTICE: notify_idx: deleted 21 +NOTICE: notify_idx: deleted 40 +-- INSERT ... ON CONFLICT DO UPDATE +INSERT INTO notify_tab VALUES (2, 0, 0) ON CONFLICT (k) DO UPDATE SET v = 23; +NOTICE: notify_idx: deleted 22 +-- A partial index hears only of rows that were in it +CREATE INDEX notify_partial ON notify_tab USING dummy_index_am (v) + WHERE v > 100; +INSERT INTO notify_tab VALUES (5, 50, 0), (6, 150, 0); +DELETE FROM notify_tab WHERE k IN (5, 6); +NOTICE: notify_idx: deleted 50 +NOTICE: notify_idx: deleted 150 +NOTICE: notify_partial: deleted 150 +-- INSERT ... ON CONFLICT that finds the conflict before inserting has no +-- tuple to remove, and neither has one that finds no conflict +INSERT INTO notify_tab VALUES (2, 0, 0) ON CONFLICT DO NOTHING; +INSERT INTO notify_tab VALUES (7, 70, 0) ON CONFLICT DO NOTHING; +-- The call is made in the deleting transaction, even if it later aborts; +-- an aborted insert is not reported +BEGIN; +DELETE FROM notify_tab WHERE k = 7; +NOTICE: notify_idx: deleted 70 +ROLLBACK; +BEGIN; +INSERT INTO notify_tab VALUES (8, 80, 0); +ROLLBACK; +-- TRUNCATE rebuilds the index instead of reporting rows +TRUNCATE notify_tab; +-- An index with no column references still hears of deletes +CREATE TABLE notify_const (k int4); +CREATE INDEX notify_const_idx ON notify_const USING dummy_index_am ((7)); +INSERT INTO notify_const VALUES (1); +DELETE FROM notify_const; +NOTICE: notify_const_idx: deleted 7 +DROP TABLE notify_const; +-- A row moved between partitions is reported by the partition it left +CREATE TABLE notify_part (k int4, v int4) PARTITION BY RANGE (k); +CREATE TABLE notify_part1 PARTITION OF notify_part FOR VALUES FROM (0) TO (10); +CREATE TABLE notify_part2 PARTITION OF notify_part FOR VALUES FROM (10) TO (20); +CREATE INDEX notify_part_idx ON notify_part USING dummy_index_am (v); +INSERT INTO notify_part VALUES (1, 100); +UPDATE notify_part SET k = 11 WHERE k = 1; +NOTICE: notify_part1_v_idx: deleted 100 +DELETE FROM notify_part; +NOTICE: notify_part2_v_idx: deleted 100 +-- A table with only indexes that don't ask is not affected +CREATE TABLE notify_none (k int4 PRIMARY KEY); +INSERT INTO notify_none VALUES (1); +DELETE FROM notify_none; +DROP TABLE notify_tab, notify_part, notify_none; diff --git a/src/test/modules/dummy_index_am/expected/notify_repack.out b/src/test/modules/dummy_index_am/expected/notify_repack.out new file mode 100644 index 00000000000..76de8d7432d --- /dev/null +++ b/src/test/modules/dummy_index_am/expected/notify_repack.out @@ -0,0 +1,46 @@ +Parsed test spec with 2 sessions + +starting permutation: s2_begin s1_repack s2_delete s2_update s2_commit s1_show +step s2_begin: BEGIN; SELECT count(*) FROM repack_tab; +count +----- + 3 +(1 row) + +step s1_repack: REPACK (CONCURRENTLY) repack_tab; +s2: NOTICE: repack_idx: deleted 10 +step s2_delete: DELETE FROM repack_tab WHERE k = 1; +s2: NOTICE: repack_idx: deleted 20 +step s2_update: UPDATE repack_tab SET v = 21 WHERE k = 2; +step s2_commit: COMMIT; +s1: NOTICE: repack_idx_repacknew: deleted 10 +s1: NOTICE: repack_idx_repacknew: deleted 20 +step s1_repack: <... completed> +step s1_show: SELECT * FROM repack_tab ORDER BY k; +k| v +-+-- +2|21 +3|30 +(2 rows) + + +starting permutation: s2_begin s1_repack s2_insert s2_commit s1_show +step s2_begin: BEGIN; SELECT count(*) FROM repack_tab; +count +----- + 3 +(1 row) + +step s1_repack: REPACK (CONCURRENTLY) repack_tab; +step s2_insert: INSERT INTO repack_tab VALUES (4, 40); +step s2_commit: COMMIT; +step s1_repack: <... completed> +step s1_show: SELECT * FROM repack_tab ORDER BY k; +k| v +-+-- +1|10 +2|20 +3|30 +4|40 +(4 rows) + diff --git a/src/test/modules/dummy_index_am/expected/notify_speculative.out b/src/test/modules/dummy_index_am/expected/notify_speculative.out new file mode 100644 index 00000000000..fc01656795a --- /dev/null +++ b/src/test/modules/dummy_index_am/expected/notify_speculative.out @@ -0,0 +1,105 @@ +Parsed test spec with 3 sessions + +starting permutation: c_lock c_order s1_nothing s2_insert c_unlock c_show +step c_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step c_order: SELECT 'spec_idx'::regclass::oid < 'spec_key'::regclass::oid; +?column? +-------- +t +(1 row) + +step s1_nothing: INSERT INTO spec_tab VALUES (1, 10) ON CONFLICT DO NOTHING; +step s2_insert: INSERT INTO spec_tab VALUES (1, 20); +step c_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +s1: NOTICE: spec_idx: deleted 10 +step s1_nothing: <... completed> +step c_show: SELECT * FROM spec_tab ORDER BY k; +k| v +-+-- +1|20 +(1 row) + + +starting permutation: c_lock s1_update s2_insert c_unlock c_show +step c_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step s1_update: INSERT INTO spec_tab VALUES (1, 10) ON CONFLICT (k) DO UPDATE SET v = excluded.v; +step s2_insert: INSERT INTO spec_tab VALUES (1, 20); +step c_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +s1: NOTICE: spec_idx: deleted 10 +s1: NOTICE: spec_idx: deleted 20 +step s1_update: <... completed> +step c_show: SELECT * FROM spec_tab ORDER BY k; +k| v +-+-- +1|10 +(1 row) + + +starting permutation: c_lock s1_nothing s2_other c_unlock c_show +step c_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step s1_nothing: INSERT INTO spec_tab VALUES (1, 10) ON CONFLICT DO NOTHING; +step s2_other: INSERT INTO spec_tab VALUES (2, 20); +step c_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step s1_nothing: <... completed> +step c_show: SELECT * FROM spec_tab ORDER BY k; +k| v +-+-- +1|10 +2|20 +(2 rows) + + +starting permutation: c_lock s1_nothing s2_begin s2_insert s2_abort c_unlock c_show +step c_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step s1_nothing: INSERT INTO spec_tab VALUES (1, 10) ON CONFLICT DO NOTHING; +step s2_begin: BEGIN; +step s2_insert: INSERT INTO spec_tab VALUES (1, 20); +step s2_abort: ROLLBACK; +step c_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step s1_nothing: <... completed> +step c_show: SELECT * FROM spec_tab ORDER BY k; +k| v +-+-- +1|10 +(1 row) + diff --git a/src/test/modules/dummy_index_am/meson.build b/src/test/modules/dummy_index_am/meson.build index 9cedfd2cf8b..8957fa40f75 100644 --- a/src/test/modules/dummy_index_am/meson.build +++ b/src/test/modules/dummy_index_am/meson.build @@ -28,6 +28,18 @@ tests += { 'regress': { 'sql': [ 'reloptions', + 'notify_delete', + ], + }, + 'isolation': { + 'specs': [ + 'notify_speculative', + 'notify_repack', + ], + }, + 'tap': { + 'tests': [ + 't/001_apply.pl', ], }, } diff --git a/src/test/modules/dummy_index_am/specs/notify_repack.spec b/src/test/modules/dummy_index_am/specs/notify_repack.spec new file mode 100644 index 00000000000..59e09199ad9 --- /dev/null +++ b/src/test/modules/dummy_index_am/specs/notify_repack.spec @@ -0,0 +1,53 @@ +# Tests for amnotifydelete with REPACK (CONCURRENTLY). +# +# REPACK copies the table and builds new indexes, then replays the changes +# made meanwhile on the copy. A replayed delete or update must reach the new +# index as the original reached the old one. s2 holds a lock on the table +# without an XID, so REPACK waits for it before its last replay; s2's changes +# are then replayed under that lock. +# +# Each session loads the library quietly first: its string reloption +# validator prints notices when it is loaded. + +setup +{ + CREATE EXTENSION dummy_index_am; + CREATE TABLE repack_tab (k int4 PRIMARY KEY, v int4); + CREATE INDEX repack_idx ON repack_tab USING dummy_index_am (v); + INSERT INTO repack_tab VALUES (1, 10), (2, 20), (3, 30); +} + +teardown +{ + DROP TABLE repack_tab; + DROP EXTENSION dummy_index_am; +} + +session s1 +setup +{ + SET client_min_messages = warning; + LOAD 'dummy_index_am'; + RESET client_min_messages; +} +step s1_repack { REPACK (CONCURRENTLY) repack_tab; } +step s1_show { SELECT * FROM repack_tab ORDER BY k; } + +session s2 +setup +{ + SET client_min_messages = warning; + LOAD 'dummy_index_am'; + RESET client_min_messages; +} +step s2_begin { BEGIN; SELECT count(*) FROM repack_tab; } +step s2_delete { DELETE FROM repack_tab WHERE k = 1; } +step s2_update { UPDATE repack_tab SET v = 21 WHERE k = 2; } +step s2_insert { INSERT INTO repack_tab VALUES (4, 40); } +step s2_commit { COMMIT; } + +# A delete and a non-HOT update are reported to the new index too +permutation s2_begin s1_repack s2_delete s2_update s2_commit s1_show + +# An insert is not +permutation s2_begin s1_repack s2_insert s2_commit s1_show diff --git a/src/test/modules/dummy_index_am/specs/notify_speculative.spec b/src/test/modules/dummy_index_am/specs/notify_speculative.spec new file mode 100644 index 00000000000..6e3337e778c --- /dev/null +++ b/src/test/modules/dummy_index_am/specs/notify_speculative.spec @@ -0,0 +1,82 @@ +# Tests for amnotifydelete with INSERT ... ON CONFLICT. +# +# A speculative insertion that runs into a concurrent insert removes its +# tuple again, after every index was given it through aminsert, so the +# indexes must hear of the removal. s1 is made to wait while it inserts +# index entries, after its pre-check found no conflict but before the +# unique index sees one: notify_wait() in the expression of spec_idx, which +# comes first because its OID is lower, waits for the controller's lock. +# +# Each session loads the library quietly first: its string reloption +# validator prints notices when it is loaded. + +setup +{ + CREATE EXTENSION dummy_index_am; + CREATE FUNCTION notify_wait(int4) RETURNS int4 + IMMUTABLE LANGUAGE plpgsql AS $$ + BEGIN + IF current_setting('notify.wait', true) = 'on' THEN + PERFORM pg_advisory_xact_lock_shared(1); + END IF; + RETURN $1; + END $$; + CREATE TABLE spec_tab (k int4, v int4); + CREATE INDEX spec_idx ON spec_tab USING dummy_index_am (notify_wait(v)); + CREATE UNIQUE INDEX spec_key ON spec_tab (k); +} + +teardown +{ + DROP TABLE spec_tab; + DROP FUNCTION notify_wait(int4); + DROP EXTENSION dummy_index_am; +} + +session c +setup +{ + SET client_min_messages = warning; + LOAD 'dummy_index_am'; + RESET client_min_messages; +} +step c_lock { SELECT pg_advisory_lock(1); } +step c_order { SELECT 'spec_idx'::regclass::oid < 'spec_key'::regclass::oid; } +step c_unlock { SELECT pg_advisory_unlock(1); } +step c_show { SELECT * FROM spec_tab ORDER BY k; } + +session s1 +setup +{ + SET client_min_messages = warning; + LOAD 'dummy_index_am'; + RESET client_min_messages; + SET notify.wait = on; +} +step s1_nothing { INSERT INTO spec_tab VALUES (1, 10) ON CONFLICT DO NOTHING; } +step s1_update { INSERT INTO spec_tab VALUES (1, 10) ON CONFLICT (k) DO UPDATE SET v = excluded.v; } + +session s2 +setup +{ + SET client_min_messages = warning; + LOAD 'dummy_index_am'; + RESET client_min_messages; +} +step s2_begin { BEGIN; } +step s2_insert { INSERT INTO spec_tab VALUES (1, 20); } +step s2_other { INSERT INTO spec_tab VALUES (2, 20); } +step s2_abort { ROLLBACK; } + +# s2 inserts the same key while s1 waits: s1 removes its tuple, which spec_idx +# hears of, and then does nothing +permutation c_lock c_order s1_nothing s2_insert c_unlock c_show + +# The same, but s1 then updates s2's row, which spec_idx hears of too +permutation c_lock s1_update s2_insert c_unlock c_show + +# s2 inserts another key: no conflict, so nothing is removed +permutation c_lock s1_nothing s2_other c_unlock c_show + +# s2's insert of the same key aborts first: no conflict either +permutation c_lock s1_nothing s2_begin s2_insert s2_abort c_unlock c_show diff --git a/src/test/modules/dummy_index_am/sql/notify_delete.sql b/src/test/modules/dummy_index_am/sql/notify_delete.sql new file mode 100644 index 00000000000..d443823650c --- /dev/null +++ b/src/test/modules/dummy_index_am/sql/notify_delete.sql @@ -0,0 +1,79 @@ +-- Tests for amnotifydelete: every path that deletes a row, or replaces it +-- with a new version, must tell the index the old value. +-- Load the library quietly first: its string reloption validator prints +-- notices when it is loaded. +SET client_min_messages TO 'warning'; +CREATE EXTENSION IF NOT EXISTS dummy_index_am; +LOAD 'dummy_index_am'; +RESET client_min_messages; + +CREATE TABLE notify_tab (k int4 PRIMARY KEY, v int4, w int4); +CREATE INDEX notify_idx ON notify_tab USING dummy_index_am (v); +INSERT INTO notify_tab VALUES (1, 10, 0), (2, 20, 0), (3, 30, 0), (4, 40, 0); + +-- DELETE reports the old value +DELETE FROM notify_tab WHERE k = 1; + +-- UPDATE of the indexed column reports the old value +UPDATE notify_tab SET v = 21 WHERE k = 2; + +-- UPDATE of no indexed column is HOT: the index is not called +UPDATE notify_tab SET w = 1 WHERE k = 3; + +-- UPDATE of another index's column gives the row a new TID, so the index +-- hears of the old version, as it hears of the new one +UPDATE notify_tab SET k = 33 WHERE k = 3; + +-- MERGE, both actions +MERGE INTO notify_tab t USING (VALUES (2), (4)) s(k) ON t.k = s.k + WHEN MATCHED AND t.k = 2 THEN UPDATE SET v = 22 + WHEN MATCHED THEN DELETE; + +-- INSERT ... ON CONFLICT DO UPDATE +INSERT INTO notify_tab VALUES (2, 0, 0) ON CONFLICT (k) DO UPDATE SET v = 23; + +-- A partial index hears only of rows that were in it +CREATE INDEX notify_partial ON notify_tab USING dummy_index_am (v) + WHERE v > 100; +INSERT INTO notify_tab VALUES (5, 50, 0), (6, 150, 0); +DELETE FROM notify_tab WHERE k IN (5, 6); + +-- INSERT ... ON CONFLICT that finds the conflict before inserting has no +-- tuple to remove, and neither has one that finds no conflict +INSERT INTO notify_tab VALUES (2, 0, 0) ON CONFLICT DO NOTHING; +INSERT INTO notify_tab VALUES (7, 70, 0) ON CONFLICT DO NOTHING; + +-- The call is made in the deleting transaction, even if it later aborts; +-- an aborted insert is not reported +BEGIN; +DELETE FROM notify_tab WHERE k = 7; +ROLLBACK; +BEGIN; +INSERT INTO notify_tab VALUES (8, 80, 0); +ROLLBACK; + +-- TRUNCATE rebuilds the index instead of reporting rows +TRUNCATE notify_tab; + +-- An index with no column references still hears of deletes +CREATE TABLE notify_const (k int4); +CREATE INDEX notify_const_idx ON notify_const USING dummy_index_am ((7)); +INSERT INTO notify_const VALUES (1); +DELETE FROM notify_const; +DROP TABLE notify_const; + +-- A row moved between partitions is reported by the partition it left +CREATE TABLE notify_part (k int4, v int4) PARTITION BY RANGE (k); +CREATE TABLE notify_part1 PARTITION OF notify_part FOR VALUES FROM (0) TO (10); +CREATE TABLE notify_part2 PARTITION OF notify_part FOR VALUES FROM (10) TO (20); +CREATE INDEX notify_part_idx ON notify_part USING dummy_index_am (v); +INSERT INTO notify_part VALUES (1, 100); +UPDATE notify_part SET k = 11 WHERE k = 1; +DELETE FROM notify_part; + +-- A table with only indexes that don't ask is not affected +CREATE TABLE notify_none (k int4 PRIMARY KEY); +INSERT INTO notify_none VALUES (1); +DELETE FROM notify_none; + +DROP TABLE notify_tab, notify_part, notify_none; diff --git a/src/test/modules/dummy_index_am/t/001_apply.pl b/src/test/modules/dummy_index_am/t/001_apply.pl new file mode 100644 index 00000000000..f1cd63d4668 --- /dev/null +++ b/src/test/modules/dummy_index_am/t/001_apply.pl @@ -0,0 +1,81 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Tests for amnotifydelete in logical replication apply: a delete or update +# applied by a subscription must tell the index the old value, as the same +# command run on the subscriber would. +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; + +# The apply worker's notices go only to the server log +my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber'); +$node_subscriber->init; +$node_subscriber->append_conf('postgresql.conf', 'log_min_messages = notice'); +$node_subscriber->start; + +$node_publisher->safe_psql('postgres', + "CREATE TABLE notify_rep (k int4 PRIMARY KEY, v int4, w int4)"); +$node_publisher->safe_psql('postgres', + "CREATE PUBLICATION notify_pub FOR TABLE notify_rep"); + +$node_subscriber->safe_psql( + 'postgres', qq( + CREATE EXTENSION dummy_index_am; + CREATE TABLE notify_rep (k int4 PRIMARY KEY, v int4, w int4); + CREATE INDEX notify_rep_idx ON notify_rep USING dummy_index_am (v); + CREATE INDEX notify_rep_partial ON notify_rep USING dummy_index_am (v) + WHERE v > 100; +)); +my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres'; +$node_subscriber->safe_psql('postgres', + "CREATE SUBSCRIPTION notify_sub CONNECTION '$publisher_connstr' PUBLICATION notify_pub" +); +$node_subscriber->wait_for_subscription_sync($node_publisher, 'notify_sub'); + +# Run a command on the publisher, wait until it is applied, and return what +# the subscriber logged meanwhile +sub apply +{ + my ($sql) = @_; + my $offset = -s $node_subscriber->logfile; + + $node_publisher->safe_psql('postgres', $sql); + $node_publisher->wait_for_catchup('notify_sub'); + return slurp_file($node_subscriber->logfile, $offset); +} + +my $log = apply( + "INSERT INTO notify_rep VALUES (1, 10, 0), (2, 20, 0), (3, 150, 0)"); +unlike($log, qr/notify_rep_(idx|partial): deleted/, + 'insert is not reported'); + +$log = apply("DELETE FROM notify_rep WHERE k = 1"); +like($log, qr/notify_rep_idx: deleted 10/, 'delete reports the old value'); +unlike($log, qr/notify_rep_partial: deleted/, + 'delete of a row outside a partial index is not reported to it'); + +$log = apply("UPDATE notify_rep SET v = 21 WHERE k = 2"); +like($log, qr/notify_rep_idx: deleted 20/, + 'update of the indexed column reports the old value'); + +$log = apply("UPDATE notify_rep SET w = 1 WHERE k = 2"); +unlike($log, qr/notify_rep_idx: deleted/, + 'HOT update is not reported'); + +$log = apply("DELETE FROM notify_rep WHERE k = 3"); +like($log, qr/notify_rep_idx: deleted 150/, + 'delete of a row in a partial index reports to the full index'); +like($log, qr/notify_rep_partial: deleted 150/, + 'delete of a row in a partial index reports to it'); + +$node_subscriber->stop; +$node_publisher->stop; + +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index a7a8dbdd23f..a4b02144bf2 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3577,6 +3577,7 @@ aminitparallelscan_function aminsert_function aminsertcleanup_function ammarkpos_function +amnotifydelete_function amoptions_function amparallelrescan_function amproperty_function -- 2.56.0