From 60565c50be63e27258dfbf0811de6b507c8e6ab4 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <houzj.fnst@fujitsu.com>
Date: Mon, 3 Aug 2026 14:02:38 +0800
Subject: [PATCH v2 2/2] Invalidate cached parallel DML safety via a new sinval
 message type

The previous commit cached each relation's parallel DML safety hazard
level in its relcache entry (rd_paralleldml).  This commit adds the
machinery that keeps the cached value up to date.

A new shared-invalidation message type, SHAREDINVALPARALLELDML_ID,
carries a database OID and a relation OID; it resets the cached
rd_paralleldml of just that relcache entry (without flushing any other
relcache data), or of every relcache entry in the database when the
relation OID is InvalidOid.  Since the message does not destroy the
relcache entry, sending it requires no lock on the target table.

The message is only needed for changes that do not already discard the
cached value by other means.  Adding or dropping a parallel-safety-
related object on a table itself (a trigger, index, constraint or
column default) always also updates the table's pg_class or
pg_attribute row, which forces a relcache rebuild of the table; the
rebuild discards rd_paralleldml as a side effect, and the accompanying
relcache invalidation also invalidates dependent cached plans.  The
regression tests verify this per command via the raw cached value.

So invalidation is sent when:

* a function's parallel safety property changes (ALTER FUNCTION ...
  PARALLEL, or CREATE OR REPLACE FUNCTION that changes proparallel).
  We deliberately do not try to find the tables using the function;
  that would require additional locking and visibility handling.
  Instead, the cached values of all relcache entries in the database
  are invalidated.

* a parallel-safety-related object is added to or dropped from a
  partition: the hazard level cached for a partitioned table is
  computed recursively over its partitions, so the ancestors' cached
  values must be invalidated too.  The partition's own cached value
  needs no message (see above), so
  CacheInvalidateParallelDmlSafetyForAncestors() sends the invalidation
  only to the ancestors, which it finds with get_partition_ancestors()
  -- safe because the caller holds a lock on the relation, so its place
  in the partition tree cannot change meanwhile (ATTACH/DETACH
  PARTITION take conflicting locks).

* a constraint is dropped (RemoveConstraintById). For a table constraint,
  the cached value of its ancestors (if any) is invalidated.

* a partition is attached or detached: the ancestors' cached values
  were computed without or with the partition (ATExecAttachPartition,
  DetachPartitionFinalize).  The parent's own cached value is already
  discarded by the relcache flush in StorePartitionBound or by the
  explicit relcache invalidation, so again only the ancestors need the
  message.

A cached value may momentarily be stale when a function's parallel safety is
changed concurrently with in-flight queries, since the message requires no lock
on the target table; this is an accepted trade-off, because users can already
alter a function's parallel safety without locking the tables that use it. But
the relcache will eventually be invalidated, and the cached value will be
recomputed to the correct value on next use.

The regression tests in test_parallel_dml_safety verify that the cached
value is recomputed after each kind of change, in particular that
changes on a partition propagate to all of its ancestors.
---
 src/backend/catalog/heap.c                    |   9 +
 src/backend/catalog/index.c                   |  18 +
 src/backend/catalog/pg_attrdef.c              |  10 +
 src/backend/catalog/pg_constraint.c           |  12 +
 src/backend/catalog/pg_proc.c                 |  13 +
 src/backend/commands/functioncmds.c           |  19 +-
 src/backend/commands/tablecmds.c              |  18 +
 src/backend/commands/trigger.c                |  18 +
 src/backend/utils/cache/inval.c               | 127 ++++++
 src/backend/utils/cache/relcache.c            |  44 ++
 src/include/storage/sinval.h                  |  12 +
 src/include/utils/inval.h                     |   4 +
 src/include/utils/relcache.h                  |   2 +
 .../modules/test_parallel_dml_safety/Makefile |   1 +
 .../expected/parallel_dml_safety.out          | 377 ++++++++++++++++++
 .../expected/stale_safety_cache.out           |  34 ++
 .../test_parallel_dml_safety/meson.build      |   5 +
 .../specs/stale_safety_cache.spec             |  76 ++++
 .../sql/parallel_dml_safety.sql               | 133 ++++++
 .../test_parallel_dml_safety--1.0.sql         |   5 +
 .../test_parallel_dml_safety.c                |  21 +
 21 files changed, 957 insertions(+), 1 deletion(-)
 create mode 100644 src/test/modules/test_parallel_dml_safety/expected/stale_safety_cache.out
 create mode 100644 src/test/modules/test_parallel_dml_safety/specs/stale_safety_cache.spec

diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 88087654de9..17b3edbeaf4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -2715,6 +2715,15 @@ AddRelationNewConstraints(Relation rel,
 	 */
 	SetRelationNumChecks(rel, numchecks);
 
+	/*
+	 * Adding a constraint or column default can change the parallel DML
+	 * safety hazard levels cached for the relation's ancestors, if it is a
+	 * partition.  (The relation's own cached value is already discarded by
+	 * the pg_class/pg_attribute updates above, which force a relcache
+	 * rebuild, so no message is needed for it.)
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(RelationGetRelid(rel));
+
 	return cookedConstraints;
 }
 
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 7c1b94407a9..2495fca6a98 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -1294,6 +1294,15 @@ index_create(Relation heapRelation,
 	 */
 	index_close(indexRelation, NoLock);
 
+	/*
+	 * The parallel DML safety hazard levels cached in relcache for the heap
+	 * relation's ancestors, if it is a partition, may have changed;
+	 * invalidate them.  (The heap relation's own cached value is already
+	 * discarded by the pg_class update above, which forces a relcache
+	 * rebuild.)
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(heapRelationId);
+
 	return indexRelationId;
 }
 
@@ -2424,6 +2433,15 @@ index_drop(Oid indexId, bool concurrent, bool concurrent_lock_mode)
 	 */
 	CacheInvalidateRelcache(userHeapRelation);
 
+	/*
+	 * Dropping the index can also change the parallel DML safety hazard
+	 * levels cached for the heap relation's ancestors, if it is a
+	 * partition.  (The heap relation's own cached value is already
+	 * discarded by the relcache invalidation above, so no message is needed
+	 * for it.)
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(heapId);
+
 	/*
 	 * Close owning rel, but keep lock
 	 */
diff --git a/src/backend/catalog/pg_attrdef.c b/src/backend/catalog/pg_attrdef.c
index 24815090d3d..7fbe7bf10c5 100644
--- a/src/backend/catalog/pg_attrdef.c
+++ b/src/backend/catalog/pg_attrdef.c
@@ -24,6 +24,7 @@
 #include "catalog/pg_attrdef.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
+#include "utils/inval.h"
 #include "utils/rel.h"
 #include "utils/syscache.h"
 
@@ -265,6 +266,15 @@ RemoveAttrDefaultById(Oid attrdefId)
 	 */
 	table_close(attr_rel, RowExclusiveLock);
 
+	/*
+	 * Dropping a column default can change the parallel DML safety hazard
+	 * levels cached for the relation's ancestors, if it is a partition.
+	 * (The relation's own cached value is already discarded by the
+	 * pg_attribute update above, which forces a relcache rebuild, so no
+	 * message is needed for it.)
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(myrelid);
+
 	/* Keep lock on attribute's rel until end of xact */
 	relation_close(myrel, NoLock);
 }
diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..78086d60105 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -32,6 +32,7 @@
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
+#include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
 #include "utils/syscache.h"
@@ -986,6 +987,17 @@ RemoveConstraintById(Oid conId)
 	/* Fry the constraint itself */
 	CatalogTupleDelete(conDesc, &tup->t_self);
 
+	/*
+	 * Dropping the constraint can change the parallel DML safety hazard
+	 * level cached in relcache: for a table constraint, invalidate the
+	 * cached value of the table and, if it is a partition, its ancestors
+	 * (whose cached values are computed recursively over their partitions).
+	 * Domain constraints are not part of the cached hazard level, so there
+	 * is nothing to do for them.
+	 */
+	if (OidIsValid(con->conrelid))
+		CacheInvalidateParallelDmlSafetyForAncestors(con->conrelid);
+
 	/* Clean up */
 	ReleaseSysCache(tup);
 	table_close(conDesc, RowExclusiveLock);
diff --git a/src/backend/catalog/pg_proc.c b/src/backend/catalog/pg_proc.c
index 4c6dfb5ca90..00c2da06b18 100644
--- a/src/backend/catalog/pg_proc.c
+++ b/src/backend/catalog/pg_proc.c
@@ -39,6 +39,7 @@
 #include "tcop/tcopprot.h"
 #include "utils/acl.h"
 #include "utils/builtins.h"
+#include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/regproc.h"
 #include "utils/rel.h"
@@ -587,6 +588,18 @@ ProcedureCreate(const char *procedureName,
 		tup = heap_modify_tuple(oldtup, tupDesc, values, nulls, replaces);
 		CatalogTupleUpdate(rel, &tup->t_self, tup);
 
+		/*
+		 * If the function's parallel safety changed, the parallel DML
+		 * safety hazard level cached in relcache for any table that uses
+		 * this function (e.g. in a trigger, constraint or index expression)
+		 * may no longer be accurate.  We intentionally don't try to track
+		 * down those tables (that would require additional locking and
+		 * visibility handling); instead, invalidate the cached hazard level
+		 * of all relcache entries in this database.
+		 */
+		if (oldproc->proparallel != parallel)
+			CacheInvalidateParallelDmlSafety(InvalidOid);
+
 		ReleaseSysCache(oldtup);
 		is_update = true;
 	}
diff --git a/src/backend/commands/functioncmds.c b/src/backend/commands/functioncmds.c
index 3afd762e9dc..e3b678a3a3b 100644
--- a/src/backend/commands/functioncmds.c
+++ b/src/backend/commands/functioncmds.c
@@ -67,6 +67,7 @@
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
+#include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
 #include "utils/snapmgr.h"
@@ -1486,7 +1487,23 @@ AlterFunction(ParseState *pstate, AlterFunctionStmt *stmt)
 		procForm->prosupport = newsupport;
 	}
 	if (parallel_item)
