From ee22cab172b94e1d01f4cf84cdde457cd7e97d16 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <houzj.fnst@fujitsu.com>
Date: Mon, 3 Aug 2026 13:21:39 +0800
Subject: [PATCH v1 1/4] Compute and cache relations' parallel DML safety in
 relcache
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The goal of this patch set is to allow the SELECT portion of an INSERT ...
SELECT command to be planned and executed in parallel, even though the statement
as a whole modifies a table.

Modifying a table while in parallel mode is only safe if none of the objects
attached to the table involves parallel-unsafe or parallel-restricted functions:
triggers, index expressions and predicates, CHECK constraints, column default
expressions, and the partition key.  For a partitioned table, each partition is
checked recursively as well.

Checking all of that for every query is expensive, so cache the result in the
relation's relcache entry: rd_paralleldml holds the worst hazard level found
(one of the PROPARALLEL_xxx values), or zero if it has not been computed yet.
The new RelationGetParallelDmlSafety() function computes the value on first use
and returns the cached value thereafter.

The cached value is invalidated whenever a function's parallel-safety flag is
altered, or whenever a parallel-safety-relevant object is added to or dropped
from the table — or, for a partitioned table, from any of its partitions.

When a function's parallel safety changes, we invalidate the cached
parallel-safety flag in all relcache entries, rather than introducing heavier
locking or reverse-engineering the set of tables that reference the function.
Function-safety changes are expected to be rare, so this broad invalidation
should be acceptable.

Because we do not lock the function while altering its safety, a race is
possible: the safety flag could change after another backend has already used
the cached value to build its plan. This is no worse than current HEAD behavior,
since a function has always been free to be altered without blocking concurrent
DML.

When a partition's parallel safety changes, we invalidate the cached values of
all its ancestors in the partition tree. We deliberately do not take locks on
the ancestors: this avoids introducing new deadlock risk, and it avoids changing
locking behavior in a way that would make commands suddenly block normal DML
that previously ran unimpeded - a change that might be hard for users to reason
about. Find the ancestors without locking is safe because a partition cannot be
concurrently attached or detached - both ATTACH PARTITION and DETACH PARTITION
lock the child tables during DDL execution. The only remaining race is that an
unsafe object could be added to a partition while a parallel INSERT ... SELECT
is already executing; in that case, execution detects the hazard and raises an
ERROR before inserting into that partition. This rare, detectable execution-time
error is the trade-off for keeping the locking scheme simple.

Because the cached value lives in relcache, every new session must recompute it
on first use. This is true of all relcache data, but it is slightly more costly
here since the safety computation can be noticeable for tables with many
partitions. A possible future improvement is a fixed-size shared hash table
storing parallel-safety values, so that only the first session to touch a table
pays the computation cost while later sessions can reuse the result.

This patch implements the parallel-safety computation and its cache in relcache.
Subsequent patches will add the invalidation logic and enable parallel SELECT
for INSERT ... SELECT.

A new test module, test_parallel_dml_safety, exposes
RelationGetParallelDmlSafety() to SQL and exercises the computation for
each kind of parallel-safety-related object, including partitioned
tables.
---
 src/backend/optimizer/util/clauses.c          | 414 ++++++++++++++++++
 src/backend/utils/cache/relcache.c            |  44 ++
 src/include/optimizer/clauses.h               |   2 +
 src/include/utils/rel.h                       |  15 +
 src/include/utils/relcache.h                  |   1 +
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 .../modules/test_parallel_dml_safety/Makefile |  23 +
 .../expected/parallel_dml_safety.out          | 192 ++++++++
 .../test_parallel_dml_safety/meson.build      |  33 ++
 .../sql/parallel_dml_safety.sql               | 109 +++++
 .../test_parallel_dml_safety--1.0.sql         |   6 +
 .../test_parallel_dml_safety.c                |  46 ++
 .../test_parallel_dml_safety.control          |   4 +
 14 files changed, 891 insertions(+)
 create mode 100644 src/test/modules/test_parallel_dml_safety/Makefile
 create mode 100644 src/test/modules/test_parallel_dml_safety/expected/parallel_dml_safety.out
 create mode 100644 src/test/modules/test_parallel_dml_safety/meson.build
 create mode 100644 src/test/modules/test_parallel_dml_safety/sql/parallel_dml_safety.sql
 create mode 100644 src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety--1.0.sql
 create mode 100644 src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.c
 create mode 100644 src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.control

diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 337fc27262e..6d742734907 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -19,6 +19,7 @@
 
 #include "postgres.h"
 
+#include "access/genam.h"
 #include "access/htup_details.h"
 #include "access/table.h"
 #include "catalog/pg_class.h"
@@ -48,6 +49,7 @@
 #include "parser/parse_func.h"
 #include "parser/parse_oper.h"
 #include "parser/parsetree.h"
+#include "partitioning/partdesc.h"
 #include "rewrite/rewriteHandler.h"
 #include "rewrite/rewriteManip.h"
 #include "tcop/tcopprot.h"
@@ -60,7 +62,9 @@
 #include "utils/jsonpath.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
+#include "utils/partcache.h"
 #include "utils/rel.h"
+#include "utils/relcache.h"
 #include "utils/syscache.h"
 #include "utils/typcache.h"
 