-		procForm->proparallel = interpret_func_parallel(parallel_item);
+	{
+		char		proparallel = interpret_func_parallel(parallel_item);
+
+		/*
+		 * If the function's parallel safety changes, the parallel DML
+		 * safety hazard level cached in relcache for any table that uses
+		 * this function (e.g. in a trigger, constraint or index expression)
+		 * may no longer be accurate.  We intentionally don't try to track
+		 * down those tables (that would require additional locking and
+		 * visibility handling); instead, invalidate the cached hazard level
+		 * of all relcache entries in this database.
+		 */
+		if (proparallel != procForm->proparallel)
+			CacheInvalidateParallelDmlSafety(InvalidOid);
+
+		procForm->proparallel = proparallel;
+	}
 	if (set_items)
 	{
 		Datum		datum;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2fa534413ea..eb5d49239ce 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -21315,6 +21315,15 @@ ATExecAttachPartition(List **wqueue, Relation rel, PartitionCmd *cmd,
 
 	ObjectAddressSet(address, RelationRelationId, RelationGetRelid(attachrel));
 
+	/*
+	 * The parallel DML safety hazard levels cached in relcache for the
+	 * partitioned table's ancestors, if any, were computed without the new
+	 * partition, so they must be invalidated.  (The partitioned table's own
+	 * cached value is already discarded by the relcache invalidation in
+	 * StorePartitionBound, so no message is needed for it.)
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(RelationGetRelid(rel));
+
 	/*
 	 * If the partition we just attached is partitioned itself, invalidate
 	 * relcache for all descendent partitions too to ensure that their
@@ -22166,6 +22175,15 @@ DetachPartitionFinalize(Relation rel, Relation partRel, bool concurrent,
 	 */
 	CacheInvalidateRelcache(rel);
 
+	/*
+	 * The parallel DML safety hazard levels cached in relcache for the
+	 * parent's ancestors, if any, were computed including the detached
+	 * partition, so they must be invalidated as well.  (The parent's own
+	 * cached value is already discarded by the relcache invalidation above,
+	 * so no message is needed for it.)
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(RelationGetRelid(rel));
+
 	/*
 	 * If the partition we just detached is partitioned itself, invalidate
 	 * relcache for all descendent partitions too to ensure that their
diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index 292c76b1967..2a9a23fbbaf 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -1215,6 +1215,16 @@ CreateTriggerFiringOn(const CreateTrigStmt *stmt, const char *queryString,
 		MemoryContextDelete(perChildCxt);
 	}
 
+	/*
+	 * The parallel DML safety hazard levels cached in relcache for the
+	 * target relation's ancestors, if it is a partition, may have changed;
+	 * invalidate them.  (The relation's own cached value is already
+	 * discarded by the pg_class update above, which forces a relcache
+	 * rebuild.)  Note that relOid can be InvalidOid here (the relation was
+	 * then found by name), so use the opened relation's OID.
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(RelationGetRelid(rel));
+
 	/* Keep lock on target rel until end of xact */
 	table_close(rel, NoLock);
 
@@ -1370,6 +1380,14 @@ RemoveTriggerById(Oid trigOid)
 	 */
 	CacheInvalidateRelcache(rel);
 
+	/*
+	 * Dropping a trigger can also change the parallel DML safety hazard
+	 * levels cached for the relation's ancestors, if it is a partition.
+	 * (The relation's own cached value is already discarded by the relcache
+	 * invalidation above, so no message is needed for it.)
+	 */
+	CacheInvalidateParallelDmlSafetyForAncestors(relid);
+
 	/* Keep lock on trigger's rel until end of xact */
 	table_close(rel, NoLock);
 }
diff --git a/src/backend/utils/cache/inval.c b/src/backend/utils/cache/inval.c
index 63dc36d4d91..c465e4ab65c 100644
--- a/src/backend/utils/cache/inval.c
+++ b/src/backend/utils/cache/inval.c
@@ -119,6 +119,7 @@
 #include "access/xloginsert.h"
 #include "catalog/catalog.h"
 #include "catalog/pg_constraint.h"
+#include "catalog/partition.h"
 #include "miscadmin.h"
 #include "storage/procnumber.h"
 #include "storage/sinval.h"
@@ -552,6 +553,38 @@ AddSnapshotInvalidationMessage(InvalidationMsgsGroup *group,
 	AddInvalidationMessage(group, RelCacheMsgs, &msg);
 }
 
+/*
+ * Add a parallel DML safety inval entry
+ *
+ * We put these into the relcache subgroup for simplicity.
+ */
+static void
+AddParallelDmlInvalidationMessage(InvalidationMsgsGroup *group,
+								  Oid dbId, Oid relId)
+{
+	SharedInvalidationMessage msg;
+
+	/*
+	 * Don't add a duplicate item.  We assume dbId need not be checked
+	 * because it will never change.  InvalidOid for relId means all
+	 * relations, so we don't need to add individual ones when it is present.
+	 */
+	ProcessMessageSubGroup(group, RelCacheMsgs,
+						   if (msg->pd.id == SHAREDINVALPARALLELDML_ID &&
+							   (msg->pd.relId == relId ||
+								msg->pd.relId == InvalidOid))
+						   return);
+
+	/* OK, add the item */
+	msg.pd.id = SHAREDINVALPARALLELDML_ID;
+	msg.pd.dbId = dbId;
+	msg.pd.relId = relId;
+	/* check AddCatcacheInvalidationMessage() for an explanation */
+	VALGRIND_MAKE_MEM_DEFINED(&msg, sizeof(msg));
+
+	AddInvalidationMessage(group, RelCacheMsgs, &msg);
+}
+
 /*
  * Append one group of invalidation messages to another, resetting
  * the source group to empty.
@@ -674,6 +707,34 @@ RegisterSnapshotInvalidation(InvalidationInfo *info, Oid dbId, Oid relId)
 	AddSnapshotInvalidationMessage(&info->CurrentCmdInvalidMsgs, dbId, relId);
 }
 
+/*
+ * RegisterParallelDmlInvalidation
+ *
+ * As above, but register an invalidation event for the cached parallel DML
+ * safety hazard level of one relation, or of all relations in the database
+ * if relId is InvalidOid.
+ */
+static void
+RegisterParallelDmlInvalidation(InvalidationInfo *info, Oid dbId, Oid relId)
+{
+	AddParallelDmlInvalidationMessage(&info->CurrentCmdInvalidMsgs,
+									  dbId, relId);
+
+	/*
+	 * Parallel DML safety invalidation is not associated with system catalog
+	 * updates.  Quick hack to ensure that the next CommandCounterIncrement()
+	 * will think that we need to do CommandEndInvalidationMessages().
+	 */
+	(void) GetCurrentCommandId(true);
+
+	/*
+	 * Note that there is no need to zap the relcache init file here:
+	 * rd_paralleldml is never trusted from the init file (see
+	 * load_relcache_init_file), so a stale init file cannot propagate a
+	 * stale hazard level to other sessions.
+	 */
+}
+
 /*
  * PrepareInvalidationState
  *		Initialize inval data for the current (sub)transaction.
@@ -897,6 +958,12 @@ LocalExecuteInvalidationMessage(SharedInvalidationMessage *msg)
 		if (msg->rs.dbId == MyDatabaseId)
 			CallRelSyncCallbacks(msg->rs.relid);
 	}
+	else if (msg->id == SHAREDINVALPARALLELDML_ID)
+	{
+		/* We only care about our own database */
+		if (msg->pd.dbId == MyDatabaseId)
+			RelationCacheInvalidateParallelDml(msg->pd.relId);
+	}
 	else
 		elog(FATAL, "unrecognized SI message ID: %d", msg->id);
 }
@@ -1723,6 +1790,66 @@ CacheInvalidateRelSyncAll(void)
 	CacheInvalidateRelSync(InvalidOid);
 }
 
+/*
+ * CacheInvalidateParallelDmlSafety
+ *		Register invalidation of the cached parallel DML safety hazard level
+ *		(rd_paralleldml) of the given relation at the end of command.  If
+ *		relId is InvalidOid, all relcache entries in the current database
+ *		are invalidated.
+ *
+ * Unlike the relcache invalidation messages, this only discards the cached
+ * hazard level; the relcache entries themselves are kept.
+ *
+ * The caller is not required to hold a lock on the relation: resetting the
+ * cached value is always safe.  It merely means that a concurrent query
+ * that has already planned using the old value may not notice the change;
+ * this is an accepted trade-off, since users can already alter a function's
+ * parallel safety without locking the tables that use it.
+ */
+void
+CacheInvalidateParallelDmlSafety(Oid relId)
+{
+	RegisterParallelDmlInvalidation(PrepareInvalidationState(),
+									MyDatabaseId, relId);
+}
+
+/*
+ * CacheInvalidateParallelDmlSafetyForAncestors
+ *		Register invalidation of the cached parallel DML safety hazard levels
+ *		of all of the given partition's ancestor partitioned tables, but not
+ *		of the given relation itself.
+ *
+ * The hazard level cached for a partitioned table is computed recursively
+ * over its partitions, so adding a parallel-safety-related object to (or
+ * dropping one from) a partition must invalidate the ancestors' cached
+ * values.  The partition's own cached value needs no message: such DDL
+ * always also updates the partition's own pg_class or pg_attribute row,
+ * which forces a relcache rebuild that discards it (see the previous
+ * commit).  This is safe to call while modifying the relation: the caller
+ * holds a lock on it, so its place in the partition tree cannot change
+ * meanwhile (ATTACH/DETACH PARTITION take conflicting locks), which is what
+ * makes looking up its ancestors without locking them safe.
+ *
+ * Since we do not lock ancestors, parallel-unsafe objects could be added
+ * concurrently after other backends have computed parallel safety and planned a
+ * parallel INSERT SELECT. This means the unsafe objects could be executed
+ * during execution, causing the INSERT SELECT to fail. However, this avoids the
+ * risk of deadlocks by not locking ancestors and preserves existing DDL locking
+ * behavior. Given that such unsafe object additions are not frequent, we
+ * consider the risk acceptable.
+ */
+void
+CacheInvalidateParallelDmlSafetyForAncestors(Oid relId)
+{
+	List	   *ancestors;
+	ListCell   *lc;
+
+	ancestors = get_partition_ancestors(relId);
+	foreach(lc, ancestors)
+		CacheInvalidateParallelDmlSafety(lfirst_oid(lc));
+	list_free(ancestors);
+}
+
 /*
  * CacheInvalidateSmgr
  *		Register invalidation of smgr references to a physical relation.
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 3ce475640da..173e8de5940 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -2965,6 +2965,50 @@ RelationCacheInvalidateEntry(Oid relationId)
 	}
 }
 
+/*
+ * RelationCacheInvalidateParallelDml
+ *	 Reset the cached parallel DML safety hazard level (rd_paralleldml) of
+ *	 the relcache entry for the given relation, or of every relcache entry
+ *	 if relationId is InvalidOid, forcing it to be recomputed on next use.
+ *
+ *	 Unlike RelationCacheInvalidateEntry, the relcache entry itself is
+ *	 kept: only the derived flag is discarded, so entries that are in use
+ *	 need no special handling.  Resetting an entry that is currently in use
+ *	 by the planner or executor is harmless; the hazard level in use at
+ *	 that point has already been read.
+ *
+ *	 If the relation is not cached in this backend, there is nothing to do.
+ */
+void
+RelationCacheInvalidateParallelDml(Oid relationId)
+{
+	Relation	relation;
+
+	if (OidIsValid(relationId))
+	{
+		RelationIdCacheLookup(relationId, relation);
+
+		if (relation)
+		{
+			relation->rd_paralleldml = 0;
+			relation->rd_paralleldmlstate = PARALLELDML_NEEDS_REBUILD;
+		}
+	}
+	else
+	{
+		HASH_SEQ_STATUS status;
+		RelIdCacheEnt *idhentry;
+
+		hash_seq_init(&status, RelationIdCache);
+
+		while ((idhentry = (RelIdCacheEnt *) hash_seq_search(&status)) != NULL)
+		{
+			idhentry->reldesc->rd_paralleldml = 0;
+			idhentry->reldesc->rd_paralleldmlstate = PARALLELDML_NEEDS_REBUILD;
+		}
+	}
+}
+
 /*
  * RelationCacheInvalidate
  *	 Blow away cached relation descriptors that have zero reference counts,
diff --git a/src/include/storage/sinval.h b/src/include/storage/sinval.h
index 4d33fdcabe1..78da9005aa4 100644
--- a/src/include/storage/sinval.h
+++ b/src/include/storage/sinval.h
@@ -28,6 +28,8 @@
  *	* invalidate the mapped-relation mapping for a given database
  *	* invalidate any saved snapshot that might be used to scan a given relation
  *	* invalidate a RelationSyncCache entry for a specific relation
+ *	* invalidate the cached parallel DML safety hazard level of one or all
+ *	  relcache entries
  * More types could be added if needed.  The message type is identified by
  * the first "int8" field of the message struct.  Zero or positive means a
  * specific-catcache inval message (and also serves as the catcache ID field).
@@ -121,6 +123,15 @@ typedef struct
 								 * RelationSyncCache */
 } SharedInvalRelSyncMsg;
 
+#define SHAREDINVALPARALLELDML_ID	(-7)
+
+typedef struct
+{
+	int8		id;				/* type field --- must be first */
+	Oid			dbId;			/* database ID */
+	Oid			relId;			/* relation ID, or 0 for all relations */
+} SharedInvalParallelDmlMsg;
+
 typedef union
 {
 	int8		id;				/* type field --- must be first */
@@ -131,6 +142,7 @@ typedef union
 	SharedInvalRelmapMsg rm;
 	SharedInvalSnapshotMsg sn;
 	SharedInvalRelSyncMsg rs;
+	SharedInvalParallelDmlMsg pd;
 } SharedInvalidationMessage;
 
 
diff --git a/src/include/utils/inval.h b/src/include/utils/inval.h
index 735e42f7310..9358e179c22 100644
--- a/src/include/utils/inval.h
+++ b/src/include/utils/inval.h
@@ -79,6 +79,10 @@ extern void CacheInvalidateRelSync(Oid relid);
 
 extern void CacheInvalidateRelSyncAll(void);
 
+extern void CacheInvalidateParallelDmlSafety(Oid relId);
+
+extern void CacheInvalidateParallelDmlSafetyForAncestors(Oid relId);
+
 extern void CacheInvalidateSmgr(RelFileLocatorBackend rlocator);
 
 extern void CacheInvalidateRelmap(Oid databaseId);
diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h
index f6244d88eac..568977dbab1 100644
--- a/src/include/utils/relcache.h
+++ b/src/include/utils/relcache.h
@@ -137,6 +137,8 @@ extern void RelationForgetRelation(Oid rid);
 
 extern void RelationCacheInvalidateEntry(Oid relationId);
 
+extern void RelationCacheInvalidateParallelDml(Oid relationId);
+
 extern void RelationCacheInvalidate(bool debug_discard);
 
 #ifdef USE_ASSERT_CHECKING