@@ -98,6 +102,7 @@ typedef struct
 	char		max_hazard;		/* worst proparallel hazard found so far */
 	char		max_interesting;	/* worst proparallel hazard of interest */
 	List	   *safe_param_ids; /* PARAM_EXEC Param IDs to treat as safe */
+	PartitionDirectory partition_directory; /* partition descriptors */
 } max_parallel_hazard_context;
 
 /*
@@ -119,6 +124,22 @@ static bool contain_volatile_functions_walker(Node *node, void *context);
 static bool contain_volatile_functions_not_nextval_walker(Node *node, void *context);
 static bool max_parallel_hazard_walker(Node *node,
 									   max_parallel_hazard_context *context);
+static bool parallel_dml_hazard_walker(Node *node,
+									   max_parallel_hazard_context *context);
+static bool table_parallel_dml_hazard_recurse(Relation rel,
+											  max_parallel_hazard_context *context);
+static bool table_partitions_parallel_dml_hazard(Relation rel,
+												 max_parallel_hazard_context *context);
+static bool table_trigger_parallel_dml_hazard(Relation rel,
+											  max_parallel_hazard_context *context);
+static bool index_expr_parallel_dml_hazard(Relation index_rel,
+										   List *ii_Expressions,
+										   List *ii_Predicate,
+										   max_parallel_hazard_context *context);
+static bool table_index_parallel_dml_hazard(Relation rel,
+											max_parallel_hazard_context *context);
+static bool table_chk_constr_parallel_dml_hazard(Relation rel,
+												 max_parallel_hazard_context *context);
 static bool contain_nonstrict_functions_walker(Node *node, void *context);
 static bool contain_exec_param_walker(Node *node, List *param_ids);
 static bool contain_context_dependent_node(Node *clause);
@@ -767,6 +788,7 @@ max_parallel_hazard(Query *parse)
 	context.max_hazard = PROPARALLEL_SAFE;
 	context.max_interesting = PROPARALLEL_UNSAFE;
 	context.safe_param_ids = NIL;
+	context.partition_directory = NULL;
 	(void) max_parallel_hazard_walker((Node *) parse, &context);
 	return context.max_hazard;
 }
@@ -798,6 +820,7 @@ is_parallel_safe(PlannerInfo *root, Node *node)
 	context.max_hazard = PROPARALLEL_SAFE;
 	context.max_interesting = PROPARALLEL_RESTRICTED;
 	context.safe_param_ids = NIL;
+	context.partition_directory = NULL;
 
 	/*
 	 * The params that refer to the same or parent query level are considered
@@ -1001,6 +1024,397 @@ max_parallel_hazard_walker(Node *node, max_parallel_hazard_context *context)
 								  context);
 }
 
+/*
+ * The workhorse for RelationGetParallelDmlSafety(). Recursively examine a
+ * relation and all of the objects attached to it for PARALLEL UNSAFE/RESTRICTED
+ * constructs. Returns the worst hazard found, or PROPARALLEL_SAFE if none.
+ */
+char
+max_parallel_dml_hazard(Relation rel)
+{
+	max_parallel_hazard_context context;
+
+	context.max_hazard = PROPARALLEL_SAFE;
+	context.max_interesting = PROPARALLEL_UNSAFE;
+	context.safe_param_ids = NIL;
+	context.partition_directory = NULL;
+
+	(void) table_parallel_dml_hazard_recurse(rel, &context);
+
+	if (context.partition_directory != NULL)
+		DestroyPartitionDirectory(context.partition_directory);
+
+	return context.max_hazard;
+}
+
+/*
+ * parallel_dml_hazard_walker
+ *
+ * Recursively search an expression tree (defined as a partition key, index
+ * or check constraint, column default, or trigger WHEN clause) for PARALLEL
+ * UNSAFE/RESTRICTED functions.  Returns true if the maximum hazard of
+ * interest was found.
+ *
+ * CoerceToDomain is treated as parallel-restricted without examining the
+ * domain's constraints, following what max_parallel_hazard_walker() does
+ * for parallel query.
+ */
+static bool
+parallel_dml_hazard_walker(Node *node, max_parallel_hazard_context *context)
+{
+	if (node == NULL)
+		return false;
+
+	/* Check for hazardous functions in node itself */
+	if (check_functions_in_node(node, max_parallel_hazard_checker,
+								context))
+		return true;
+
+	if (IsA(node, CoerceToDomain))
+	{
+		if (max_parallel_hazard_test(PROPARALLEL_RESTRICTED, context))
+			return true;
+	}
+
+	/* Recurse to check arguments */
+	return expression_tree_walker(node,
+								  parallel_dml_hazard_walker,
+								  context);
+}
+
+/*
+ * table_parallel_dml_hazard_recurse
+ *
+ * Recursively examine a relation and all of the objects attached to it for
+ * PARALLEL UNSAFE/RESTRICTED constructs.  Returns true if the maximum
+ * hazard of interest was found.
+ */
+static bool
+table_parallel_dml_hazard_recurse(Relation rel,
+								  max_parallel_hazard_context *context)
+{
+	TupleDesc	tupdesc;
+	int			attnum;
+
+	/*
+	 * We can't support table modification in a parallel worker if it's a
+	 * foreign table/partition (no FDW API for supporting parallel access) or
+	 * a temporary table (workers cannot access the leader's temporary
+	 * tables).
+	 */
+	if (rel->rd_rel->relkind == RELKIND_FOREIGN_TABLE ||
+		RelationUsesLocalBuffers(rel))
+	{
+		if (max_parallel_hazard_test(PROPARALLEL_RESTRICTED, context))
+			return true;
+	}
+
+	/*
+	 * If a partitioned table or partition, check that the partition key and
+	 * each partition are safe for modification in parallel mode.
+	 */
+	if (table_partitions_parallel_dml_hazard(rel, context))
+		return true;
+
+	/*
+	 * If there are any index expressions or index predicates, check that
+	 * they are parallel-mode safe.
+	 */
+	if (table_index_parallel_dml_hazard(rel, context))
+		return true;
+
+	/*
+	 * If any triggers exist, check that they are parallel-safe.
+	 */
+	if (table_trigger_parallel_dml_hazard(rel, context))
+		return true;
+
+	tupdesc = RelationGetDescr(rel);
+	for (attnum = 0; attnum < tupdesc->natts; attnum++)
+	{
+		Form_pg_attribute att = TupleDescAttr(tupdesc, attnum);
+
+		/* We don't need info for dropped or generated attributes */
+		if (att->attisdropped || att->attgenerated)
+			continue;
+
+		/*
+		 * Column default expressions are only applicable to INSERT and
+		 * UPDATE.
+		 */
+		if (att->atthasdef)
+		{
+			Node	   *defaultexpr;
+
+			defaultexpr = build_column_default(rel, attnum + 1);
+			if (parallel_dml_hazard_walker(defaultexpr, context))
+				return true;
+		}
+	}
+
+	/*
+	 * CHECK constraints are only applicable to INSERT and UPDATE.  If any
+	 * CHECK constraints exist, determine if they are parallel-safe.
+	 */
+	if (table_chk_constr_parallel_dml_hazard(rel, context))
+		return true;
+
+	return false;
+}
+
+/*
+ * table_trigger_parallel_dml_hazard
+ *
+ * Check whether any of the relation's triggers involves parallel
+ * unsafe/restricted constructs, either in the trigger function or in the
+ * trigger's WHEN clause.  Returns true if the maximum hazard of interest
+ * was found.
+ */
+static bool
+table_trigger_parallel_dml_hazard(Relation rel,
+								  max_parallel_hazard_context *context)
+{
+	int			i;
+
+	if (rel->trigdesc == NULL)
+		return false;
+
+	/*
+	 * Care is needed here to avoid using the same relcache TriggerDesc field
+	 * across other cache accesses, because relcache doesn't guarantee that
+	 * it won't move.
+	 */
+	for (i = 0; i < rel->trigdesc->numtriggers; i++)
+	{
+		Oid			tgfoid = rel->trigdesc->triggers[i].tgfoid;
+		char	   *tgqual = rel->trigdesc->triggers[i].tgqual;
+
+		if (max_parallel_hazard_test(func_parallel(tgfoid), context))
+			return true;
+
+		if (tgqual != NULL &&
+			parallel_dml_hazard_walker(stringToNode(tgqual), context))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * index_expr_parallel_dml_hazard
+ *
+ * Check whether an index' expressions or predicate involve parallel
+ * unsafe/restricted functions.  Returns true if the maximum hazard of
+ * interest was found.
+ */
+static bool
+index_expr_parallel_dml_hazard(Relation index_rel,
+							   List *ii_Expressions,
+							   List *ii_Predicate,
+							   max_parallel_hazard_context *context)
+{
+	int			i;
+	Form_pg_index indexStruct;
+	ListCell   *index_expr_item;
+
+	indexStruct = index_rel->rd_index;
+	index_expr_item = list_head(ii_Expressions);
+
+	/* Check parallel-safety of index expressions */
+	for (i = 0; i < indexStruct->indnatts; i++)
+	{
+		int			keycol = indexStruct->indkey.values[i];
+
+		if (keycol == 0)
+		{
+			/* Found an index expression */
+			Node	   *index_expr;
+
+			Assert(index_expr_item != NULL);
+			if (index_expr_item == NULL)	/* shouldn't happen */
+				elog(ERROR, "too few entries in indexprs list");
+
+			index_expr = (Node *) lfirst(index_expr_item);
+
+			if (parallel_dml_hazard_walker(index_expr, context))
+				return true;
+
+			index_expr_item = lnext(ii_Expressions, index_expr_item);
+		}
+	}
+
+	/* Check parallel-safety of index predicate */
+	if (parallel_dml_hazard_walker((Node *) ii_Predicate, context))
+		return true;
+
+	return false;
+}
+
+/*
+ * table_index_parallel_dml_hazard
+ *
+ * Check whether any of the relation's indexes involves parallel
+ * unsafe/restricted functions.  Returns true if the maximum hazard of
+ * interest was found.
+ */
+static bool
+table_index_parallel_dml_hazard(Relation rel,
+								max_parallel_hazard_context *context)
+{
+	List	   *index_oid_list;
+	ListCell   *lc;
+	bool		result = false;
+
+	index_oid_list = RelationGetIndexList(rel);
+
+	foreach(lc, index_oid_list)
+	{
+		Oid			index_oid = lfirst_oid(lc);
+		Relation	index_rel;
+		List	   *ii_Expressions;
+		List	   *ii_Predicate;
+
+		index_rel = index_open(index_oid, AccessShareLock);
+
+		ii_Expressions = RelationGetIndexExpressions(index_rel);
+		ii_Predicate = RelationGetIndexPredicate(index_rel);
+
+		result = index_expr_parallel_dml_hazard(index_rel,
+												ii_Expressions,
+												ii_Predicate,
+												context);
+
+		index_close(index_rel, AccessShareLock);
+
+		if (result)
+			break;
+	}
+
+	list_free(index_oid_list);
+
+	return result;
+}
+
+/*
+ * table_partitions_parallel_dml_hazard
+ *
+ * Check whether the relation's partition key, and (recursively) each of its
+ * partitions if it's a partitioned table, involves parallel
+ * unsafe/restricted functions.  Returns true if the maximum hazard of
+ * interest was found.
+ */
+static bool
+table_partitions_parallel_dml_hazard(Relation rel,
+									 max_parallel_hazard_context *context)
+{
+	int			i;
+	PartitionDesc pdesc;
+	PartitionKey pkey;
+	ListCell   *partexprs_item;
+	int			partnatts;
+	List	   *partexprs;
+	List	   *qual;
+
+	/* Check parallel-safety of the partition partition bound expression */
+	qual = RelationGetPartitionQual(rel);
+	if (parallel_dml_hazard_walker((Node *) qual, context))
+		return true;
+
+	if (rel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
+		return false;
+
+	pkey = RelationGetPartitionKey(rel);
+
+	partnatts = get_partition_natts(pkey);
+	partexprs = get_partition_exprs(pkey);
+
+	partexprs_item = list_head(partexprs);
+	for (i = 0; i < partnatts; i++)
+	{
+		Oid			funcOid = pkey->partsupfunc[i].fn_oid;
+
+		if (OidIsValid(funcOid))
+		{
+			char		proparallel = func_parallel(funcOid);
+
+			if (max_parallel_hazard_test(proparallel, context))
+				return true;
+		}
+
+		/* Check parallel-safety of any expressions in the partition key */
+		if (get_partition_col_attnum(pkey, i) == 0)
+		{
+			Node	   *check_expr = (Node *) lfirst(partexprs_item);
+
+			if (parallel_dml_hazard_walker(check_expr, context))
+				return true;
+
+			partexprs_item = lnext(partexprs, partexprs_item);
+		}
+	}
+
+	/* Recursively check each partition ... */
+
+	/* Create the PartitionDirectory infrastructure if we didn't already */
+	if (context->partition_directory == NULL)
+		context->partition_directory =
+			CreatePartitionDirectory(CurrentMemoryContext, false);
+
+	pdesc = PartitionDirectoryLookup(context->partition_directory, rel);
+
+	for (i = 0; i < pdesc->nparts; i++)
+	{
+		Relation	part_rel;
+		char		part_max_hazard;
+
+		part_rel = table_open(pdesc->oids[i], AccessShareLock);
+		part_max_hazard = RelationGetParallelDmlSafety(part_rel);
+		table_close(part_rel, AccessShareLock);
+
+		if (max_parallel_hazard_test(part_max_hazard, context))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * table_chk_constr_parallel_dml_hazard
+ *
+ * Check whether any of the relation's CHECK constraints involves parallel
+ * unsafe/restricted functions.  Returns true if the maximum hazard of
+ * interest was found.
+ */
+static bool
+table_chk_constr_parallel_dml_hazard(Relation rel,
+									 max_parallel_hazard_context *context)
+{
+	int			i;
+	TupleDesc	tupdesc;
+	ConstrCheck *check;
+
+	tupdesc = RelationGetDescr(rel);
+
+	if (tupdesc->constr == NULL)
+		return false;
+
+	check = tupdesc->constr->check;
+
+	/*
+	 * Determine if there are any CHECK constraints which are not
+	 * parallel-safe.
+	 */
+	for (i = 0; i < tupdesc->constr->num_check; i++)
+	{
+		Expr	   *check_expr = stringToNode(check[i].ccbin);
+
+		if (parallel_dml_hazard_walker((Node *) check_expr, context))
+			return true;
+	}
+
+	return false;
+}
+
 
 /*****************************************************************************
  *		Check clauses for nonstrict functions
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 19c4ff6e75e..ef7f71deaa6 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -72,6 +72,7 @@
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
+#include "optimizer/clauses.h"
 #include "optimizer/optimizer.h"
 #include "pgstat.h"
 #include "rewrite/rewriteDefine.h"
@@ -1207,6 +1208,9 @@ retry:
 	relation->rd_fkeylist = NIL;
 	relation->rd_fkeyvalid = false;
 
+	/* parallel DML safety is not computed till asked for */
+	relation->rd_paralleldml = 0;
+
 	/* partitioning data is not loaded till asked for */
 	relation->rd_partkey = NULL;
 	relation->rd_partkeycxt = NULL;
@@ -6068,6 +6072,37 @@ RelationGetIndexAttOptions(Relation relation, bool copy)
 	return relation->rd_opcoptions;
 }
 
+/*
+ * RelationGetParallelDmlSafety
+ *		Return the worst parallel-safety hazard level (the earliest in this
+ *		list: PROPARALLEL_UNSAFE, PROPARALLEL_RESTRICTED, PROPARALLEL_SAFE)
+ *		for modifying the given relation while in parallel mode.
+ *
+ * Modifying a relation in parallel mode is unsafe if any of the objects
+ * attached to it involves a parallel-unsafe or parallel-restricted function:
+ * triggers, index expressions and predicates, CHECK constraints, column default
+ * expressions, or the partition key.  For a partitioned table, each partition
+ * is checked recursively as well.
+ *
+ * The cached value is invalidated whenever a parallel-safety-related object is
+ * added to or dropped from the relation, and when any function's parallel
+ * safety property changes; see CacheInvalidateParallelDmlSafety().
+ *
+ * See also max_parallel_dml_hazard() for details on how the hazard level is
+ * computed.
+ */
+char
+RelationGetParallelDmlSafety(Relation rel)
+{
+	/* Use the cached value if we have already computed it */
+	if (rel->rd_paralleldml != 0)
+		return rel->rd_paralleldml;
+
+	rel->rd_paralleldml = max_parallel_dml_hazard(rel);
+
+	return rel->rd_paralleldml;
+}
+
 /*
  * Routines to support ereport() reports of relation-related errors
  *
@@ -6528,6 +6563,15 @@ load_relcache_init_file(bool shared)
 		rel->rd_statlist = NIL;
 		rel->rd_fkeyvalid = false;
 		rel->rd_fkeylist = NIL;
+
+		/*
+		 * The cached parallel DML safety hazard level is never saved in the
+		 * init file; it must be recomputed on first use in each session.
+		 * This way, changes to the parallel safety of functions (which can
+		 * affect the hazard level of any relation) cannot leak to new
+		 * sessions via a stale init file.
+		 */
+		rel->rd_paralleldml = 0;
 		rel->rd_createSubid = InvalidSubTransactionId;
 		rel->rd_newRelfilelocatorSubid = InvalidSubTransactionId;
 		rel->rd_firstRelfilelocatorSubid = InvalidSubTransactionId;
diff --git a/src/include/optimizer/clauses.h b/src/include/optimizer/clauses.h
index 0e5a7b07404..d43f45d0f23 100644
--- a/src/include/optimizer/clauses.h
+++ b/src/include/optimizer/clauses.h
@@ -15,6 +15,7 @@
 #define CLAUSES_H
 
 #include "nodes/pathnodes.h"
+#include "utils/rel.h"
 
 typedef struct
 {
@@ -44,6 +45,7 @@ extern bool contain_subplans(Node *clause);
 
 extern char max_parallel_hazard(Query *parse);
 extern bool is_parallel_safe(PlannerInfo *root, Node *node);
+extern char max_parallel_dml_hazard(Relation rel);
 extern bool contain_nonstrict_functions(Node *clause);
 extern bool contain_exec_param(Node *clause, List *param_ids);
 extern bool contain_leaked_vars(Node *clause);
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 89c159b133f..c1c76aa0e7f 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -65,6 +65,21 @@ typedef struct RelationData
 								 * rd_replidindex) */
 	bool		rd_statvalid;	/* is rd_statlist valid? */
 
+	/*
+	 * rd_paralleldml caches the worst parallel-safety hazard level (one of
+	 * the PROPARALLEL_xxx values) found among the objects that affect the
+	 * safety of modifying this relation while in parallel mode, i.e. its
+	 * triggers, index expressions and predicates, check constraints,
+	 * column defaults, and partition key (recursively including its
+	 * partitions, if any).  It is zero if the hazard level has not been
+	 * computed yet; see RelationGetParallelDmlSafety().  The cached value is
+	 * reset to zero whenever a parallel-safety-related object is added to or
+	 * dropped from this relation or, for a partition, one of its ancestors
+	 * (in practice, addition or removal of such an object on the relation
+	 * itself normally also causes a full relcache invalidation).
+	 */
+	char		rd_paralleldml;
+
 	/*----------
 	 * rd_createSubid is the ID of the highest subtransaction the rel has
 	 * survived into or zero if the rel or its storage was created before the
diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h
index 89c27aa1529..f6244d88eac 100644
--- a/src/include/utils/relcache.h
+++ b/src/include/utils/relcache.h
@@ -61,6 +61,7 @@ extern List *RelationGetIndexExpressions(Relation relation);
 extern List *RelationGetDummyIndexExpressions(Relation relation);
 extern List *RelationGetIndexPredicate(Relation relation);
 extern bytea **RelationGetIndexAttOptions(Relation relation, bool copy);
+extern char RelationGetParallelDmlSafety(Relation rel);
 
 /*
  * Which set of columns to return by RelationGetIndexAttrBitmap.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 098bb8142ae..c36811bb8bb 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -39,6 +39,7 @@ SUBDIRS = \
 		  test_lwlock_tranches \
 		  test_misc \
 		  test_oat_hooks \
+		  test_parallel_dml_safety \
 		  test_parser \
 		  test_pg_dump \
 		  test_plan_advice \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..da01d6ea33e 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -40,6 +40,7 @@ subdir('test_lfind')
 subdir('test_lwlock_tranches')
 subdir('test_misc')
 subdir('test_oat_hooks')
+subdir('test_parallel_dml_safety')
 subdir('test_parser')
 subdir('test_pg_dump')
 subdir('test_plan_advice')
diff --git a/src/test/modules/test_parallel_dml_safety/Makefile b/src/test/modules/test_parallel_dml_safety/Makefile
new file mode 100644
index 00000000000..ca5b96e133e
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_parallel_dml_safety/Makefile
+
+MODULE_big = test_parallel_dml_safety
+OBJS = \
+	$(WIN32RES) \
+	test_parallel_dml_safety.o
+PGFILEDESC = "test_parallel_dml_safety - test code for parallel DML safety caching"
+
+EXTENSION = test_parallel_dml_safety
+DATA = test_parallel_dml_safety--1.0.sql
+
+REGRESS = parallel_dml_safety
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_parallel_dml_safety
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
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
new file mode 100644
index 00000000000..c9c83d39d9e
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/expected/parallel_dml_safety.out
@@ -0,0 +1,192 @@
+--
+-- Test computation and caching of relations' parallel DML safety hazard
+-- level.
+--
+CREATE EXTENSION test_parallel_dml_safety;
+-- helper functions with various parallel safety levels; use plpgsql so that
+-- the functions are not inlined into the stored expressions
+CREATE FUNCTION pdml_safe_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+CREATE FUNCTION pdml_restricted_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL RESTRICTED
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+CREATE FUNCTION pdml_unsafe_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL UNSAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+-- a plain table is parallel-safe; computing it twice must give the same result
+CREATE TABLE pdml_plain (a int);
+SELECT test_parallel_dml_safety('pdml_plain');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_plain');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- a parallel-safe trigger function doesn't change anything
+CREATE FUNCTION pdml_trg_safe_fn() RETURNS trigger LANGUAGE plpgsql PARALLEL SAFE
+  AS $$ BEGIN RETURN NEW; END $$;
+CREATE TRIGGER trg BEFORE INSERT ON pdml_plain
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_safe_fn();
+SELECT test_parallel_dml_safety('pdml_plain');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- a parallel-restricted trigger function makes the table restricted
+CREATE FUNCTION pdml_trg_restricted_fn() RETURNS trigger LANGUAGE plpgsql PARALLEL RESTRICTED
+  AS $$ BEGIN RETURN NEW; END $$;
+CREATE TABLE pdml_trg_r (a int);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_trg_r
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_restricted_fn();
+SELECT test_parallel_dml_safety('pdml_trg_r');
+ test_parallel_dml_safety 
+--------------------------
+ r
+(1 row)
+
+-- a parallel-unsafe trigger function makes the table unsafe
+CREATE FUNCTION pdml_trg_unsafe_fn() RETURNS trigger LANGUAGE plpgsql PARALLEL UNSAFE
+  AS $$ BEGIN RETURN NEW; END $$;
+CREATE TABLE pdml_trg_u (a int);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_trg_u
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety('pdml_trg_u');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- an unsafe function in a trigger WHEN clause makes the table unsafe
+CREATE TABLE pdml_trg_when (a int);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_trg_when
+  FOR EACH ROW WHEN (pdml_unsafe_fn(NEW.a))
+  EXECUTE FUNCTION pdml_trg_safe_fn();
+SELECT test_parallel_dml_safety('pdml_trg_when');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- check constraints
+CREATE TABLE pdml_chk_u (a int CHECK (pdml_unsafe_fn(a)));
+SELECT test_parallel_dml_safety('pdml_chk_u');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+CREATE TABLE pdml_chk_r (a int CHECK (pdml_restricted_fn(a)));
+SELECT test_parallel_dml_safety('pdml_chk_r');
+ test_parallel_dml_safety 
+--------------------------
+ r
+(1 row)
+
+-- index expression
+CREATE TABLE pdml_idx_expr (a int);
+CREATE INDEX ON pdml_idx_expr ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety('pdml_idx_expr');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- index predicate
+CREATE TABLE pdml_idx_pred (a int);
+CREATE INDEX ON pdml_idx_pred (a) WHERE pdml_unsafe_fn(a);
+SELECT test_parallel_dml_safety('pdml_idx_pred');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- column default expression
+CREATE TABLE pdml_def (a int, b text DEFAULT (CASE WHEN pdml_unsafe_fn(0) THEN 'x' ELSE 'y' END));
+SELECT test_parallel_dml_safety('pdml_def');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- domain constraints are deliberately not examined: like
+-- max_parallel_hazard_walker, CoerceToDomain is treated as parallel
+-- restricted without looking inside the domain, so a domain column never
+-- disqualifies the table
+CREATE DOMAIN pdml_dom_u AS int CHECK (pdml_unsafe_fn(VALUE));
+CREATE TABLE pdml_dom (a pdml_dom_u);
+SELECT test_parallel_dml_safety('pdml_dom');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+-- a coercion to a domain in a stored expression is restricted (which does
+-- not disqualify either), regardless of the domain's constraints
+CREATE TABLE pdml_dom_cast (a int DEFAULT (1::pdml_dom_u));
+SELECT test_parallel_dml_safety('pdml_dom_cast');
+ test_parallel_dml_safety 
+--------------------------
+ r
+(1 row)
+
+-- partitioned table: partition key support functions/expressions
+CREATE TABLE pdml_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_part_p1 PARTITION OF pdml_part FOR VALUES FROM (0) TO (100);
+SELECT test_parallel_dml_safety('pdml_part');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_part_p1');
+ test_parallel_dml_safety 
+--------------------------
+ s
+(1 row)
+
+CREATE TABLE pdml_part_key (a int)
+  PARTITION BY RANGE ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety('pdml_part_key');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- partitioned table: an unsafe object on a partition affects the parent
+CREATE TABLE pdml_part_trg (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_part_trg_p1 PARTITION OF pdml_part_trg FOR VALUES FROM (0) TO (100);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_part_trg_p1
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety('pdml_part_trg');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+SELECT test_parallel_dml_safety('pdml_part_trg_p1');
+ test_parallel_dml_safety 
+--------------------------
+ u
+(1 row)
+
+-- foreign tables and temporary tables are restricted
+CREATE FOREIGN DATA WRAPPER pdml_fdw;
+CREATE SERVER pdml_srv FOREIGN DATA WRAPPER pdml_fdw;
+CREATE FOREIGN TABLE pdml_ft (a int) SERVER pdml_srv;
+SELECT test_parallel_dml_safety('pdml_ft');
+ test_parallel_dml_safety 
+--------------------------
+ r
+(1 row)
+
+CREATE TEMP TABLE pdml_temp (a int);
+SELECT test_parallel_dml_safety('pdml_temp');
+ test_parallel_dml_safety 
+--------------------------
+ r
+(1 row)
+
diff --git a/src/test/modules/test_parallel_dml_safety/meson.build b/src/test/modules/test_parallel_dml_safety/meson.build
new file mode 100644
index 00000000000..e12f94c130c
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_parallel_dml_safety_sources = files(
+  'test_parallel_dml_safety.c',
+)
+
+if host_system == 'windows'
+  test_parallel_dml_safety_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_parallel_dml_safety',
+    '--FILEDESC', 'test_parallel_dml_safety - test code for parallel DML safety caching',])
+endif
+
+test_parallel_dml_safety = shared_module('test_parallel_dml_safety',
+  test_parallel_dml_safety_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_parallel_dml_safety
+
+test_install_data += files(
+  'test_parallel_dml_safety.control',
+  'test_parallel_dml_safety--1.0.sql',
+)
+
+tests += {
+  'name': 'test_parallel_dml_safety',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'parallel_dml_safety',
+    ],
+  },
+}
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
new file mode 100644
index 00000000000..f502cf793d3
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/sql/parallel_dml_safety.sql
@@ -0,0 +1,109 @@
+--
+-- Test computation and caching of relations' parallel DML safety hazard
+-- level.
+--
+
+CREATE EXTENSION test_parallel_dml_safety;
+
+-- helper functions with various parallel safety levels; use plpgsql so that
+-- the functions are not inlined into the stored expressions
+CREATE FUNCTION pdml_safe_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+CREATE FUNCTION pdml_restricted_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL RESTRICTED
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+CREATE FUNCTION pdml_unsafe_fn(int) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL UNSAFE
+  AS $$ BEGIN RETURN $1 > 0; END $$;
+
+-- a plain table is parallel-safe; computing it twice must give the same result
+CREATE TABLE pdml_plain (a int);
+SELECT test_parallel_dml_safety('pdml_plain');
+SELECT test_parallel_dml_safety('pdml_plain');
+
+-- a parallel-safe trigger function doesn't change anything
+CREATE FUNCTION pdml_trg_safe_fn() RETURNS trigger LANGUAGE plpgsql PARALLEL SAFE
+  AS $$ BEGIN RETURN NEW; END $$;
+CREATE TRIGGER trg BEFORE INSERT ON pdml_plain
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_safe_fn();
+SELECT test_parallel_dml_safety('pdml_plain');
+
+-- a parallel-restricted trigger function makes the table restricted
+CREATE FUNCTION pdml_trg_restricted_fn() RETURNS trigger LANGUAGE plpgsql PARALLEL RESTRICTED
+  AS $$ BEGIN RETURN NEW; END $$;
+CREATE TABLE pdml_trg_r (a int);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_trg_r
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_restricted_fn();
+SELECT test_parallel_dml_safety('pdml_trg_r');
+
+-- a parallel-unsafe trigger function makes the table unsafe
+CREATE FUNCTION pdml_trg_unsafe_fn() RETURNS trigger LANGUAGE plpgsql PARALLEL UNSAFE
+  AS $$ BEGIN RETURN NEW; END $$;
+CREATE TABLE pdml_trg_u (a int);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_trg_u
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety('pdml_trg_u');
+
+-- an unsafe function in a trigger WHEN clause makes the table unsafe
+CREATE TABLE pdml_trg_when (a int);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_trg_when
+  FOR EACH ROW WHEN (pdml_unsafe_fn(NEW.a))
+  EXECUTE FUNCTION pdml_trg_safe_fn();
+SELECT test_parallel_dml_safety('pdml_trg_when');
+
+-- check constraints
+CREATE TABLE pdml_chk_u (a int CHECK (pdml_unsafe_fn(a)));
+SELECT test_parallel_dml_safety('pdml_chk_u');
+CREATE TABLE pdml_chk_r (a int CHECK (pdml_restricted_fn(a)));
+SELECT test_parallel_dml_safety('pdml_chk_r');
+
+-- index expression
+CREATE TABLE pdml_idx_expr (a int);
+CREATE INDEX ON pdml_idx_expr ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety('pdml_idx_expr');
+
+-- index predicate
+CREATE TABLE pdml_idx_pred (a int);
+CREATE INDEX ON pdml_idx_pred (a) WHERE pdml_unsafe_fn(a);
+SELECT test_parallel_dml_safety('pdml_idx_pred');
+
+-- column default expression
+CREATE TABLE pdml_def (a int, b text DEFAULT (CASE WHEN pdml_unsafe_fn(0) THEN 'x' ELSE 'y' END));
+SELECT test_parallel_dml_safety('pdml_def');
+
+-- domain constraints are deliberately not examined: like
+-- max_parallel_hazard_walker, CoerceToDomain is treated as parallel
+-- restricted without looking inside the domain, so a domain column never
+-- disqualifies the table
+CREATE DOMAIN pdml_dom_u AS int CHECK (pdml_unsafe_fn(VALUE));
+CREATE TABLE pdml_dom (a pdml_dom_u);
+SELECT test_parallel_dml_safety('pdml_dom');
+-- a coercion to a domain in a stored expression is restricted (which does
+-- not disqualify either), regardless of the domain's constraints
+CREATE TABLE pdml_dom_cast (a int DEFAULT (1::pdml_dom_u));
+SELECT test_parallel_dml_safety('pdml_dom_cast');
+
+-- partitioned table: partition key support functions/expressions
+CREATE TABLE pdml_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_part_p1 PARTITION OF pdml_part FOR VALUES FROM (0) TO (100);
+SELECT test_parallel_dml_safety('pdml_part');
+SELECT test_parallel_dml_safety('pdml_part_p1');
+
+CREATE TABLE pdml_part_key (a int)
+  PARTITION BY RANGE ((CASE WHEN pdml_unsafe_fn(a) THEN a END));
+SELECT test_parallel_dml_safety('pdml_part_key');
+
+-- partitioned table: an unsafe object on a partition affects the parent
+CREATE TABLE pdml_part_trg (a int) PARTITION BY RANGE (a);
+CREATE TABLE pdml_part_trg_p1 PARTITION OF pdml_part_trg FOR VALUES FROM (0) TO (100);
+CREATE TRIGGER trg BEFORE INSERT ON pdml_part_trg_p1
+  FOR EACH ROW EXECUTE FUNCTION pdml_trg_unsafe_fn();
+SELECT test_parallel_dml_safety('pdml_part_trg');
+SELECT test_parallel_dml_safety('pdml_part_trg_p1');
+
+-- foreign tables and temporary tables are restricted
+CREATE FOREIGN DATA WRAPPER pdml_fdw;
+CREATE SERVER pdml_srv FOREIGN DATA WRAPPER pdml_fdw;
+CREATE FOREIGN TABLE pdml_ft (a int) SERVER pdml_srv;
+SELECT test_parallel_dml_safety('pdml_ft');
+
+CREATE TEMP TABLE pdml_temp (a int);
+SELECT test_parallel_dml_safety('pdml_temp');
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
new file mode 100644
index 00000000000..9cf44f51b9a
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety--1.0.sql
@@ -0,0 +1,6 @@
+-- src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety--1.0.sql
+
+CREATE FUNCTION test_parallel_dml_safety(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
new file mode 100644
index 00000000000..8a8d2d6f48a
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.c
@@ -0,0 +1,46 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_parallel_dml_safety.c
+ *	  Test code for caching of a relation's parallel DML safety hazard
+ *	  level in the relcache.
+ *
+ * This simply exposes RelationGetParallelDmlSafety() to SQL, so that
+ * regression tests can inspect the computed (and cached) hazard level of a
+ * relation and verify that it is invalidated when appropriate.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/table.h"
+#include "fmgr.h"
+#include "optimizer/clauses.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(test_parallel_dml_safety);
+
+/*
+ * Return the relation's parallel DML safety hazard level ('s', 'r' or 'u'),
+ * as computed and cached by RelationGetParallelDmlSafety().
+ */
+Datum
+test_parallel_dml_safety(PG_FUNCTION_ARGS)
+{
+	Oid			relid = PG_GETARG_OID(0);
+	Relation	rel;
+	char		hazard;
+
+	rel = table_open(relid, AccessShareLock);
+	hazard = RelationGetParallelDmlSafety(rel);
+	table_close(rel, AccessShareLock);
+
+	PG_RETURN_CHAR(hazard);
+}
diff --git a/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.control b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.control
new file mode 100644
index 00000000000..a1bc7a5e699
--- /dev/null
+++ b/src/test/modules/test_parallel_dml_safety/test_parallel_dml_safety.control
@@ -0,0 +1,4 @@
+comment = 'test code for parallel DML safety caching'
+default_version = '1.0'
+module_pathname = '$libdir/test_parallel_dml_safety'
+relocatable = true
-- 
2.43.0