diff --git a/src/test/modules/test_parallel_dml_safety/Makefile b/src/test/modules/test_parallel_dml_safety/Makefile
index ca5b96e133e..8c037b328a0 100644
--- a/src/test/modules/test_parallel_dml_safety/Makefile
+++ b/src/test/modules/test_parallel_dml_safety/Makefile
@@ -10,6 +10,7 @@ EXTENSION = test_parallel_dml_safety
 DATA = test_parallel_dml_safety--1.0.sql
 
 REGRESS = parallel_dml_safety
+ISOLATION = stale_safety_cache
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/src/test/modules/test_parallel_dml_safety/expected/parallel_dml_safety.out b/src/test/modules/test_parallel_dml_safety/expected/parallel_dml_safety.out
index 8cd18d1c947..0b8b35e9992 100644
--- a/src/test/modules/test_parallel_dml_safety/expected/parallel_dml_safety.out
+++ b/src/test/modules/test_parallel_dml_safety/expected/parallel_dml_safety.out
@@ -213,3 +213,380 @@ SELECT test_parallel_dml_safety('pdml_temp');
  r
 (1 row)
 
+--
+-- Invalidation tests
+--
+-- Note that adding or dropping a trigger, constraint, index or default on a
+-- table itself causes a full relcache invalidation of that table, so those
+-- cases are not very interesting here.  The cases below are the ones where
+-- no full relcache invalidation happens: changes to a partition (which must
+-- propagate to its ancestors) and changes of a function's parallel safety.
+-- (Domain constraint changes need no invalidation, since domain constraints
+-- are not part of the cached hazard level.)
+--
+-- adding an unsafe trigger to a partition invalidates the ancestors' cached
+-- hazard levels (via the propagated message), while the partition's own
+-- value is discarded by its relcache rebuild
+CREATE TABLE pdml_inv_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_part_p1 PARTITION OF pdml_inv_part FOR VALUES FROM (0) TO (100);
+SELECT test_parallel_dml_safety('pdml_inv_part');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_part_p1');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+CREATE TRIGGER trg BEFORE INSERT ON pdml_inv_part_p1
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety_cached('pdml_inv_part_p1');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety_cached('pdml_inv_part');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_part');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+DROP TRIGGER trg ON pdml_inv_part_p1;
+SELECT test_parallel_dml_safety_cached('pdml_inv_part');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_part');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- ... and propagation works across multiple levels
+CREATE TABLE pdml_inv_gp (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_p PARTITION OF pdml_inv_gp FOR VALUES FROM (0) TO (100)
+  PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_p_p1 PARTITION OF pdml_inv_p FOR VALUES FROM (0) TO (50);
+SELECT test_parallel_dml_safety('pdml_inv_gp');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_p');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_p_p1');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+ALTER TABLE pdml_inv_p_p1 ADD CONSTRAINT chk_u CHECK (pdml_unsafe_fn(a));
+SELECT test_parallel_dml_safety_cached('pdml_inv_p_p1');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety_cached('pdml_inv_p');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety_cached('pdml_inv_gp');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_gp');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_p');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+ALTER TABLE pdml_inv_p_p1 DROP CONSTRAINT chk_u;
+SELECT test_parallel_dml_safety('pdml_inv_gp');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- an unsafe index on a partition likewise propagates to the parent
+CREATE TABLE pdml_inv_idx (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_idx_p1 PARTITION OF pdml_inv_idx FOR VALUES FROM (0) TO (100);
+SELECT test_parallel_dml_safety('pdml_inv_idx');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+CREATE INDEX pdml_inv_idx_p1_i ON pdml_inv_idx_p1 ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety_cached('pdml_inv_idx_p1');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety_cached('pdml_inv_idx');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_inv_idx');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+DROP INDEX pdml_inv_idx_p1_i;
+SELECT test_parallel_dml_safety('pdml_inv_idx');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- attaching a partition that has an unsafe object invalidates the values
+-- cached for all of its ancestors (the parent itself is covered by the
+-- relcache rebuild in StorePartitionBound; the grandparent only by the
+-- propagated message)
+CREATE TABLE pdml_att_gp (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_att_p PARTITION OF pdml_att_gp FOR VALUES FROM (0) TO (100)
+  PARTITION BY RANGE (a);
+SELECT test_parallel_dml_safety('pdml_att_gp');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_att_p');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+CREATE TABLE pdml_att_child (a int, CONSTRAINT chk_u CHECK (pdml_unsafe_fn(a)));
+ALTER TABLE pdml_att_p ATTACH PARTITION pdml_att_child FOR VALUES FROM (0) TO (50);
+SELECT test_parallel_dml_safety_cached('pdml_att_p');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety_cached('pdml_att_gp');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_att_gp');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_att_p');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- detaching it invalidates them again
+ALTER TABLE pdml_att_p DETACH PARTITION pdml_att_child;
+SELECT test_parallel_dml_safety('pdml_att_gp');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_att_p');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- altering a function's parallel safety invalidates all cached values
+CREATE FUNCTION pdml_alterable_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+CREATE TABLE pdml_inv_fn (a int CHECK (pdml_alterable_fn(a)));
+SELECT test_parallel_dml_safety('pdml_inv_fn');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+ALTER FUNCTION pdml_alterable_fn(int) PARALLEL UNSAFE;
+SELECT test_parallel_dml_safety('pdml_inv_fn');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+CREATE OR REPLACE FUNCTION pdml_alterable_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+SELECT test_parallel_dml_safety('pdml_inv_fn');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- adding an unsafe object to a table itself needs no invalidation message:
+-- each of these DDL commands updates the table's pg_class/pg_attribute row,
+-- which forces a relcache rebuild that discards the cached value (and
+-- invalidates dependent plans via the relcache invalidation)
+CREATE TABLE pdml_self (a int);
+SELECT test_parallel_dml_safety('pdml_self');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+CREATE TRIGGER trg BEFORE INSERT ON pdml_self
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety_cached('pdml_self');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_self');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+DROP TRIGGER trg ON pdml_self;
+SELECT test_parallel_dml_safety_cached('pdml_self');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_self');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+ALTER TABLE pdml_self ADD CONSTRAINT chk_u CHECK (pdml_unsafe_fn(a));
+SELECT test_parallel_dml_safety_cached('pdml_self');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_self');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+ALTER TABLE pdml_self DROP CONSTRAINT chk_u;
+SELECT test_parallel_dml_safety_cached('pdml_self');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_self');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+CREATE INDEX pdml_self_i ON pdml_self ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety_cached('pdml_self');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_self');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+DROP INDEX pdml_self_i;
+SELECT test_parallel_dml_safety_cached('pdml_self');
+ test_parallel_dml_safety_cached 
+---------------------------------
+ 0
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_self');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- column default changes are reflected too
+CREATE TABLE pdml_inv_def (a int);
+SELECT test_parallel_dml_safety('pdml_inv_def');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+ALTER TABLE pdml_inv_def ALTER COLUMN a SET DEFAULT (CASE WHEN pdml_unsafe_fn(0) THEN 1 ELSE 0 END);
+SELECT test_parallel_dml_safety('pdml_inv_def');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+ALTER TABLE pdml_inv_def ALTER COLUMN a DROP DEFAULT;
+SELECT test_parallel_dml_safety('pdml_inv_def');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- adding or dropping a domain constraint does not change the cached hazard
+-- level, since domain constraints are not examined at all
+CREATE DOMAIN pdml_inv_dom AS int;
+CREATE TABLE pdml_inv_dom_t (a pdml_inv_dom);
+SELECT test_parallel_dml_safety('pdml_inv_dom_t');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+ALTER DOMAIN pdml_inv_dom ADD CONSTRAINT dom_u CHECK (pdml_unsafe_fn(VALUE));
+SELECT test_parallel_dml_safety('pdml_inv_dom_t');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+ALTER DOMAIN pdml_inv_dom DROP CONSTRAINT dom_u;
+SELECT test_parallel_dml_safety('pdml_inv_dom_t');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
diff --git a/src/test/modules/test_parallel_dml_safety/expected/stale_safety_cache.out b/src/test/modules/test_parallel_dml_safety/expected/stale_safety_cache.out
new file mode 100644
index 00000000000..7484e7aba61
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/expected/stale_safety_cache.out
@@ -0,0 +1,34 @@
+Parsed test spec with 3 sessions
+
+starting permutation: s2_lock s1_compute s2_trig s2_commit s1_cached s1_recheck s3_truth s1_insert s3_insert
+step s2_lock: LOCK TABLE stale_p2 IN ACCESS EXCLUSIVE MODE;
+step s1_compute: SELECT test_parallel_dml_safety('stale_root'::regclass); <waiting ...>
+step s2_trig: CREATE TRIGGER stale_trig BEFORE INSERT ON stale_p1
+					FOR EACH ROW EXECUTE FUNCTION stale_unsafe();
+step s2_commit: COMMIT;
+step s1_compute: <... completed>
+test_parallel_dml_safety
+------------------------
+u                       
+(1 row)
+
+step s1_cached: SELECT test_parallel_dml_safety_cached('stale_root'::regclass);
+test_parallel_dml_safety_cached
+-------------------------------
+u                              
+(1 row)
+
+step s1_recheck: SELECT test_parallel_dml_safety('stale_root'::regclass);
+test_parallel_dml_safety
+------------------------
+u                       
+(1 row)
+
+step s3_truth: SELECT test_parallel_dml_safety('stale_root'::regclass);
+test_parallel_dml_safety
+------------------------
+u                       
+(1 row)
+
+step s1_insert: INSERT INTO stale_root SELECT a FROM stale_src;
+step s3_insert: INSERT INTO stale_root SELECT a FROM stale_src;
diff --git a/src/test/modules/test_parallel_dml_safety/meson.build b/src/test/modules/test_parallel_dml_safety/meson.build
index e12f94c130c..82fc2c69caf 100644
--- a/src/test/modules/test_parallel_dml_safety/meson.build
+++ b/src/test/modules/test_parallel_dml_safety/meson.build
@@ -30,4 +30,9 @@ tests += {
       'parallel_dml_safety',
     ],
   },
+  'isolation': {
+    'specs': [
+      'stale_safety_cache',
+    ],
+  },
 }
diff --git a/src/test/modules/test_parallel_dml_safety/specs/stale_safety_cache.spec b/src/test/modules/test_parallel_dml_safety/specs/stale_safety_cache.spec
new file mode 100644
index 00000000000..60aea893850
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/specs/stale_safety_cache.spec
@@ -0,0 +1,76 @@
+# Regression test for stale cache write-after-invalidation in
+# RelationGetParallelDmlSafety().
+#
+# The computation of a partitioned table's hazard level opens (locks) each
+# partition in turn, and acquiring a lock can process pending invalidation
+# messages.  If a concurrent commit makes an already-examined partition
+# parallel-unsafe while the computation is blocked on the next partition's
+# lock, the invalidation is processed in the middle of the computation: the
+# result computed so far is based on the pre-change catalogs, and must not
+# be cached.  rd_paralleldmlstate detects that and the computation is
+# redone; without that, the session would keep a stale "safe" value and
+# plan parallel INSERTs into a relation that is no longer parallel-safe,
+# which then fail at execution time.
+
+setup
+{
+	CREATE EXTENSION test_parallel_dml_safety;
+
+	CREATE TABLE stale_root (a int) PARTITION BY RANGE (a);
+	CREATE TABLE stale_p1 PARTITION OF stale_root FOR VALUES FROM (0) TO (50);
+	CREATE TABLE stale_p2 PARTITION OF stale_root FOR VALUES FROM (50) TO (100);
+
+	CREATE TABLE stale_src (a int);
+	INSERT INTO stale_src SELECT g % 50 FROM generate_series(1, 1000) g;
+
+	CREATE FUNCTION stale_unsafe() RETURNS trigger
+		LANGUAGE plpgsql PARALLEL UNSAFE
+		AS $$ BEGIN RETURN NEW; END $$;
+}
+
+teardown
+{
+	DROP TABLE stale_root, stale_src;
+	DROP FUNCTION stale_unsafe();
+	DROP EXTENSION test_parallel_dml_safety;
+}
+
+# The session whose cache is invalidated mid-computation.
+session s1
+setup			{ SET min_parallel_table_scan_size = 0;
+				  SET parallel_setup_cost = 0;
+				  SET parallel_tuple_cost = 0;
+				  SET max_parallel_workers_per_gather = 2; }
+step s1_compute	{ SELECT test_parallel_dml_safety('stale_root'::regclass); }
+step s1_cached	{ SELECT test_parallel_dml_safety_cached('stale_root'::regclass); }
+step s1_recheck	{ SELECT test_parallel_dml_safety('stale_root'::regclass); }
+step s1_insert	{ INSERT INTO stale_root SELECT a FROM stale_src; }
+
+# The session that makes the relation parallel unsafe while s1 is computing.
+session s2
+setup			{ BEGIN; }
+step s2_lock	{ LOCK TABLE stale_p2 IN ACCESS EXCLUSIVE MODE; }
+# CREATE TRIGGER takes ShareRowExclusiveLock, which does not conflict with
+# the AccessShareLock s1 already holds on stale_p1, so s1 has read stale_p1
+# as safe by the time this runs.
+step s2_trig	{ CREATE TRIGGER stale_trig BEFORE INSERT ON stale_p1
+					FOR EACH ROW EXECUTE FUNCTION stale_unsafe(); }
+step s2_commit	{ COMMIT; }
+
+# A session that never saw the pre-change state, for comparison.
+session s3
+setup			{ SET min_parallel_table_scan_size = 0;
+				  SET parallel_setup_cost = 0;
+				  SET parallel_tuple_cost = 0;
+				  SET max_parallel_workers_per_gather = 2; }
+step s3_truth	{ SELECT test_parallel_dml_safety('stale_root'::regclass); }
+step s3_insert	{ INSERT INTO stale_root SELECT a FROM stale_src; }
+
+# s1_compute blocks on stale_p2 after having examined stale_p1 as safe; s2
+# then makes stale_p1 unsafe and commits.  s1 processes the invalidation
+# while waking up, so the computation is redone and both returns and caches
+# 'u', like the fresh session s3 computes; both INSERTs then get a
+# non-parallel plan and succeed.  (Before the fix, s1 returned and cached
+# the stale 's', and its INSERT failed at execution time when the executor
+# rechecked the partition.)
+permutation s2_lock s1_compute s2_trig s2_commit s1_cached s1_recheck s3_truth s1_insert s3_insert
diff --git a/src/test/modules/test_parallel_dml_safety/sql/parallel_dml_safety.sql b/src/test/modules/test_parallel_dml_safety/sql/parallel_dml_safety.sql
index f2efe035b5d..205d8c3469b 100644
--- a/src/test/modules/test_parallel_dml_safety/sql/parallel_dml_safety.sql
+++ b/src/test/modules/test_parallel_dml_safety/sql/parallel_dml_safety.sql
@@ -116,3 +116,136 @@ SELECT test_parallel_dml_safety('pdml_ft');
 
 CREATE TEMP TABLE pdml_temp (a int);
 SELECT test_parallel_dml_safety('pdml_temp');
+
+--
+-- Invalidation tests
+--
+-- Note that adding or dropping a trigger, constraint, index or default on a
+-- table itself causes a full relcache invalidation of that table, so those
+-- cases are not very interesting here.  The cases below are the ones where
+-- no full relcache invalidation happens: changes to a partition (which must
+-- propagate to its ancestors) and changes of a function's parallel safety.
+-- (Domain constraint changes need no invalidation, since domain constraints
+-- are not part of the cached hazard level.)
+--
+
+-- adding an unsafe trigger to a partition invalidates the ancestors' cached
+-- hazard levels (via the propagated message), while the partition's own
+-- value is discarded by its relcache rebuild
+CREATE TABLE pdml_inv_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_part_p1 PARTITION OF pdml_inv_part FOR VALUES FROM (0) TO (100);
+SELECT test_parallel_dml_safety('pdml_inv_part');
+SELECT test_parallel_dml_safety('pdml_inv_part_p1');
+CREATE TRIGGER trg BEFORE INSERT ON pdml_inv_part_p1
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety_cached('pdml_inv_part_p1');
+SELECT test_parallel_dml_safety_cached('pdml_inv_part');
+SELECT test_parallel_dml_safety('pdml_inv_part');
+DROP TRIGGER trg ON pdml_inv_part_p1;
+SELECT test_parallel_dml_safety_cached('pdml_inv_part');
+SELECT test_parallel_dml_safety('pdml_inv_part');
+
+-- ... and propagation works across multiple levels
+CREATE TABLE pdml_inv_gp (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_p PARTITION OF pdml_inv_gp FOR VALUES FROM (0) TO (100)
+  PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_p_p1 PARTITION OF pdml_inv_p FOR VALUES FROM (0) TO (50);
+SELECT test_parallel_dml_safety('pdml_inv_gp');
+SELECT test_parallel_dml_safety('pdml_inv_p');
+SELECT test_parallel_dml_safety('pdml_inv_p_p1');
+ALTER TABLE pdml_inv_p_p1 ADD CONSTRAINT chk_u CHECK (pdml_unsafe_fn(a));
+SELECT test_parallel_dml_safety_cached('pdml_inv_p_p1');
+SELECT test_parallel_dml_safety_cached('pdml_inv_p');
+SELECT test_parallel_dml_safety_cached('pdml_inv_gp');
+SELECT test_parallel_dml_safety('pdml_inv_gp');
+SELECT test_parallel_dml_safety('pdml_inv_p');
+ALTER TABLE pdml_inv_p_p1 DROP CONSTRAINT chk_u;
+SELECT test_parallel_dml_safety('pdml_inv_gp');
+
+-- an unsafe index on a partition likewise propagates to the parent
+CREATE TABLE pdml_inv_idx (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_inv_idx_p1 PARTITION OF pdml_inv_idx FOR VALUES FROM (0) TO (100);
+SELECT test_parallel_dml_safety('pdml_inv_idx');
+CREATE INDEX pdml_inv_idx_p1_i ON pdml_inv_idx_p1 ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety_cached('pdml_inv_idx_p1');
+SELECT test_parallel_dml_safety_cached('pdml_inv_idx');
+SELECT test_parallel_dml_safety('pdml_inv_idx');
+DROP INDEX pdml_inv_idx_p1_i;
+SELECT test_parallel_dml_safety('pdml_inv_idx');
+
+-- attaching a partition that has an unsafe object invalidates the values
+-- cached for all of its ancestors (the parent itself is covered by the
+-- relcache rebuild in StorePartitionBound; the grandparent only by the
+-- propagated message)
+CREATE TABLE pdml_att_gp (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_att_p PARTITION OF pdml_att_gp FOR VALUES FROM (0) TO (100)
+  PARTITION BY RANGE (a);
+SELECT test_parallel_dml_safety('pdml_att_gp');
+SELECT test_parallel_dml_safety('pdml_att_p');
+CREATE TABLE pdml_att_child (a int, CONSTRAINT chk_u CHECK (pdml_unsafe_fn(a)));
+ALTER TABLE pdml_att_p ATTACH PARTITION pdml_att_child FOR VALUES FROM (0) TO (50);
+SELECT test_parallel_dml_safety_cached('pdml_att_p');
+SELECT test_parallel_dml_safety_cached('pdml_att_gp');
+SELECT test_parallel_dml_safety('pdml_att_gp');
+SELECT test_parallel_dml_safety('pdml_att_p');
+-- detaching it invalidates them again
+ALTER TABLE pdml_att_p DETACH PARTITION pdml_att_child;
+SELECT test_parallel_dml_safety('pdml_att_gp');
+SELECT test_parallel_dml_safety('pdml_att_p');
+
+-- altering a function's parallel safety invalidates all cached values
+CREATE FUNCTION pdml_alterable_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+CREATE TABLE pdml_inv_fn (a int CHECK (pdml_alterable_fn(a)));
+SELECT test_parallel_dml_safety('pdml_inv_fn');
+ALTER FUNCTION pdml_alterable_fn(int) PARALLEL UNSAFE;
+SELECT test_parallel_dml_safety('pdml_inv_fn');
+CREATE OR REPLACE FUNCTION pdml_alterable_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+SELECT test_parallel_dml_safety('pdml_inv_fn');
+
+-- adding an unsafe object to a table itself needs no invalidation message:
+-- each of these DDL commands updates the table's pg_class/pg_attribute row,
+-- which forces a relcache rebuild that discards the cached value (and
+-- invalidates dependent plans via the relcache invalidation)
+CREATE TABLE pdml_self (a int);
+SELECT test_parallel_dml_safety('pdml_self');
+CREATE TRIGGER trg BEFORE INSERT ON pdml_self
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety_cached('pdml_self');
+SELECT test_parallel_dml_safety('pdml_self');
+DROP TRIGGER trg ON pdml_self;
+SELECT test_parallel_dml_safety_cached('pdml_self');
+SELECT test_parallel_dml_safety('pdml_self');
+
+ALTER TABLE pdml_self ADD CONSTRAINT chk_u CHECK (pdml_unsafe_fn(a));
+SELECT test_parallel_dml_safety_cached('pdml_self');
+SELECT test_parallel_dml_safety('pdml_self');
+ALTER TABLE pdml_self DROP CONSTRAINT chk_u;
+SELECT test_parallel_dml_safety_cached('pdml_self');
+SELECT test_parallel_dml_safety('pdml_self');
+
+CREATE INDEX pdml_self_i ON pdml_self ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety_cached('pdml_self');
+SELECT test_parallel_dml_safety('pdml_self');
+DROP INDEX pdml_self_i;
+SELECT test_parallel_dml_safety_cached('pdml_self');
+SELECT test_parallel_dml_safety('pdml_self');
+
+-- column default changes are reflected too
+CREATE TABLE pdml_inv_def (a int);
+SELECT test_parallel_dml_safety('pdml_inv_def');
+ALTER TABLE pdml_inv_def ALTER COLUMN a SET DEFAULT (CASE WHEN pdml_unsafe_fn(0) THEN 1 ELSE 0 END);
+SELECT test_parallel_dml_safety('pdml_inv_def');
+ALTER TABLE pdml_inv_def ALTER COLUMN a DROP DEFAULT;
+SELECT test_parallel_dml_safety('pdml_inv_def');
+
+-- adding or dropping a domain constraint does not change the cached hazard
+-- level, since domain constraints are not examined at all
+CREATE DOMAIN pdml_inv_dom AS int;
+CREATE TABLE pdml_inv_dom_t (a pdml_inv_dom);
+SELECT test_parallel_dml_safety('pdml_inv_dom_t');
+ALTER DOMAIN pdml_inv_dom ADD CONSTRAINT dom_u CHECK (pdml_unsafe_fn(VALUE));
+SELECT test_parallel_dml_safety('pdml_inv_dom_t');
+ALTER DOMAIN pdml_inv_dom DROP CONSTRAINT dom_u;
+SELECT test_parallel_dml_safety('pdml_inv_dom_t');
diff --git a/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety--1.0.sql b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety--1.0.sql
index 9cf44f51b9a..6d587be6117 100644
--- a/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety--1.0.sql
+++ b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety--1.0.sql
@@ -4,3 +4,8 @@ CREATE FUNCTION test_parallel_dml_safety(regclass)
 RETURNS "char"
 AS 'MODULE_PATHNAME'
 LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION test_parallel_dml_safety_cached(regclass)
+RETURNS "char"
+AS 'MODULE_PATHNAME'
+LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.c b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.c
index 8a8d2d6f48a..50556f0802a 100644
--- a/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.c
+++ b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.c
@@ -26,6 +26,7 @@
 PG_MODULE_MAGIC;
 
 PG_FUNCTION_INFO_V1(test_parallel_dml_safety);
+PG_FUNCTION_INFO_V1(test_parallel_dml_safety_cached);
 
 /*
  * Return the relation's parallel DML safety hazard level ('s', 'r' or 'u'),
@@ -44,3 +45,23 @@ test_parallel_dml_safety(PG_FUNCTION_ARGS)
 
 	PG_RETURN_CHAR(hazard);
 }
+
+/*
+ * Return the relation's cached parallel DML safety hazard level ('s', 'r'
+ * or 'u'), or '0' if it has not been computed yet.  Unlike
+ * test_parallel_dml_safety(), this does not compute the value, so tests can
+ * tell exactly which cached values an invalidation has discarded.
+ */
+Datum
+test_parallel_dml_safety_cached(PG_FUNCTION_ARGS)
+{
+	Oid			relid = PG_GETARG_OID(0);
+	Relation	rel;
+	char		hazard;
+
+	rel = table_open(relid, AccessShareLock);
+	hazard = rel->rd_paralleldml;
+	table_close(rel, AccessShareLock);
+
+	PG_RETURN_CHAR(hazard == 0 ? '0' : hazard);
+}
-- 
2.43.0

